Skip to main content

whatsapp_rust/
pair_code.rs

1//! Pair code authentication for phone number linking.
2//!
3//! This module provides an alternative to QR code pairing. Users enter an
4//! 8-character code on their phone instead of scanning a QR code.
5//!
6//! # Usage
7//!
8//! ## Random Code (Default)
9//!
10//! ```rust,no_run
11//! use whatsapp_rust::pair_code::PairCodeOptions;
12//!
13//! # async fn example(client: std::sync::Arc<whatsapp_rust::Client>) -> Result<(), Box<dyn std::error::Error>> {
14//! let options = PairCodeOptions {
15//!     phone_number: "15551234567".to_string(),
16//!     ..Default::default()
17//! };
18//! let code = client.pair_with_code(options).await?;
19//! println!("Enter this code on your phone: {}", code);
20//! # Ok(())
21//! # }
22//! ```
23//!
24//! ## Custom Pairing Code
25//!
26//! You can specify your own 8-character code using Crockford Base32 alphabet
27//! (characters: `123456789ABCDEFGHJKLMNPQRSTVWXYZ` - excludes 0, I, O, U):
28//!
29//! ```rust,no_run
30//! use whatsapp_rust::pair_code::PairCodeOptions;
31//!
32//! # async fn example(client: std::sync::Arc<whatsapp_rust::Client>) -> Result<(), Box<dyn std::error::Error>> {
33//! let options = PairCodeOptions {
34//!     phone_number: "15551234567".to_string(),
35//!     custom_code: Some("MYCODE12".to_string()), // Must be exactly 8 valid chars
36//!     ..Default::default()
37//! };
38//! let code = client.pair_with_code(options).await?;
39//! assert_eq!(code, "MYCODE12");
40//! # Ok(())
41//! # }
42//! ```
43//!
44//! ## Concurrent with QR Codes
45//!
46//! Pair code and QR code run on the same connection, and whichever completes
47//! first wins — matching WA Web, which leaves its QR rotation running when the
48//! user switches to phone-number linking.
49//!
50//! They are not, however, the same clock. A QR code is superseded every 20s and
51//! the surface re-renders it; a pair code is read off a screen and typed into a
52//! phone minutes later, so **a QR rotation is not a reason to request a new
53//! pair code**. WA Web mints one per user action and regenerates it only on the
54//! server's `refresh_code`, on `force_manual_refresh`, or on its own expiry
55//! timers. [`Client::pair_with_code`] enforces that: it refuses to supersede a
56//! code that is still live, and [`Client::cancel_pair_code`] is the explicit
57//! way to replace one.
58
59use crate::client::Client;
60use crate::request::{InfoQuery, InfoQueryType, IqError};
61use crate::types::events::Event;
62use log::{error, info, warn};
63
64use std::sync::Arc;
65use wacore::libsignal::protocol::KeyPair;
66use wacore::pair_code::{PairCodeState, PairCodeUtils, resolve_companion_platform};
67use wacore_binary::Jid;
68use wacore_binary::{NodeContent, NodeContentRef, NodeRef};
69
70pub use wacore::companion_reg::{CompanionOs, CompanionWebClientType};
71pub use wacore::pair_code::{PairCodeError, PairCodeOptions, PairCodeRejection};
72
73/// Errors raised by the high-level pair-code flow.
74///
75/// Wraps `wacore::pair_code::PairCodeError` (validation, key derivation, bundle
76/// building) and adds the IQ transport layer via `RequestFailed`.
77#[derive(Debug, thiserror::Error)]
78#[non_exhaustive]
79pub enum PairError {
80    #[error("{0}")]
81    PairCode(#[from] PairCodeError),
82
83    /// The pair-code IQ was rejected by the server.
84    ///
85    /// Note the server returns `bad-request` (400) **both** for genuinely invalid
86    /// content and for **rate-limiting** — it throttles pair-code requests per
87    /// phone number and reuses the same error. So a 400 here is not necessarily a
88    /// permanent/invalid-input failure: back off and retry rather than treating
89    /// every 400 as fatal. (The lib canonicalizes the `companion_platform_display`
90    /// OS, so a display-shaped rejection is already ruled out — see
91    /// [`wacore::companion_reg::CompanionOs`].) Any server `backoff` hint is
92    /// preserved on the wrapped [`IqError`].
93    ///
94    /// Renders what it wraps, per the [rendering
95    /// convention](crate::error#rendering) — so the code and text reach a log
96    /// line that only prints the error, without the reader having to reach for
97    /// the `Debug` form.
98    #[error("{0}")]
99    RequestFailed(#[from] IqError),
100}
101
102// Imported inside each body, not at module scope: `ErrorChainExt::as_dyn_error`
103// would then be ambiguous with thiserror's own `AsDynError` for every `#[from]`
104// in this module.
105impl PairError {
106    /// How the server refused the request, as a status to branch on.
107    ///
108    /// `None` when nothing was refused: local validation, no connection, or a
109    /// request that went unanswered. Prefer this to matching the message, which
110    /// is not a stable surface.
111    ///
112    /// Classified from the `code` and `text` together, so a pairing WA Web
113    /// would not accept yields `None` rather than the named arm — see
114    /// [`PairCodeRejection::from_server`]. The refused-but-unclassifiable case
115    /// is therefore indistinguishable here from "nothing was refused"; both
116    /// mean the same thing to a consumer, which is that there is no typed
117    /// status to act on and the message is all there is.
118    pub fn rejection(&self) -> Option<PairCodeRejection> {
119        use crate::error::ErrorChainExt;
120        self.server_rejection()
121            .and_then(|rejection| PairCodeRejection::from_server(rejection.code, rejection.text))
122    }
123
124    /// Whether this request lost the pairing flow to someone else rather than
125    /// ending it — so its failure says nothing about whether a code arrives.
126    ///
127    /// These are the failures [`Event::PairingCodeError`] must stay silent for,
128    /// because its meaning is "no code is coming" and here that is not what
129    /// happened:
130    ///
131    /// - [`PairCodeError::CodeAlreadyOutstanding`] — refused *because* an
132    ///   earlier code is still inside its validity window. That code is on
133    ///   screen and may yet be entered; the consumer already has it from the
134    ///   [`Event::PairingCode`] that minted it.
135    /// - [`PairCodeError::Cancelled`] — the caller withdrew this request via
136    ///   [`Client::cancel_pair_code`], and a replacement may already own the
137    ///   slot. Reporting the *predecessor* would let a consumer read the live
138    ///   replacement as failed and tear down a code that is about to arrive.
139    ///
140    /// Both are consequences of something the caller did, so neither is news to
141    /// them, and a direct caller still receives the `Err` either way.
142    pub fn lost_the_flow_to_another_request(&self) -> bool {
143        matches!(
144            self,
145            Self::PairCode(PairCodeError::CodeAlreadyOutstanding { .. } | PairCodeError::Cancelled)
146        )
147    }
148
149    /// How long the server asked the client to wait before retrying, from the
150    /// `backoff` attribute.
151    ///
152    /// Usually `None` — the server rarely populates it on this request, and WA
153    /// Web never reads it — but a value here is the server naming its own delay,
154    /// which beats an interval the consumer picked.
155    pub fn backoff(&self) -> Option<std::time::Duration> {
156        use crate::error::ErrorChainExt;
157        self.server_rejection()
158            .and_then(|rejection| rejection.backoff)
159            .map(|secs| std::time::Duration::from_secs(u64::from(secs)))
160    }
161}
162
163impl Client {
164    /// Initiates pair code authentication as an alternative to QR code pairing.
165    ///
166    /// This method starts the phone number linking process. The returned code should
167    /// be displayed to the user, who then enters it on their phone in:
168    /// **WhatsApp > Linked Devices > Link a Device > Link with phone number instead**
169    ///
170    /// This can run concurrently with QR code pairing - whichever completes first wins.
171    ///
172    /// # One code at a time
173    ///
174    /// Fails with [`PairCodeError::CodeAlreadyOutstanding`] while a previously
175    /// issued code is still within its validity window. A second code does not
176    /// replace the first for the phone: the server routes `primary_hello` by
177    /// number, so whoever enters the older code still reaches stage 2 and is
178    /// answered with a key bundle their code cannot open — the phone reports a
179    /// failed link and nothing surfaces here. Call
180    /// [`Client::cancel_pair_code`] first when the replacement is intentional.
181    ///
182    /// In particular, do not drive this from QR-code rotation: the two have
183    /// unrelated lifetimes, and a code being typed into a phone outlives
184    /// several QR refs.
185    ///
186    /// # Arguments
187    ///
188    /// * `options` - Configuration for pair code authentication
189    ///
190    /// # Returns
191    ///
192    /// * `Ok(String)` - The 8-character pairing code to display
193    /// * `Err` - If validation fails, a code is already outstanding, not
194    ///   connected, or server error. A [`PairError::RequestFailed`] carrying
195    ///   `bad-request` may be **rate-limiting** (throttled per phone number),
196    ///   not invalid input — back off and retry.
197    ///
198    /// # Example
199    ///
200    /// ```rust,no_run
201    /// use whatsapp_rust::pair_code::PairCodeOptions;
202    ///
203    /// # async fn example(client: std::sync::Arc<whatsapp_rust::Client>) -> Result<(), Box<dyn std::error::Error>> {
204    /// let options = PairCodeOptions {
205    ///     phone_number: "15551234567".to_string(),
206    ///     show_push_notification: true,
207    ///     custom_code: None, // Generate random code
208    ///     ..Default::default()
209    /// };
210    ///
211    /// let code = client.pair_with_code(options).await?;
212    /// println!("Enter this code on your phone: {}", code);
213    /// # Ok(())
214    /// # }
215    /// ```
216    #[cfg_attr(
217        feature = "tracing",
218        tracing::instrument(name = "wa.pair.code", level = "debug", skip_all, err(Debug))
219    )]
220    pub async fn pair_with_code(
221        self: &Arc<Self>,
222        options: PairCodeOptions,
223    ) -> Result<String, PairError> {
224        // The failure is dispatched here rather than at each `return Err`
225        // below: stage 1 fails from a dozen places, and what a consumer needs
226        // from all of them is the same single fact — no code is coming. Wrapping
227        // the flow is also what stops a *later* early return from going
228        // unreported. `BotBuilder::with_pair_code` depends on it having no gaps,
229        // because it drives this from a detached task whose `Err` reaches nobody.
230        //
231        // Mirrors the success path, which likewise both returns the code and
232        // dispatches `Event::PairingCode`; a direct caller sees the failure
233        // twice, and a `with_pair_code` consumer sees it at all.
234        match self.pair_with_code_inner(options).await {
235            Ok(code) => Ok(code),
236            Err(e) if self.failure_is_not_this_flows_to_report(&e).await => Err(e),
237            Err(e) => {
238                self.core.event_bus.dispatch(Event::PairingCodeError(
239                    crate::types::events::PairingCodeError::builder()
240                        .maybe_rejection(e.rejection())
241                        .maybe_backoff(e.backoff())
242                        .error(e.to_string())
243                        .build(),
244                ));
245                Err(e)
246            }
247        }
248    }
249
250    /// Whether reporting this failure would speak for a flow that is not the
251    /// failed request's to speak for.
252    ///
253    /// The event means "no code is coming", so the question is not *how* the
254    /// request failed but whether a code is nonetheless on its way. Answered on
255    /// the state, not on the error variant: the variants that can reach here
256    /// while a flow is live are open-ended — a duplicate request, a withdrawn
257    /// one, its IQ timing out, or a second caller simply passing a bad phone
258    /// number while the first code is still on screen — and enumerating them
259    /// has already been wrong four times.
260    ///
261    /// [`PairCodeError::Cancelled`] is still matched explicitly, because a
262    /// cancellation with no replacement leaves the slot idle: nothing is live,
263    /// yet the caller asked for exactly this and does not need telling.
264    async fn failure_is_not_this_flows_to_report(self: &Arc<Self>, e: &PairError) -> bool {
265        if e.lost_the_flow_to_another_request() {
266            return true;
267        }
268        // A failing request that still owned the slot has released it by now, so
269        // an outstanding flow here belongs to somebody else.
270        self.pair_code_state
271            .lock()
272            .await
273            .is_outstanding(wacore::time::now_secs())
274    }
275
276    async fn pair_with_code_inner(
277        self: &Arc<Self>,
278        options: PairCodeOptions,
279    ) -> Result<String, PairError> {
280        // Strip non-digit characters from phone number (allows "+1-555-123-4567" format)
281        let phone_number: String = options
282            .phone_number
283            .chars()
284            .filter(|c| c.is_ascii_digit())
285            .collect();
286
287        // Validate phone number
288        if phone_number.is_empty() {
289            return Err(PairCodeError::PhoneNumberRequired.into());
290        }
291        if phone_number.len() < 7 {
292            return Err(PairCodeError::PhoneNumberTooShort.into());
293        }
294        if phone_number.starts_with('0') {
295            return Err(PairCodeError::PhoneNumberNotInternational.into());
296        }
297
298        // Generate or validate code
299        let code = match &options.custom_code {
300            Some(custom) => {
301                if !PairCodeUtils::validate_code(custom) {
302                    return Err(PairCodeError::InvalidCustomCode.into());
303                }
304                custom.to_uppercase()
305            }
306            None => PairCodeUtils::generate_code(),
307        };
308
309        // A second code does not replace the first for the *phone*: the server
310        // routes `primary_hello` by number, never seeing the code, so whoever
311        // is still reading the older one reaches stage 2 and is handed a key
312        // bundle their code cannot open — the phone reports a failed link and
313        // nothing surfaces here. WA Web makes the overlap impossible by
314        // guarding `startAltLinkingFlow` with `invariant(stage === Initialized)`
315        // (`Alt/DeviceLinkingApi.js`); `cancel_pair_code` is our
316        // `initializeAltDeviceLinking()`.
317        //
318        // Claimed under the same lock that reads it, because releasing it
319        // across the stage-1 round trip would let two concurrent callers both
320        // find the state idle. The stamp doubles as the validity clock, which
321        // WA Web also starts before the request (`startAltLinkingFlow` sets
322        // `codeGenerationTs` before sending), so the ~180s window covers the
323        // round trip rather than starting after it.
324        let code_generation_ts = wacore::time::now_secs();
325        let claim = wacore::pair_code::PairCodeClaim::next();
326        {
327            let mut state = self.pair_code_state.lock().await;
328            if state.is_outstanding(code_generation_ts) {
329                return Err(PairCodeError::CodeAlreadyOutstanding {
330                    remaining: state
331                        .live_flow_remaining(code_generation_ts)
332                        .unwrap_or_default(),
333                }
334                .into());
335            }
336            *state = PairCodeState::RequestingCode {
337                code_generation_ts,
338                claim,
339            };
340        }
341        // Every path out has to hand the claim back, including a caller who
342        // drops this future (a `timeout` shorter than the IQ's, say) — an
343        // orphaned claim rejects every later request for the rest of the
344        // validity window. Disarmed only once the flow is installed.
345        let mut claim_guard = ClaimGuard {
346            client: Arc::clone(self),
347            claim,
348            armed: true,
349        };
350
351        info!(
352            target: "Client/PairCode",
353            "Starting pair code authentication for phone: {}",
354            phone_number
355        );
356
357        // Generate ephemeral keypair for this pairing session
358        let ephemeral_keypair = KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>());
359
360        // Get device state for noise key
361        let device_snapshot = self.persistence_manager.get_device_snapshot();
362        let noise_static_pub: [u8; 32] = device_snapshot
363            .noise_key
364            .public_key
365            .public_key_bytes()
366            .try_into()
367            .expect("noise key is 32 bytes");
368
369        // Derive key and encrypt ephemeral pub (expensive PBKDF2 operation)
370        // Run in spawn_blocking to avoid stalling the async runtime
371        let code_clone = code.clone();
372        let ephemeral_pub: [u8; 32] = ephemeral_keypair
373            .public_key
374            .public_key_bytes()
375            .try_into()
376            .expect("ephemeral key is 32 bytes");
377
378        let wrapped_ephemeral = wacore::runtime::blocking(&*self.runtime, move || {
379            PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, &code_clone)
380        })
381        .await;
382
383        let (platform_id, platform_display) =
384            resolve_companion_platform(&options, &device_snapshot.device_props);
385        let platform_id_str = platform_id.to_string();
386
387        // Warn when a branding `DeviceProps::os` gets coerced to "Linux", so a
388        // consumer sees why it didn't ride through (the pair-code server rejects a
389        // non-OS display with bad-request; QR never sends this field). Skipped under
390        // a `display_os` override. Once-per-process: retries (PairError::RequestFailed
391        // is rate-limitable) reuse the same os, so repeating the warning is just noise.
392        static OS_COERCE_WARNED: std::sync::Once = std::sync::Once::new();
393        let os_overridden = options
394            .display_os
395            .as_deref()
396            .is_some_and(|o| !o.trim().is_empty());
397        if !os_overridden
398            && let Some(os) = device_snapshot.device_props.os.as_deref()
399            && !os.trim().is_empty()
400            && CompanionOs::classify(os).is_none()
401        {
402            OS_COERCE_WARNED.call_once(|| {
403                warn!(
404                    target: "Client/PairCode",
405                    "companion_platform_display OS {os:?} is not a recognized OS; coerced to \"Linux\" for pair-code (the server would reject a non-OS display with bad-request)"
406                );
407            });
408        }
409
410        let req_id = self.generate_request_id();
411        let iq_content = PairCodeUtils::build_companion_hello_iq(
412            &phone_number,
413            &noise_static_pub,
414            &wrapped_ephemeral,
415            &platform_id_str,
416            &platform_display,
417            options.show_push_notification,
418            req_id.clone(),
419        );
420
421        // Send the IQ and wait for response using the standard send_iq method
422        let query = InfoQuery {
423            query_type: InfoQueryType::Set,
424            namespace: "md",
425            to: Jid::new("", wacore_binary::Server::Pn),
426            target: None,
427            content: Some(NodeContent::Nodes(
428                iq_content
429                    .children()
430                    .map(|c| c.to_vec())
431                    .unwrap_or_default(),
432            )),
433            id: Some(req_id),
434            timeout: Some(std::time::Duration::from_secs(30)),
435        };
436
437        // The PBKDF2 above takes long enough for a `cancel_pair_code` to land.
438        // Sending anyway would put a second `companion_hello` on the server for
439        // this number, which then routes `primary_hello` to whichever it likes
440        // — the overlap the claim exists to prevent.
441        if !self.owns_code_claim(claim).await {
442            // Someone else owns the slot; releasing would take theirs.
443            claim_guard.armed = false;
444            return Err(PairCodeError::Cancelled.into());
445        }
446
447        let response = match self.send_iq(query).await {
448            Ok(response) => response,
449            Err(e) => {
450                // The same ownership recheck the success path does below, and
451                // for the same reason. A 30s IQ timeout easily outlives a
452                // `cancel_pair_code` plus its replacement, and reporting this
453                // request's transport failure would then put an uncorrelated
454                // error on the bus against the flow that now owns the slot.
455                // Losing the slot outranks how this request happened to end.
456                if !self.owns_code_claim(claim).await {
457                    claim_guard.armed = false;
458                    return Err(PairCodeError::Cancelled.into());
459                }
460                claim_guard.release_now().await;
461                return Err(e.into());
462            }
463        };
464
465        let Some(pairing_ref) = PairCodeUtils::parse_companion_hello_response(response.get())
466        else {
467            claim_guard.release_now().await;
468            return Err(PairCodeError::MissingPairingRef.into());
469        };
470
471        info!(
472            target: "Client/PairCode",
473            "Stage 1 complete, waiting for phone confirmation. Code: {}",
474            code
475        );
476
477        // Store state for when phone confirms, unless the claim was withdrawn
478        // while stage 1 was in flight: installing over a cancellation would
479        // revive a flow the caller asked to drop, and over a replacement would
480        // strand the code that replacement returned.
481        {
482            let mut state = self.pair_code_state.lock().await;
483            if !matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) {
484                claim_guard.armed = false;
485                return Err(PairCodeError::Cancelled.into());
486            }
487            *state = PairCodeState::WaitingForPhoneConfirmation {
488                pairing_ref,
489                phone_jid: phone_number,
490                pair_code: code.clone(),
491                ephemeral_keypair: Box::new(ephemeral_keypair),
492                code_generation_ts,
493                primary_hello_attempt_count: 0,
494            };
495            claim_guard.armed = false;
496        }
497
498        // Dispatch event for the user to display the code. The validity clock
499        // started at `code_generation_ts` (before stage 1), so advertise the
500        // *remaining* window — otherwise a consumer's countdown would outlast the
501        // server's (and our own `handle_primary_hello`) expiry by the stage-1
502        // elapsed time.
503        let elapsed = wacore::time::now_secs()
504            .saturating_sub(code_generation_ts)
505            .max(0) as u64;
506        let remaining =
507            PairCodeUtils::code_validity().saturating_sub(std::time::Duration::from_secs(elapsed));
508        self.core.event_bus.dispatch(Event::PairingCode(
509            crate::types::events::PairingCode::builder()
510                .code(code.clone())
511                .timeout(remaining)
512                .build(),
513        ));
514
515        Ok(code)
516    }
517
518    /// Hand back a claim taken by [`Self::pair_with_code`] when stage 1 failed.
519    ///
520    /// Identified by its token, so a claim already superseded — by a
521    /// cancellation, or by the replacement that followed one — is left alone.
522    async fn owns_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) -> bool {
523        matches!(&*self.pair_code_state.lock().await, PairCodeState::RequestingCode { claim: c, .. } if *c == claim)
524    }
525
526    async fn release_code_claim(self: &Arc<Self>, claim: wacore::pair_code::PairCodeClaim) {
527        let mut state = self.pair_code_state.lock().await;
528        if matches!(&*state, PairCodeState::RequestingCode { claim: c, .. } if *c == claim) {
529            *state = PairCodeState::Idle;
530        }
531    }
532
533    /// Abandons the outstanding pair-code flow, if any.
534    ///
535    /// The explicit reset [`Client::pair_with_code`] requires before it will
536    /// mint a replacement — WA Web's `initializeAltDeviceLinking()`. After this
537    /// the previous code can no longer complete: a `primary_hello` for it is
538    /// dropped rather than answered with a bundle its holder cannot open.
539    ///
540    /// A flow cancelled after it reached stage 2 also gives up the adv secret
541    /// that stage derived: it is keyed to a primary that will never link. A
542    /// [`PairCodeState::Completed`] flow does not, because that secret belongs
543    /// to a device that paired, and re-minting it would invalidate the
544    /// account's own ADV signatures.
545    pub async fn cancel_pair_code(self: &Arc<Self>) {
546        let mut state = self.pair_code_state.lock().await;
547        if matches!(&*state, PairCodeState::Idle) {
548            return;
549        }
550        let rotated_adv_secret = state.awaiting_pair_success();
551        *state = PairCodeState::Idle;
552        if rotated_adv_secret {
553            // Held across the write for the reason in `retire_stage_two_flow`.
554            replace_adv_secret_key(self).await;
555        }
556    }
557}
558
559/// Releases a stage-1 claim unless the flow it belongs to was installed.
560///
561/// Error paths call [`Self::release_now`]; `Drop` is the backstop for a caller
562/// that drops the future instead. It cannot await, so that release is spawned —
563/// the claim is identified by its token, which makes a late release harmless
564/// once something else has taken the slot.
565struct ClaimGuard {
566    client: Arc<Client>,
567    claim: wacore::pair_code::PairCodeClaim,
568    armed: bool,
569}
570
571impl ClaimGuard {
572    /// Hand the claim back before returning, so a caller that retries the
573    /// moment it sees the error does not race the detached release and get
574    /// `CodeAlreadyOutstanding` for a request that already failed. `Drop` is
575    /// left to cover only the caller who never sees the error at all.
576    async fn release_now(&mut self) {
577        self.armed = false;
578        self.client.release_code_claim(self.claim).await;
579    }
580}
581
582impl Drop for ClaimGuard {
583    fn drop(&mut self) {
584        if !self.armed {
585            return;
586        }
587        let client = Arc::clone(&self.client);
588        let claim = self.claim;
589        client.clone().runtime.spawn_detached(Box::pin(async move {
590            client.release_code_claim(claim).await;
591        }));
592    }
593}
594
595/// Handles a `link_code_companion_reg` notification. Dispatches on the child's
596/// `stage` attribute, mirroring WA Web `handleAltDeviceLinkingNotification`:
597/// `primary_hello` completes stage 2; `refresh_code` asks the companion to
598/// regenerate the code it is displaying.
599#[cfg_attr(
600    feature = "tracing",
601    tracing::instrument(name = "wa.pair.code_notification", level = "debug", skip_all)
602)]
603pub(crate) async fn handle_pair_code_notification(
604    client: &Arc<Client>,
605    node: &NodeRef<'_>,
606) -> bool {
607    let Some(reg_node) = node.get_optional_child_by_tag(&["link_code_companion_reg"]) else {
608        return false;
609    };
610
611    match reg_node.get_attr("stage").map(|v| v.as_str()).as_deref() {
612        Some("primary_hello") => handle_primary_hello(client, reg_node).await,
613        Some("refresh_code") => handle_refresh_code(client, reg_node).await,
614        other => {
615            warn!(
616                target: "Client/PairCode",
617                "Ignoring link_code_companion_reg notification with stage {other:?}"
618            );
619            false
620        }
621    }
622}
623
624/// Stage 2: the user entered the code on their phone. The notification carries
625/// the primary's encrypted ephemeral public key and identity public key.
626async fn handle_primary_hello(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bool {
627    // Extract primary's wrapped ephemeral public key (80 bytes: salt + iv + encrypted key)
628    let primary_wrapped_ephemeral = match reg_node
629        .get_optional_child_by_tag(&["link_code_pairing_wrapped_primary_ephemeral_pub"])
630        .and_then(|n| match n.content.as_ref() {
631            Some(NodeContentRef::Bytes(b)) if b.len() == 80 => Some(b.to_vec()),
632            _ => None,
633        }) {
634        Some(b) => b,
635        None => {
636            warn!(
637                target: "Client/PairCode",
638                "Missing or invalid primary wrapped ephemeral pub in notification"
639            );
640            return false;
641        }
642    };
643
644    // Extract primary's identity public key (32 bytes, unencrypted)
645    let primary_identity_pub: [u8; 32] = match reg_node
646        .get_optional_child_by_tag(&["primary_identity_pub"])
647        .and_then(|n| match n.content.as_ref() {
648            Some(NodeContentRef::Bytes(b)) if b.len() == 32 => b.as_ref().try_into().ok(),
649            _ => None,
650        }) {
651        Some(arr) => arr,
652        None => {
653            warn!(
654                target: "Client/PairCode",
655                "Missing or invalid primary identity pub in notification"
656            );
657            return false;
658        }
659    };
660
661    // Ref echoed by the primary. WA Web (`InvalidRefError`) rejects a
662    // primary_hello whose ref doesn't match the one from our companion_hello.
663    let notif_ref = match reg_node
664        .get_optional_child_by_tag(&["link_code_pairing_ref"])
665        .and_then(|n| match n.content.as_ref() {
666            Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
667            _ => None,
668        }) {
669        Some(r) => r,
670        None => {
671            warn!(target: "Client/PairCode", "primary_hello missing link_code_pairing_ref");
672            return false;
673        }
674    };
675
676    // Only the cheap guards run here. Everything they admit is handed to a
677    // task, because this function's return is what releases the stanza's ack:
678    // WA Web starts `handlePrimaryHello` without awaiting it and returns the
679    // ack in the same expression (`Alt/DeviceLinkingHandleNotification.js`),
680    // whereas running stage 2 inline puts a 131k-round PBKDF2 between the
681    // server's notification and our acknowledgement of it.
682    //
683    // The lock still serializes stage 2 end to end — see `run_stage_two`.
684    let mut state_guard = client.pair_code_state.lock().await;
685    let (pairing_ref, phone_jid, pair_code, ephemeral_keypair, attempt) = match &mut *state_guard {
686        PairCodeState::WaitingForPhoneConfirmation {
687            pairing_ref,
688            phone_jid,
689            pair_code,
690            ephemeral_keypair,
691            code_generation_ts,
692            primary_hello_attempt_count,
693        } => {
694            // Validate before counting: only a genuine, in-window attempt
695            // (matching ref, unexpired code) may spend a retry slot, so a
696            // stale/foreign or late notification — neither of which triggers
697            // a companion_finish — can't exhaust the budget.
698            if pairing_ref.as_slice() != notif_ref.as_slice() {
699                warn!(
700                    target: "Client/PairCode",
701                    "primary_hello ref does not match the outstanding request; ignoring"
702                );
703                return false;
704            }
705            let age = wacore::time::now_secs() - *code_generation_ts;
706            if age > PairCodeUtils::code_validity().as_secs() as i64 {
707                warn!(
708                    target: "Client/PairCode",
709                    "primary_hello arrived for an expired code ({age}s old); ignoring"
710                );
711                return false;
712            }
713            // Check the cap before bumping so a rejected attempt never pushes the
714            // counter past the limit (keeps it bounded at max).
715            if *primary_hello_attempt_count >= PairCodeUtils::max_primary_hello_attempts() {
716                warn!(
717                    target: "Client/PairCode",
718                    "Exceeded max primary_hello attempts for this code; abandoning"
719                );
720                return false;
721            }
722            *primary_hello_attempt_count += 1;
723            (
724                pairing_ref.clone(),
725                phone_jid.clone(),
726                pair_code.clone(),
727                (**ephemeral_keypair).clone(),
728                *primary_hello_attempt_count,
729            )
730        }
731        _ => {
732            warn!(
733                target: "Client/PairCode",
734                "Received primary_hello but not in waiting state"
735            );
736            return false;
737        }
738    };
739
740    info!(
741        target: "Client/PairCode",
742        "Phone confirmed code entry, processing stage 2"
743    );
744
745    // Released before the task runs: `run_stage_two` re-takes it.
746    drop(state_guard);
747
748    let client = Arc::clone(client);
749    // Armed on acceptance, matching WA Web: `primaryHelloReceivedAltLinking`
750    // fires before `handlePrimaryHelloInternal` runs, so the screen's clock
751    // starts on the notification. Waiting for a successful `companion_finish`
752    // would leave a failed stage 2 with no timeout at all — the case that most
753    // needs the consumer to hear about it.
754    start_pair_success_timeout(Arc::clone(&client), pairing_ref.clone(), attempt);
755    client.clone().runtime.spawn_detached(Box::pin(async move {
756        run_stage_two(
757            client,
758            pairing_ref,
759            phone_jid,
760            pair_code,
761            ephemeral_keypair,
762            primary_wrapped_ephemeral,
763            primary_identity_pub,
764            attempt,
765        )
766        .await;
767    }));
768    true
769}
770
771/// Derive the key bundle, persist the rotated adv secret, send
772/// `companion_finish`, and start the clock on the `pair-success` that should
773/// answer it.
774///
775/// Runs under the `pair_code_state` lock from derive through send. The
776/// transport dispatches `notification` stanzas on concurrent detached tasks
777/// (see `client/node_io.rs`), so without it two `primary_hello` for the same
778/// code could each derive a *different* random adv_secret and race
779/// `SetAdvSecretKey` (last-write-wins), leaving the persisted secret out of
780/// sync with the `companion_finish` the server acts on → pair-success HMAC
781/// failure. The state is kept (not taken) so a genuine retry can reuse it.
782///
783/// The lock stops at the send, not at the answer: ordering that pair is the
784/// whole reason it is held, and the answer takes no part in it. Keeping it
785/// across the round trip would block `cancel_pair_code` on a server that never
786/// replies — exactly the case this wait exists to detect.
787#[allow(clippy::too_many_arguments)]
788async fn run_stage_two(
789    client: Arc<Client>,
790    pairing_ref: Vec<u8>,
791    phone_jid: String,
792    pair_code: String,
793    ephemeral_keypair: KeyPair,
794    primary_wrapped_ephemeral: Vec<u8>,
795    primary_identity_pub: [u8; 32],
796    attempt: u32,
797) {
798    let state_guard = client.pair_code_state.lock().await;
799    // The flow can be retired while this task waits for the lock — by
800    // pair-success, a cancellation, or a replacement code. Matching the ref
801    // rather than the variant is what tells a replacement apart from our own
802    // flow: answering for one would persist a retired adv secret over the
803    // replacement's and put a `companion_finish` on the wire for a ref nobody
804    // holds.
805    let still_ours = matches!(
806        &*state_guard,
807        PairCodeState::WaitingForPhoneConfirmation { pairing_ref: current, .. }
808            if current.as_slice() == pairing_ref.as_slice()
809    );
810    if !still_ours {
811        return;
812    }
813
814    // Decrypt primary's ephemeral public key (expensive PBKDF2 operation)
815    // Run in spawn_blocking to avoid stalling the async runtime
816    let pair_code_clone = pair_code.clone();
817    let primary_ephemeral_pub = match wacore::runtime::blocking(&*client.runtime, move || {
818        PairCodeUtils::decrypt_primary_ephemeral_pub(&primary_wrapped_ephemeral, &pair_code_clone)
819    })
820    .await
821    {
822        Ok(pub_key) => pub_key,
823        Err(e) => {
824            error!(
825                target: "Client/PairCode",
826                "Failed to decrypt primary ephemeral pub: {e}"
827            );
828            return;
829        }
830    };
831
832    // Get device keys
833    let device_snapshot = client.persistence_manager.get_device_snapshot();
834
835    // Prepare encrypted key bundle (includes rotated adv_secret_key)
836    let (wrapped_bundle, new_adv_secret) = match PairCodeUtils::prepare_key_bundle(
837        &ephemeral_keypair,
838        &primary_ephemeral_pub,
839        &primary_identity_pub,
840        &device_snapshot.identity_key,
841    ) {
842        Ok(result) => result,
843        Err(e) => {
844            error!(target: "Client/PairCode", "Failed to prepare key bundle: {e}");
845            return;
846        }
847    };
848
849    // Persist rotated adv_secret_key so HMAC verification works in pair-success.
850    client
851        .persistence_manager
852        .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
853            new_adv_secret,
854        ))
855        .await;
856
857    // Build and send stage 2 IQ
858    let req_id = client.generate_request_id();
859    let identity_pub: [u8; 32] = device_snapshot
860        .identity_key
861        .public_key
862        .public_key_bytes()
863        .try_into()
864        .expect("identity key is 32 bytes");
865
866    let iq = PairCodeUtils::build_companion_finish_iq(
867        &phone_jid,
868        wrapped_bundle,
869        &identity_pub,
870        &pairing_ref,
871        req_id,
872    );
873
874    // Dropping the hook without calling it releases the guard too, so a send
875    // that never happened does not strand the lock either.
876    let answer = client
877        .send_iq_node_then(
878            iq,
879            Some(PairCodeUtils::companion_finish_iq_timeout()),
880            Some(Box::new(move || drop(state_guard))),
881        )
882        .await;
883
884    match answer {
885        Ok(_) => {
886            info!(
887                target: "Client/PairCode",
888                "Sent companion_finish, waiting for pair-success"
889            );
890            // State stays WaitingForPhoneConfirmation so a retry can reuse it;
891            // only pair-success (see `crate::pair`) transitions to Completed.
892            // The timeout that answers for this send was armed by the caller,
893            // on acceptance.
894        }
895        Err(e) => report_stage_two_failure(&client, &pairing_ref, attempt, e).await,
896    }
897}
898
899/// Write the flow off and tell the consumer why, when `companion_finish` itself
900/// failed.
901///
902/// WA Web treats this as terminal rather than as something to retry: its RPC
903/// raises `CompanionFinishError` for any answer that is not
904/// `CompanionFinishResponseSuccess` (`Alt/DeviceLinkingIq.js`), the throw
905/// reaches `handlePrimaryHello`, and the screen shows "Something went wrong.
906/// Please try again or link with the QR code" and returns to the number entry
907/// (`Link/DevicePhoneNumber.react.js`). So this reports and stops; it does not
908/// resend.
909///
910/// Without it the refusal was invisible: the IQ went out unanswered-for, its
911/// response matched no waiter, and the consumer learnt only from the one-minute
912/// silence timer — with no way to tell a refused bundle from a primary that
913/// went quiet.
914///
915/// A timeout is the one failure that does not retire anything. It carries no
916/// news: silence is already what
917/// [`start_pair_success_timeout`] is watching for, over a longer window, and
918/// cutting the flow short here would take a link the server may still be
919/// completing.
920async fn report_stage_two_failure(
921    client: &Arc<Client>,
922    pairing_ref: &[u8],
923    attempt: u32,
924    error: IqError,
925) {
926    if error.is_timeout() {
927        warn!(
928            target: "Client/PairCode",
929            "companion_finish went unanswered; leaving the pair-success timer to write the code off"
930        );
931        return;
932    }
933
934    error!(target: "Client/PairCode", "companion_finish failed: {error}");
935    if !retire_stage_two_flow(client, pairing_ref, attempt).await {
936        return;
937    }
938
939    let error = PairError::from(error);
940    client.core.event_bus.dispatch(Event::PairingCodeError(
941        crate::types::events::PairingCodeError::builder()
942            .maybe_rejection(error.rejection())
943            .maybe_backoff(error.backoff())
944            .error(error.to_string())
945            .build(),
946    ));
947}
948
949/// Write the code off if `pair-success` never answers `companion_finish`.
950///
951/// The primary reading the code proves nothing about the outcome: if it cannot
952/// open the key bundle — which is what a superseded or mistyped code looks like
953/// from here — it reports a failed link to its own user and says nothing to us.
954/// WA Web treats that silence as the failure signal, arming a one-minute timer
955/// on `primary_hello_received` and regenerating the code when it fires
956/// (`Link/DevicePhoneNumberCodeScreen.react.js`).
957fn start_pair_success_timeout(client: Arc<Client>, pairing_ref: Vec<u8>, attempt: u32) {
958    let timeout = PairCodeUtils::primary_hello_pair_success_timeout();
959    client.clone().runtime.spawn_detached(Box::pin(async move {
960        client.runtime.sleep(timeout).await;
961
962        if !retire_stage_two_flow(&client, &pairing_ref, attempt).await {
963            return;
964        }
965
966        warn!(
967            target: "Client/PairCode",
968            "No pair-success within {timeout:?} of companion_finish; the code will not complete"
969        );
970        client.core.event_bus.dispatch(Event::PairingCodeRefresh(
971            crate::types::events::PairingCodeRefresh::builder()
972                .force_manual(false)
973                .build(),
974        ));
975    }));
976}
977
978/// Retire a flow whose stage 2 will not complete, and return whether this
979/// caller is the one that retired it.
980///
981/// Keyed on the ref *and* the attempt: pair-success, a cancellation, or a
982/// replacement code all leave a state this does not match, and a retry accepted
983/// partway through the window owns its own attempt — so neither the timeout nor
984/// a failed `companion_finish` may write off a flow that moved on.
985///
986/// The state is cleared before the caller's event goes out, so a consumer
987/// acting on it is not turned away by the very flow it was told to replace.
988async fn retire_stage_two_flow(client: &Arc<Client>, pairing_ref: &[u8], attempt: u32) -> bool {
989    let mut state = client.pair_code_state.lock().await;
990    let still_ours = matches!(
991        &*state,
992        PairCodeState::WaitingForPhoneConfirmation {
993            pairing_ref: r,
994            primary_hello_attempt_count,
995            ..
996        } if r.as_slice() == pairing_ref
997            && *primary_hello_attempt_count == attempt
998    );
999    if !still_ours {
1000        return false;
1001    }
1002    *state = PairCodeState::Idle;
1003    // Under the same guard that retired the flow. `handle_pair_success` takes
1004    // this lock too, so releasing it first would open a window where a late
1005    // pair-success completes against the old secret and this then overwrites
1006    // the secret of a device that just paired.
1007    replace_adv_secret_key(client).await;
1008    true
1009}
1010
1011/// Re-mint the device's adv secret because the flow that rotated it is dead.
1012///
1013/// Stage 2 derives the adv secret from the key bundle and persists it before
1014/// `companion_finish` goes out, so a flow that ends there leaves the device
1015/// holding a secret only the primary that failed to link could ever match. WA
1016/// Web sheds the same value whenever a linking flow restarts —
1017/// `initializeAltDeviceLinking` clears it and `initializeQRLinking` generates a
1018/// fresh one (`Alt/DeviceLinkingApi.js`).
1019///
1020/// Generated rather than cleared, because `Device::adv_secret_key` is a
1021/// `[u8; 32]` and has no absent value: an all-zero secret would be a usable key
1022/// that happens to be wrong, which is worse than an unrelated random one. A
1023/// later QR flow carries whatever is stored inside the code itself, so a fresh
1024/// secret is as good as the old one there.
1025async fn replace_adv_secret_key(client: &Arc<Client>) {
1026    use rand::RngExt as _;
1027    let mut adv_secret_key = [0u8; 32];
1028    rand::make_rng::<rand::rngs::StdRng>().fill(&mut adv_secret_key);
1029    client
1030        .persistence_manager
1031        .process_command(crate::store::commands::DeviceCommand::SetAdvSecretKey(
1032            adv_secret_key,
1033        ))
1034        .await;
1035    // The QR payload embeds this key, and a code already on screen would now
1036    // pair against a secret nothing matches — see `Client::refresh_pairing_qr`.
1037    client.refresh_pairing_qr().await;
1038}
1039
1040/// The server asked us to refresh the code we are displaying (WA Web
1041/// `refreshAltLinkingCode` / `forceManualRefresh`). Surfaces a
1042/// [`Event::PairingCodeRefresh`] so the consumer re-requests a code, but only
1043/// when the notification's ref matches the flow currently in progress.
1044async fn handle_refresh_code(client: &Arc<Client>, reg_node: &NodeRef<'_>) -> bool {
1045    let notif_ref = match reg_node
1046        .get_optional_child_by_tag(&["link_code_pairing_ref"])
1047        .and_then(|n| match n.content.as_ref() {
1048            Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
1049            _ => None,
1050        }) {
1051        Some(r) => r,
1052        None => {
1053            warn!(target: "Client/PairCode", "refresh_code missing link_code_pairing_ref");
1054            return false;
1055        }
1056    };
1057
1058    let force_manual = reg_node
1059        .get_attr("force_manual_refresh")
1060        .map(|v| v.as_str().as_ref() == "true")
1061        .unwrap_or(false);
1062
1063    // Ignore a refresh whose ref doesn't match the outstanding code — matches
1064    // WA Web's `getCurrentRef()` guard. A matching one retires the flow on the
1065    // spot: the consumer is being told to request a replacement, and leaving
1066    // the old flow standing would make `pair_with_code` reject it. WA Web does
1067    // the same, re-running `initializeAltDeviceLinking()` on the
1068    // `force_manual_refresh` path.
1069    let matches_current = {
1070        let mut state_guard = client.pair_code_state.lock().await;
1071        let matches = matches!(
1072            &*state_guard,
1073            PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. }
1074                if pairing_ref.as_slice() == notif_ref.as_slice()
1075        );
1076        if matches {
1077            *state_guard = PairCodeState::Idle;
1078        }
1079        matches
1080    };
1081    if !matches_current {
1082        warn!(
1083            target: "Client/PairCode",
1084            "refresh_code ref does not match the outstanding request; ignoring"
1085        );
1086        return false;
1087    }
1088
1089    info!(
1090        target: "Client/PairCode",
1091        "Server requested pair-code refresh (force_manual={force_manual})"
1092    );
1093    client.core.event_bus.dispatch(Event::PairingCodeRefresh(
1094        crate::types::events::PairingCodeRefresh::builder()
1095            .force_manual(force_manual)
1096            .build(),
1097    ));
1098    true
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103    use super::*;
1104
1105    /// Pin the five arms against `WASmaxInMdIqMixinErrors.parseIqMixinErrors`,
1106    /// the complete set WA Web's `companion_hello` response parser accepts, so a
1107    /// renumber can't silently break a consumer's branching.
1108    #[test]
1109    fn rejection_codes_match_wa_web() {
1110        assert_eq!(PairCodeRejection::BadRequest.code(), 400);
1111        assert_eq!(PairCodeRejection::Forbidden.code(), 403);
1112        assert_eq!(PairCodeRejection::RateOverlimit.code(), 429);
1113        assert_eq!(PairCodeRejection::FeatureNotAvailable.code(), 452);
1114        assert_eq!(PairCodeRejection::InternalServerError.code(), 500);
1115        // A code outside WA Web's set keeps its number rather than collapsing
1116        // into a named arm.
1117        assert_eq!(
1118            PairCodeRejection::from(418),
1119            PairCodeRejection::Unknown(418)
1120        );
1121    }
1122
1123    /// `CodeAlreadyOutstanding` is the one failure that must *not* dispatch: a
1124    /// code is still live, the consumer already has it from the `PairingCode`
1125    /// that minted it, and an error event would say the opposite while inviting
1126    /// a retry loop nothing but `cancel_pair_code` or expiry can break.
1127    #[tokio::test]
1128    async fn an_outstanding_code_is_not_reported_as_a_failure() {
1129        let client = create_test_client().await;
1130        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1131        client.subscribe_handler(collector.clone()).detach();
1132
1133        // Park a live code in the slot so the next request is the duplicate.
1134        let now = wacore::time::now_secs();
1135        *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
1136            code_generation_ts: now,
1137            claim: wacore::pair_code::PairCodeClaim::next(),
1138        };
1139
1140        let err = client
1141            .pair_with_code(PairCodeOptions {
1142                phone_number: "15551234567".to_string(),
1143                ..Default::default()
1144            })
1145            .await
1146            .expect_err("a second code must be refused while one is live");
1147        assert!(
1148            err.lost_the_flow_to_another_request(),
1149            "expected CodeAlreadyOutstanding, got: {err:?}"
1150        );
1151
1152        // Let any dispatch that was going to happen get through.
1153        tokio::task::yield_now().await;
1154        assert!(
1155            !collector
1156                .events()
1157                .iter()
1158                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1159            "a still-live code must not be reported as 'no code is coming'"
1160        );
1161    }
1162
1163    /// A request cancelled while its `companion_hello` is in flight must not
1164    /// report either. It resolves *after* a replacement may already own the
1165    /// slot, so the event would be uncorrelated with the flow actually running
1166    /// and could make a consumer tear down a live code.
1167    #[tokio::test]
1168    async fn a_superseded_request_is_not_reported_as_a_failure() {
1169        let (client, transport) = create_iq_test_client().await;
1170        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1171        client.subscribe_handler(collector.clone()).detach();
1172
1173        let pending = {
1174            let client = client.clone();
1175            tokio::spawn(async move { client.pair_with_code(options()).await })
1176        };
1177        poll_until("the companion_hello to be on the wire", || {
1178            !transport.sent().is_empty()
1179        })
1180        .await;
1181
1182        client.cancel_pair_code().await;
1183        answer_companion_hello(&client, &transport, 0, b"3@2:late").await;
1184
1185        let err = pending
1186            .await
1187            .expect("the pair-code task should not panic")
1188            .expect_err("a cancelled request must not report a usable code");
1189        assert!(
1190            err.lost_the_flow_to_another_request(),
1191            "expected Cancelled, got {err:?}"
1192        );
1193
1194        tokio::task::yield_now().await;
1195        assert!(
1196            !collector
1197                .events()
1198                .iter()
1199                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1200            "a withdrawn request must not report against the flow that replaced it"
1201        );
1202    }
1203
1204    /// The same suppression must hold when the withdrawn request ends in an *IQ
1205    /// failure* rather than a late success. A 30 s timeout or a server rejection
1206    /// easily outlives a `cancel_pair_code` plus its replacement, and reporting
1207    /// this request's transport failure would then land on the flow that now
1208    /// owns the slot.
1209    #[tokio::test]
1210    async fn a_withdrawn_request_reports_cancellation_not_its_iq_failure() {
1211        let (client, transport) = create_iq_test_client().await;
1212        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1213        client.subscribe_handler(collector.clone()).detach();
1214
1215        let pending = {
1216            let client = client.clone();
1217            tokio::spawn(async move { client.pair_with_code(options()).await })
1218        };
1219        poll_until("the companion_hello to be on the wire", || {
1220            !transport.sent().is_empty()
1221        })
1222        .await;
1223
1224        client.cancel_pair_code().await;
1225
1226        // Refuse the withdrawn request's IQ, rather than answering it.
1227        let hello = crate::test_utils::decode_sent_iq(&transport, 0).await;
1228        let id = hello
1229            .get()
1230            .attrs()
1231            .optional_string("id")
1232            .expect("companion_hello carries an id")
1233            .into_owned();
1234        let refusal = NodeBuilder::new("iq")
1235            .attrs([
1236                ("from", "s.whatsapp.net".to_string()),
1237                ("type", "error".to_string()),
1238                ("id", id.clone()),
1239            ])
1240            .children([NodeBuilder::new("error")
1241                .attrs([
1242                    ("code", "429".to_string()),
1243                    ("text", "rate-overlimit".to_string()),
1244                ])
1245                .build()])
1246            .build();
1247        crate::test_utils::answer_iq(&client, &id, &refusal).await;
1248
1249        let err = pending
1250            .await
1251            .expect("the pair-code task should not panic")
1252            .expect_err("a withdrawn request must not report a usable code");
1253        assert!(
1254            err.lost_the_flow_to_another_request(),
1255            "losing the slot outranks how the request ended, got {err:?}"
1256        );
1257
1258        tokio::task::yield_now().await;
1259        assert!(
1260            !collector
1261                .events()
1262                .iter()
1263                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1264            "a withdrawn request's IQ failure must not report against its replacement"
1265        );
1266    }
1267
1268    /// Validation runs before the outstanding-flow check, so a second caller
1269    /// with a bad number fails as `PhoneNumberTooShort` and never reaches the
1270    /// suppressed variants. It must still stay silent while a code is live —
1271    /// which is why the suppression asks the state, not the error.
1272    #[tokio::test]
1273    async fn a_validation_failure_beside_a_live_code_is_not_reported() {
1274        let client = create_test_client().await;
1275        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1276        client.subscribe_handler(collector.clone()).detach();
1277
1278        *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
1279            code_generation_ts: wacore::time::now_secs(),
1280            claim: wacore::pair_code::PairCodeClaim::next(),
1281        };
1282
1283        let err = client
1284            .pair_with_code(PairCodeOptions {
1285                phone_number: "123".to_string(),
1286                ..Default::default()
1287            })
1288            .await
1289            .expect_err("a 3-digit number must be refused");
1290        assert!(
1291            matches!(err, PairError::PairCode(PairCodeError::PhoneNumberTooShort)),
1292            "validation must still win the race it already wins, got {err:?}"
1293        );
1294
1295        tokio::task::yield_now().await;
1296        assert!(
1297            !collector
1298                .events()
1299                .iter()
1300                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1301            "a live code must not be reported as failed by an unrelated bad request"
1302        );
1303    }
1304
1305    /// WA Web asserts `code` and `text` as a pair and falls to its generic
1306    /// error path when they disagree, so a contradicting text must not keep
1307    /// reading as the named arm.
1308    #[test]
1309    fn a_contradicting_text_yields_no_classification() {
1310        let pe: PairError = IqError::ServerError {
1311            code: 429,
1312            text: "something-else".into(),
1313            error_type: None,
1314            backoff: None,
1315        }
1316        .into();
1317
1318        assert_eq!(
1319            pe.rejection(),
1320            None,
1321            "a pairing WA Web would reject must not drive throttle handling"
1322        );
1323        // The code is still recoverable from the rendering, so refusing to
1324        // classify does not lose it.
1325        assert!(pe.to_string().contains("429"), "got: {pe}");
1326    }
1327
1328    /// An absent `text` is not a contradiction. Deliberately laxer than WA Web:
1329    /// demoting a bare 429 would clear `is_throttled` and put the issue's silent
1330    /// failure back for the one refusal that most needs acting on.
1331    #[test]
1332    fn an_absent_text_still_classifies_by_code() {
1333        let pe: PairError = IqError::ServerError {
1334            code: 429,
1335            text: String::new(),
1336            error_type: None,
1337            backoff: None,
1338        }
1339        .into();
1340
1341        assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
1342        assert!(pe.rejection().is_some_and(PairCodeRejection::is_throttled));
1343    }
1344
1345    /// The whole point of the typed status: a 429 is recoverable as
1346    /// `RateOverlimit` without matching the message.
1347    #[test]
1348    fn rate_overlimit_is_recoverable_as_a_typed_status() {
1349        let pe: PairError = IqError::ServerError {
1350            code: 429,
1351            text: "rate-overlimit".into(),
1352            error_type: None,
1353            backoff: Some(30),
1354        }
1355        .into();
1356
1357        assert_eq!(pe.rejection(), Some(PairCodeRejection::RateOverlimit));
1358        assert_eq!(pe.backoff(), Some(std::time::Duration::from_secs(30)));
1359        assert!(
1360            pe.rejection().is_some_and(PairCodeRejection::is_throttled),
1361            "429 must read as throttled"
1362        );
1363        // `RequestFailed` renders what it wraps, so a log line that prints only
1364        // the error still names the refusal.
1365        assert!(
1366            pe.to_string().contains("429") && pe.to_string().contains("rate-overlimit"),
1367            "Display should carry the server's code and text, got: {pe}"
1368        );
1369    }
1370
1371    /// `feature-not-available` is the one refusal that retrying cannot fix — it
1372    /// must not read as throttled, or a consumer would back off forever instead
1373    /// of falling back to the QR code the way WA Web does.
1374    #[test]
1375    fn feature_not_available_is_not_throttled() {
1376        let pe: PairError = IqError::ServerError {
1377            code: 452,
1378            text: "feature-not-available".into(),
1379            error_type: None,
1380            backoff: None,
1381        }
1382        .into();
1383
1384        assert_eq!(pe.rejection(), Some(PairCodeRejection::FeatureNotAvailable));
1385        assert!(!PairCodeRejection::FeatureNotAvailable.is_throttled());
1386        assert_eq!(pe.backoff(), None);
1387    }
1388
1389    /// A failure that never reached the server has no status to report, so
1390    /// `rejection` stays `None` rather than inventing one.
1391    #[test]
1392    fn local_failure_reports_no_rejection() {
1393        let pe: PairError = PairCodeError::PhoneNumberTooShort.into();
1394        assert_eq!(pe.rejection(), None);
1395        assert_eq!(pe.backoff(), None);
1396    }
1397
1398    /// The regression the event exists for: a failed request must be observable
1399    /// on the bus, not only through the `Err` that
1400    /// `BotBuilder::with_pair_code`'s detached task throws away.
1401    ///
1402    /// Uses a validation failure because it needs no server, and it covers the
1403    /// harder half of the guarantee: the dispatch wraps the whole flow, so even
1404    /// a path that returns before the IQ is built still reports.
1405    #[tokio::test]
1406    async fn failed_request_dispatches_pairing_code_error() {
1407        let client = create_test_client().await;
1408        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1409        client.subscribe_handler(collector.clone()).detach();
1410
1411        let err = client
1412            .pair_with_code(PairCodeOptions {
1413                phone_number: "123".to_string(),
1414                ..Default::default()
1415            })
1416            .await
1417            .expect_err("a 3-digit number must be refused");
1418        assert!(matches!(
1419            err,
1420            PairError::PairCode(PairCodeError::PhoneNumberTooShort)
1421        ));
1422
1423        poll_until("a PairingCodeError to reach the bus", || {
1424            collector
1425                .events()
1426                .iter()
1427                .any(|e| matches!(&**e, Event::PairingCodeError(_)))
1428        })
1429        .await;
1430
1431        let events = collector.events();
1432        let dispatched = events
1433            .iter()
1434            .find_map(|e| match &**e {
1435                Event::PairingCodeError(e) => Some(e.clone()),
1436                _ => None,
1437            })
1438            .expect("just polled for it");
1439        assert_eq!(
1440            dispatched.rejection, None,
1441            "a local validation failure never reached the server"
1442        );
1443        assert_eq!(dispatched.backoff, None);
1444        assert!(
1445            dispatched.error.contains("too short"),
1446            "the message should say what failed, got: {}",
1447            dispatched.error
1448        );
1449    }
1450
1451    #[test]
1452    fn pair_error_request_failed_preserves_iq_source() {
1453        let iq = IqError::ServerError {
1454            code: 400,
1455            text: "bad-request".into(),
1456            error_type: None,
1457            backoff: None,
1458        };
1459        let pe: PairError = iq.into();
1460        let src = std::error::Error::source(&pe).expect("source preserved");
1461        let downcast = src.downcast_ref::<IqError>().expect("downcasts to IqError");
1462        assert!(matches!(downcast, IqError::ServerError { code: 400, .. }));
1463    }
1464
1465    #[test]
1466    fn pair_error_paircode_walks_to_curve_error() {
1467        use wacore::libsignal::protocol::CurveError;
1468        // Wrap a wacore PairCodeError that itself carries a CurveError source.
1469        let pe: PairError =
1470            PairCodeError::EphemeralKeyAgreement(CurveError::NoKeyTypeIdentifier).into();
1471        assert_eq!(pe.to_string(), "ephemeral key agreement failed");
1472        // Hop 1 is the PairCodeError itself: the wrapper no longer erases it.
1473        let src = std::error::Error::source(&pe).expect("source preserved");
1474        let pce = src
1475            .downcast_ref::<PairCodeError>()
1476            .expect("downcasts to PairCodeError");
1477        assert!(matches!(pce, PairCodeError::EphemeralKeyAgreement(_)));
1478        let curve = std::error::Error::source(pce)
1479            .expect("inner source preserved")
1480            .downcast_ref::<CurveError>()
1481            .expect("downcasts to CurveError");
1482        assert!(matches!(curve, CurveError::NoKeyTypeIdentifier));
1483    }
1484
1485    // ── Stage-2 notification handling (WA Web parity guards) ─────────────────
1486    //
1487    // All tests drive the top-level `handle_pair_code_notification`, so the
1488    // `stage` dispatch is exercised end-to-end. The guard-reject paths bail
1489    // before any stage-2 crypto, so "the adv secret is unchanged" is a reliable
1490    // proxy for "we did not process the notification"; conversely a valid
1491    // primary_hello rotates it (via `SetAdvSecretKey`) before the socket send.
1492
1493    use crate::test_utils::{create_iq_test_client, create_test_client, poll_until};
1494    use wacore::libsignal::protocol::KeyPair;
1495    use wacore_binary::Node;
1496    use wacore_binary::builder::NodeBuilder;
1497
1498    fn primary_hello_notif(reg_ref: &[u8]) -> Node {
1499        NodeBuilder::new("notification")
1500            .attr("type", "link_code_companion_reg")
1501            .attr("from", "s.whatsapp.net")
1502            .children([NodeBuilder::new("link_code_companion_reg")
1503                .attr("stage", "primary_hello")
1504                .children([
1505                    // Non-zero dummy bytes keep the stage-2 DH well-defined.
1506                    NodeBuilder::new("link_code_pairing_wrapped_primary_ephemeral_pub")
1507                        .bytes(vec![7u8; 80])
1508                        .build(),
1509                    NodeBuilder::new("primary_identity_pub")
1510                        .bytes(vec![9u8; 32])
1511                        .build(),
1512                    NodeBuilder::new("link_code_pairing_ref")
1513                        .bytes(reg_ref.to_vec())
1514                        .build(),
1515                ])
1516                .build()])
1517            .build()
1518    }
1519
1520    fn refresh_code_notif(reg_ref: &[u8], force_manual: Option<bool>) -> Node {
1521        let mut reg = NodeBuilder::new("link_code_companion_reg").attr("stage", "refresh_code");
1522        if let Some(f) = force_manual {
1523            reg = reg.attr("force_manual_refresh", if f { "true" } else { "false" });
1524        }
1525        NodeBuilder::new("notification")
1526            .attr("type", "link_code_companion_reg")
1527            .attr("from", "s.whatsapp.net")
1528            .children([reg
1529                .children([NodeBuilder::new("link_code_pairing_ref")
1530                    .bytes(reg_ref.to_vec())
1531                    .build()])
1532                .build()])
1533            .build()
1534    }
1535
1536    async fn set_waiting(client: &Arc<Client>, pairing_ref: Vec<u8>, ts: i64, count: u32) {
1537        *client.pair_code_state.lock().await = PairCodeState::WaitingForPhoneConfirmation {
1538            pairing_ref,
1539            phone_jid: "15551234567".to_string(),
1540            pair_code: "ABCD1234".to_string(),
1541            ephemeral_keypair: Box::new(KeyPair::generate(
1542                &mut rand::make_rng::<rand::rngs::StdRng>(),
1543            )),
1544            code_generation_ts: ts,
1545            primary_hello_attempt_count: count,
1546        };
1547    }
1548
1549    fn adv(client: &Arc<Client>) -> [u8; 32] {
1550        client
1551            .persistence_manager
1552            .get_device_snapshot()
1553            .adv_secret_key
1554    }
1555
1556    async fn is_waiting(client: &Arc<Client>) -> bool {
1557        matches!(
1558            &*client.pair_code_state.lock().await,
1559            PairCodeState::WaitingForPhoneConfirmation { .. }
1560        )
1561    }
1562
1563    async fn attempt_count(client: &Arc<Client>) -> Option<u32> {
1564        match &*client.pair_code_state.lock().await {
1565            PairCodeState::WaitingForPhoneConfirmation {
1566                primary_hello_attempt_count,
1567                ..
1568            } => Some(*primary_hello_attempt_count),
1569            _ => None,
1570        }
1571    }
1572
1573    /// Regression: a `primary_hello` whose ref doesn't match our outstanding
1574    /// companion_hello must be rejected (WA Web `InvalidRefError`) without
1575    /// running stage 2, must leave the flow intact for a later valid one, and
1576    /// must NOT consume a retry slot (the ref check precedes the counter bump).
1577    #[tokio::test]
1578    async fn primary_hello_rejects_mismatched_ref() {
1579        let client = create_test_client().await;
1580        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
1581        let adv_before = adv(&client);
1582
1583        let notif = primary_hello_notif(&[9, 9, 9, 9]);
1584        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1585
1586        assert!(!handled, "mismatched ref must be rejected");
1587        assert_eq!(
1588            adv(&client),
1589            adv_before,
1590            "no stage-2 crypto on ref mismatch"
1591        );
1592        assert!(
1593            is_waiting(&client).await,
1594            "state must be preserved so a later valid primary_hello can complete"
1595        );
1596        assert_eq!(
1597            attempt_count(&client).await,
1598            Some(0),
1599            "a ref-mismatched notification must not burn a retry slot"
1600        );
1601    }
1602
1603    /// Regression: stale/foreign-ref notifications must not exhaust the attempt
1604    /// cap. Even after several mismatched hellos, the genuine one still reaches
1605    /// stage 2 (adv secret rotates).
1606    #[tokio::test]
1607    async fn stale_mismatched_hellos_do_not_block_the_valid_one() {
1608        let client = create_test_client().await;
1609        let pairing_ref = vec![1, 2, 3, 4];
1610        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
1611        let adv_before = adv(&client);
1612
1613        // More mismatched hellos than the cap would allow if they counted.
1614        for _ in 0..(PairCodeUtils::max_primary_hello_attempts() + 2) {
1615            let bad = primary_hello_notif(&[9, 9, 9, 9]);
1616            let _ = handle_pair_code_notification(&client, &bad.as_node_ref()).await;
1617        }
1618        assert_eq!(
1619            attempt_count(&client).await,
1620            Some(0),
1621            "mismatched hellos must leave the attempt count untouched"
1622        );
1623
1624        let good = primary_hello_notif(&pairing_ref);
1625        let _ = handle_pair_code_notification(&client, &good.as_node_ref()).await;
1626        poll_until(
1627            "the genuine primary_hello to still reach stage 2 after stale mismatches",
1628            || adv(&client) != adv_before,
1629        )
1630        .await;
1631    }
1632
1633    /// Regression: a `primary_hello` for a code older than the ~180s validity
1634    /// window must be rejected (WA Web `OldCodeError`).
1635    #[tokio::test]
1636    async fn primary_hello_rejects_expired_code() {
1637        let client = create_test_client().await;
1638        let pairing_ref = vec![1, 2, 3, 4];
1639        let stale_ts =
1640            wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 20);
1641        set_waiting(&client, pairing_ref.clone(), stale_ts, 0).await;
1642        let adv_before = adv(&client);
1643
1644        let notif = primary_hello_notif(&pairing_ref);
1645        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1646
1647        assert!(
1648            !handled,
1649            "primary_hello for an expired code must be rejected"
1650        );
1651        assert_eq!(
1652            adv(&client),
1653            adv_before,
1654            "no stage-2 crypto on an expired code"
1655        );
1656        assert_eq!(
1657            attempt_count(&client).await,
1658            Some(0),
1659            "an expired-code notification must not burn a retry slot"
1660        );
1661    }
1662
1663    /// Regression: at most `max_primary_hello_attempts` (WA Web `T = 3`) are
1664    /// processed per code; the next one is dropped (`MaxPrimaryHelloError`).
1665    #[tokio::test]
1666    async fn primary_hello_rejects_beyond_max_attempts() {
1667        let client = create_test_client().await;
1668        let pairing_ref = vec![1, 2, 3, 4];
1669        set_waiting(
1670            &client,
1671            pairing_ref.clone(),
1672            wacore::time::now_secs(),
1673            PairCodeUtils::max_primary_hello_attempts(),
1674        )
1675        .await;
1676        let adv_before = adv(&client);
1677
1678        let notif = primary_hello_notif(&pairing_ref);
1679        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1680
1681        assert!(!handled, "the attempt past the cap must be rejected");
1682        assert_eq!(
1683            adv(&client),
1684            adv_before,
1685            "no stage-2 crypto once the per-code attempt cap is exhausted"
1686        );
1687        assert_eq!(
1688            attempt_count(&client).await,
1689            Some(PairCodeUtils::max_primary_hello_attempts()),
1690            "a rejected over-cap attempt must not push the counter past the max"
1691        );
1692    }
1693
1694    /// The guards must not over-reject: a valid retry (matching ref, fresh code,
1695    /// still under the cap) reaches stage 2 and rotates the adv secret. The
1696    /// socket send then fails (no transport in tests), so the call returns
1697    /// false, but the rotation proves processing happened.
1698    #[tokio::test]
1699    async fn primary_hello_valid_retry_reaches_stage2() {
1700        let client = create_test_client().await;
1701        let pairing_ref = vec![1, 2, 3, 4];
1702        // count = 2 → this is the 3rd attempt, still within the cap of 3.
1703        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 2).await;
1704        let adv_before = adv(&client);
1705
1706        let notif = primary_hello_notif(&pairing_ref);
1707        let _ = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1708
1709        poll_until(
1710            "a valid in-window retry to reach stage 2 and rotate the adv secret",
1711            || adv(&client) != adv_before,
1712        )
1713        .await;
1714    }
1715
1716    /// A `refresh_code` whose ref matches the outstanding flow surfaces a
1717    /// `PairingCodeRefresh` event carrying `force_manual`.
1718    #[tokio::test]
1719    async fn refresh_code_matching_ref_dispatches_event() {
1720        let client = create_test_client().await;
1721        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1722        client.subscribe_handler(collector.clone()).detach();
1723
1724        let pairing_ref = vec![5, 6, 7, 8];
1725        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
1726
1727        let notif = refresh_code_notif(&pairing_ref, Some(true));
1728        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1729
1730        assert!(handled, "a matching refresh_code should be handled");
1731        let events = collector.events();
1732        assert!(
1733            events
1734                .iter()
1735                .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if r.force_manual)),
1736            "expected PairingCodeRefresh{{force_manual:true}}, got: {events:?}"
1737        );
1738    }
1739
1740    /// An absent `force_manual_refresh` attribute maps to `force_manual: false`
1741    /// (WA Web's non-force `refreshAltLinkingCode` branch). Locks down the
1742    /// `== "true"` parse against a flip to `!= "false"`.
1743    #[tokio::test]
1744    async fn refresh_code_without_force_manual_defaults_false() {
1745        let client = create_test_client().await;
1746        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1747        client.subscribe_handler(collector.clone()).detach();
1748
1749        let pairing_ref = vec![5, 6, 7, 8];
1750        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
1751
1752        let notif = refresh_code_notif(&pairing_ref, None);
1753        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1754
1755        assert!(handled, "a matching refresh_code should be handled");
1756        assert!(
1757            collector.events().iter().any(|e| matches!(
1758                &**e,
1759                Event::PairingCodeRefresh(r) if !r.force_manual
1760            )),
1761            "absent force_manual_refresh must dispatch force_manual: false"
1762        );
1763    }
1764
1765    /// A `refresh_code` for a different ref (or with no flow in progress) is
1766    /// ignored — no event, matching WA Web's `getCurrentRef()` guard.
1767    #[tokio::test]
1768    async fn refresh_code_mismatched_ref_is_ignored() {
1769        let client = create_test_client().await;
1770        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1771        client.subscribe_handler(collector.clone()).detach();
1772
1773        set_waiting(&client, vec![5, 6, 7, 8], wacore::time::now_secs(), 0).await;
1774
1775        let notif = refresh_code_notif(&[1, 1, 1, 1], None);
1776        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
1777
1778        assert!(!handled, "a non-matching refresh_code must be ignored");
1779        assert!(
1780            collector.events().is_empty(),
1781            "no event should fire for a refresh_code with an unknown ref"
1782        );
1783    }
1784
1785    // ── Requesting a code over a live one ────────────────────────────────────
1786    //
1787    // WA Web guards `startAltLinkingFlow` with `invariant(stage === Initialized)`
1788    // (`Alt/DeviceLinkingApi.js`): a second `companion_hello` may only follow an
1789    // explicit `initializeAltDeviceLinking()`. Silently minting a second code
1790    // strands whoever is reading the first one — the server keeps routing
1791    // `primary_hello` by phone number, so the stale code reaches stage 2 and the
1792    // primary is handed a key bundle its code cannot open.
1793
1794    /// Answers the `companion_hello` this flow puts on the wire, so
1795    /// `pair_with_code` can complete against the harness.
1796    async fn answer_companion_hello(
1797        client: &Arc<Client>,
1798        transport: &Arc<crate::transport::mock::CapturingMockTransport>,
1799        frame: usize,
1800        pairing_ref: &[u8],
1801    ) {
1802        let hello = crate::test_utils::decode_sent_iq(transport, frame).await;
1803        let id = hello
1804            .get()
1805            .attrs()
1806            .optional_string("id")
1807            .expect("companion_hello carries an id")
1808            .into_owned();
1809        let response = NodeBuilder::new("iq")
1810            .attrs([
1811                ("from", "s.whatsapp.net".to_string()),
1812                ("type", "result".to_string()),
1813                ("id", id.clone()),
1814            ])
1815            .children([NodeBuilder::new("link_code_companion_reg")
1816                .attr("stage", "companion_hello")
1817                .children([NodeBuilder::new("link_code_pairing_ref")
1818                    .bytes(pairing_ref.to_vec())
1819                    .build()])
1820                .build()])
1821            .build();
1822        crate::test_utils::answer_iq(client, &id, &response).await;
1823    }
1824
1825    fn options() -> PairCodeOptions {
1826        PairCodeOptions {
1827            phone_number: "15551234567".to_string(),
1828            ..Default::default()
1829        }
1830    }
1831
1832    /// Answers the `companion_finish` sitting in `frame` with the server's
1833    /// `<iq>`; `error` picks between the two refusals WA Web's own parser
1834    /// accepts (`WASmaxInMdCompanionFinishErrors`: bad-request and
1835    /// internal-server-error).
1836    async fn answer_companion_finish(
1837        client: &Arc<Client>,
1838        transport: &Arc<crate::transport::mock::CapturingMockTransport>,
1839        frame: usize,
1840        error: Option<(u16, &str)>,
1841    ) {
1842        let finish = crate::test_utils::decode_sent_iq(transport, frame).await;
1843        let id = finish
1844            .get()
1845            .attrs()
1846            .optional_string("id")
1847            .expect("companion_finish carries an id")
1848            .into_owned();
1849        let mut response = NodeBuilder::new("iq").attrs([
1850            ("from", "s.whatsapp.net".to_string()),
1851            ("id", id.clone()),
1852            (
1853                "type",
1854                if error.is_some() { "error" } else { "result" }.to_string(),
1855            ),
1856        ]);
1857        if let Some((code, text)) = error {
1858            response = response.children([NodeBuilder::new("error")
1859                .attrs([("code", code.to_string()), ("text", text.to_string())])
1860                .build()]);
1861        }
1862        crate::test_utils::answer_iq(client, &id, &response.build()).await;
1863    }
1864
1865    /// Drives a flow to the point where `companion_finish` is on the wire.
1866    async fn reach_stage_two(
1867        client: &Arc<Client>,
1868        transport: &Arc<crate::transport::mock::CapturingMockTransport>,
1869        pairing_ref: &[u8],
1870    ) {
1871        set_waiting(client, pairing_ref.to_vec(), wacore::time::now_secs(), 0).await;
1872        let notif = primary_hello_notif(pairing_ref);
1873        assert!(handle_pair_code_notification(client, &notif.as_node_ref()).await);
1874        poll_until("companion_finish to reach the transport", || {
1875            !transport.sent().is_empty()
1876        })
1877        .await;
1878    }
1879
1880    /// The happy path: the server takes the bundle, so the flow stays open for
1881    /// the `pair-success` that follows. Proven against the silence timer rather
1882    /// than by inspection — an accepted answer must reach the end of the window
1883    /// as silence, never as a refusal.
1884    #[tokio::test(start_paused = true)]
1885    async fn an_accepted_companion_finish_keeps_the_flow_open() {
1886        let (client, transport) = create_iq_test_client().await;
1887        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1888        client.subscribe_handler(collector.clone()).detach();
1889        let pairing_ref = vec![1, 2, 3, 4];
1890        reach_stage_two(&client, &transport, &pairing_ref).await;
1891        let adv_after_stage_two = adv(&client);
1892
1893        answer_companion_finish(&client, &transport, 0, None).await;
1894
1895        advance_past(PairCodeUtils::companion_finish_iq_timeout()).await;
1896        // The stage-2 task is detached, so a clock jump alone proves nothing —
1897        // it needs turns of the executor to act on what it received.
1898        for _ in 0..64 {
1899            tokio::task::yield_now().await;
1900        }
1901        assert!(
1902            is_waiting(&client).await,
1903            "an accepted bundle leaves pair-success still due"
1904        );
1905        assert_eq!(
1906            adv(&client),
1907            adv_after_stage_two,
1908            "the secret pair-success will verify against must survive"
1909        );
1910        assert!(
1911            !collector
1912                .events()
1913                .iter()
1914                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1915            "nothing failed, so nothing may be reported"
1916        );
1917    }
1918
1919    /// The failure this whole path exists for: the server refuses the key
1920    /// bundle. WA Web raises `CompanionFinishError` for any answer that is not
1921    /// `CompanionFinishResponseSuccess` (`Alt/DeviceLinkingIq.js`) and shows
1922    /// "Something went wrong" rather than waiting out the silence timer. We had
1923    /// been sending this IQ without reading its answer at all, so a refusal
1924    /// reached the consumer only as an unexplained minute of nothing.
1925    #[tokio::test]
1926    async fn a_refused_companion_finish_reports_the_rejection() {
1927        let (client, transport) = create_iq_test_client().await;
1928        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1929        client.subscribe_handler(collector.clone()).detach();
1930        let pairing_ref = vec![1, 2, 3, 4];
1931        reach_stage_two(&client, &transport, &pairing_ref).await;
1932        let adv_after_stage_two = adv(&client);
1933
1934        answer_companion_finish(&client, &transport, 0, Some((400, "bad-request"))).await;
1935
1936        poll_until("the refusal to reach the consumer", || {
1937            collector
1938                .events()
1939                .iter()
1940                .any(|e| matches!(&**e, Event::PairingCodeError(_)))
1941        })
1942        .await;
1943        let reported = collector
1944            .events()
1945            .iter()
1946            .find_map(|e| match &**e {
1947                Event::PairingCodeError(e) => Some(e.clone()),
1948                _ => None,
1949            })
1950            .expect("the refusal was just observed");
1951        assert_eq!(
1952            reported.rejection,
1953            Some(PairCodeRejection::BadRequest),
1954            "the consumer must be able to branch on the status, not the message"
1955        );
1956        assert!(
1957            !is_waiting(&client).await,
1958            "a refused flow must free the slot so a replacement can be requested"
1959        );
1960        assert_ne!(
1961            adv(&client),
1962            adv_after_stage_two,
1963            "the secret this dead flow rotated must not outlive it"
1964        );
1965    }
1966
1967    /// A refusal that arrives for a flow already replaced belongs to nobody:
1968    /// reporting it would tell the consumer the live code failed, and re-minting
1969    /// the adv secret would break the replacement that is about to use it.
1970    #[tokio::test]
1971    async fn a_refusal_for_a_replaced_flow_is_not_reported() {
1972        let (client, _transport) = create_iq_test_client().await;
1973        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
1974        client.subscribe_handler(collector.clone()).detach();
1975
1976        // The replacement holds the slot by the time the answer lands.
1977        set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await;
1978        let adv_of_replacement = adv(&client);
1979        report_stage_two_failure(
1980            &client,
1981            &[1, 2, 3, 4],
1982            1,
1983            IqError::ServerError {
1984                code: 500,
1985                text: "internal-server-error".to_string(),
1986                error_type: None,
1987                backoff: None,
1988            },
1989        )
1990        .await;
1991
1992        assert!(
1993            !collector
1994                .events()
1995                .iter()
1996                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
1997            "the replacement flow has not failed and must not be reported as failed"
1998        );
1999        assert_eq!(
2000            adv(&client),
2001            adv_of_replacement,
2002            "the replacement's adv secret must survive"
2003        );
2004        assert!(is_waiting(&client).await, "the replacement keeps the slot");
2005
2006        // The guard's other half: same ref, but a retry has since opened its own
2007        // attempt. Covered here because a ref mismatch alone would let a
2008        // regression that drops the attempt comparison pass unnoticed.
2009        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 2).await;
2010        let adv_of_retry = adv(&client);
2011        report_stage_two_failure(
2012            &client,
2013            &[1, 2, 3, 4],
2014            1,
2015            IqError::ServerError {
2016                code: 400,
2017                text: "bad-request".to_string(),
2018                error_type: None,
2019                backoff: None,
2020            },
2021        )
2022        .await;
2023
2024        assert!(
2025            !collector
2026                .events()
2027                .iter()
2028                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
2029            "an earlier attempt's refusal must not report the retry that replaced it"
2030        );
2031        assert_eq!(
2032            adv(&client),
2033            adv_of_retry,
2034            "the retry's adv secret must survive"
2035        );
2036        assert!(is_waiting(&client).await, "the retry keeps the slot");
2037    }
2038
2039    /// An unanswered `companion_finish` is not a refusal — it is the silence
2040    /// [`start_pair_success_timeout`] already owns, over a longer window. Ending
2041    /// the flow on the shorter IQ timeout would cut a link the server may still
2042    /// be completing.
2043    #[tokio::test(start_paused = true)]
2044    async fn an_unanswered_companion_finish_leaves_the_timer_in_charge() {
2045        let (client, transport) = create_iq_test_client().await;
2046        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2047        client.subscribe_handler(collector.clone()).detach();
2048        reach_stage_two(&client, &transport, &[1, 2, 3, 4]).await;
2049
2050        advance_past(PairCodeUtils::companion_finish_iq_timeout()).await;
2051        for _ in 0..64 {
2052            tokio::task::yield_now().await;
2053        }
2054        assert!(
2055            is_waiting(&client).await,
2056            "the IQ giving up does not end the flow"
2057        );
2058        assert!(
2059            !collector
2060                .events()
2061                .iter()
2062                .any(|e| matches!(&**e, Event::PairingCodeError(_))),
2063            "silence is not a refusal and must not be reported as one"
2064        );
2065
2066        advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2067        poll_until("the regeneration request", || {
2068            collector
2069                .events()
2070                .iter()
2071                .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual))
2072        })
2073        .await;
2074    }
2075
2076    /// Cancelling a flow that reached stage 2 has to give up the adv secret it
2077    /// derived: it is keyed to a primary that will never link, and WA Web sheds
2078    /// the same value on `initializeAltDeviceLinking` (`Alt/DeviceLinkingApi.js`).
2079    #[tokio::test]
2080    async fn cancelling_after_stage_two_gives_up_the_secret_it_derived() {
2081        let (client, transport) = create_iq_test_client().await;
2082        reach_stage_two(&client, &transport, &[1, 2, 3, 4]).await;
2083        let rotated = adv(&client);
2084
2085        client.cancel_pair_code().await;
2086
2087        assert_ne!(
2088            adv(&client),
2089            rotated,
2090            "the cancelled flow's secret must not outlive it"
2091        );
2092    }
2093
2094    /// The other side of that: a flow cancelled before stage 2 never rotated
2095    /// anything, and a paired device's secret is the account's own — re-minting
2096    /// either would be destructive.
2097    #[tokio::test]
2098    async fn cancelling_leaves_a_secret_stage_two_never_touched() {
2099        let (client, _transport) = create_iq_test_client().await;
2100        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2101        let before = adv(&client);
2102        client.cancel_pair_code().await;
2103        assert_eq!(adv(&client), before, "no stage 2 ran, so nothing rotated");
2104
2105        *client.pair_code_state.lock().await = PairCodeState::Completed;
2106        let paired = adv(&client);
2107        client.cancel_pair_code().await;
2108        assert_eq!(
2109            adv(&client),
2110            paired,
2111            "a paired device's adv secret signs its own identity"
2112        );
2113    }
2114
2115    #[tokio::test]
2116    async fn pair_with_code_refuses_to_supersede_a_live_code() {
2117        let (client, _transport) = create_iq_test_client().await;
2118        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2119
2120        let err = client
2121            .pair_with_code(options())
2122            .await
2123            .expect_err("a second code would strand the one already displayed");
2124
2125        assert!(
2126            matches!(
2127                err,
2128                PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. })
2129            ),
2130            "expected CodeAlreadyOutstanding, got {err:?}"
2131        );
2132    }
2133
2134    /// `cancel_pair_code` is our `initializeAltDeviceLinking()`: the explicit
2135    /// reset that lets a caller mint a replacement on purpose.
2136    #[tokio::test]
2137    async fn cancel_pair_code_lets_a_replacement_be_requested() {
2138        let (client, transport) = create_iq_test_client().await;
2139        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2140
2141        client.cancel_pair_code().await;
2142
2143        let pending = {
2144            let client = client.clone();
2145            tokio::spawn(async move { client.pair_with_code(options()).await })
2146        };
2147        answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await;
2148        let code = pending
2149            .await
2150            .expect("the pair-code task should not panic")
2151            .expect("a cancelled flow leaves the way clear");
2152        assert!(PairCodeUtils::validate_code(&code));
2153    }
2154
2155    /// An expired code strands nobody — its holder cannot complete it either —
2156    /// so it must not block a fresh request.
2157    #[tokio::test]
2158    async fn an_expired_code_does_not_block_a_new_one() {
2159        let (client, transport) = create_iq_test_client().await;
2160        let stale =
2161            wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1);
2162        set_waiting(&client, vec![1, 2, 3, 4], stale, 0).await;
2163
2164        let pending = {
2165            let client = client.clone();
2166            tokio::spawn(async move { client.pair_with_code(options()).await })
2167        };
2168        answer_companion_hello(&client, &transport, 0, b"3@2:fresh").await;
2169        pending
2170            .await
2171            .expect("the pair-code task should not panic")
2172            .expect("an expired code must not block a new request");
2173    }
2174
2175    /// The server's `refresh_code` asks for a replacement, so it must also
2176    /// clear the way for one — WA Web's `force_manual_refresh` path calls
2177    /// `initializeAltDeviceLinking()` before the screen re-requests.
2178    #[tokio::test]
2179    async fn refresh_code_clears_the_flow_it_asks_to_replace() {
2180        let client = create_test_client().await;
2181        let pairing_ref = vec![5, 6, 7, 8];
2182        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2183
2184        let notif = refresh_code_notif(&pairing_ref, Some(true));
2185        assert!(handle_pair_code_notification(&client, &notif.as_node_ref()).await);
2186
2187        assert!(
2188            !is_waiting(&client).await,
2189            "a consumer acting on the refresh must not be rejected by the flow it replaces"
2190        );
2191    }
2192
2193    /// Regression: the guard has to survive two callers, not just two calls.
2194    /// Checking the state and then releasing the lock across the
2195    /// `companion_hello` round trip lets both pass, and the second response
2196    /// overwrites the first flow's key material — so the code returned first
2197    /// can no longer complete.
2198    #[tokio::test]
2199    async fn a_request_racing_another_is_refused_too() {
2200        let (client, transport) = create_iq_test_client().await;
2201
2202        let first = {
2203            let client = client.clone();
2204            tokio::spawn(async move { client.pair_with_code(options()).await })
2205        };
2206        poll_until("the first companion_hello to be on the wire", || {
2207            !transport.sent().is_empty()
2208        })
2209        .await;
2210
2211        let second = client.pair_with_code(options()).await;
2212        assert!(
2213            matches!(
2214                second,
2215                Err(PairError::PairCode(
2216                    PairCodeError::CodeAlreadyOutstanding { .. }
2217                ))
2218            ),
2219            "a request in flight already owns the slot, got {second:?}"
2220        );
2221
2222        answer_companion_hello(&client, &transport, 0, b"3@2:first").await;
2223        first
2224            .await
2225            .expect("the pair-code task should not panic")
2226            .expect("the winner still completes");
2227    }
2228
2229    /// ...and a request that fails must hand the slot back, or the client is
2230    /// stuck refusing to issue any code at all.
2231    #[tokio::test]
2232    async fn a_rejected_request_frees_the_slot() {
2233        let (client, transport) = create_iq_test_client().await;
2234
2235        let first = {
2236            let client = client.clone();
2237            tokio::spawn(async move { client.pair_with_code(options()).await })
2238        };
2239        let hello = crate::test_utils::decode_sent_iq(&transport, 0).await;
2240        let id = hello
2241            .get()
2242            .attrs()
2243            .optional_string("id")
2244            .expect("companion_hello carries an id")
2245            .into_owned();
2246        let error = NodeBuilder::new("iq")
2247            .attrs([
2248                ("from", "s.whatsapp.net".to_string()),
2249                ("type", "error".to_string()),
2250                ("id", id.clone()),
2251            ])
2252            .children([NodeBuilder::new("error")
2253                .attrs([
2254                    ("code", "400".to_string()),
2255                    ("text", "bad-request".to_string()),
2256                ])
2257                .build()])
2258            .build();
2259        crate::test_utils::answer_iq(&client, &id, &error).await;
2260        first
2261            .await
2262            .expect("the pair-code task should not panic")
2263            .expect_err("the server rejected this one");
2264
2265        let retry = {
2266            let client = client.clone();
2267            tokio::spawn(async move { client.pair_with_code(options()).await })
2268        };
2269        answer_companion_hello(&client, &transport, 1, b"3@2:second").await;
2270        retry
2271            .await
2272            .expect("the pair-code task should not panic")
2273            .expect("a rejected request must not leave the slot taken");
2274    }
2275
2276    /// Move the clock past `d`, after giving spawned tasks a turn to register
2277    /// their timers. A jump taken before the sleep exists only pushes its
2278    /// deadline out of reach.
2279    async fn advance_past(d: std::time::Duration) {
2280        for _ in 0..64 {
2281            tokio::task::yield_now().await;
2282        }
2283        tokio::time::advance(d + std::time::Duration::from_secs(1)).await;
2284    }
2285
2286    /// Regression: a second-granularity stamp is not an identity. Cancel a
2287    /// request and start its replacement inside the same wall-clock second and
2288    /// both claims carry the same number, so the first one's late response
2289    /// installs its own code over the replacement's claim — and its failure
2290    /// path would release the replacement's.
2291    #[tokio::test]
2292    async fn a_claim_is_identified_by_more_than_the_second_it_started_in() {
2293        let (client, transport) = create_iq_test_client().await;
2294
2295        let first = {
2296            let client = client.clone();
2297            tokio::spawn(async move { client.pair_with_code(options()).await })
2298        };
2299        poll_until("the first companion_hello", || !transport.sent().is_empty()).await;
2300
2301        // Same second, by construction: no clock advances in between.
2302        client.cancel_pair_code().await;
2303        let second = {
2304            let client = client.clone();
2305            tokio::spawn(async move { client.pair_with_code(options()).await })
2306        };
2307        poll_until("the replacement's companion_hello", || {
2308            transport.sent().len() >= 2
2309        })
2310        .await;
2311
2312        answer_companion_hello(&client, &transport, 0, b"3@2:first").await;
2313        let stale = first
2314            .await
2315            .expect("the pair-code task should not panic")
2316            .expect_err("the cancelled request must not install its flow");
2317        assert!(
2318            matches!(stale, PairError::PairCode(PairCodeError::Cancelled)),
2319            "expected Cancelled, got {stale:?}"
2320        );
2321
2322        answer_companion_hello(&client, &transport, 1, b"3@2:second").await;
2323        second
2324            .await
2325            .expect("the pair-code task should not panic")
2326            .expect("the replacement owns the slot and must complete");
2327        assert!(
2328            matches!(
2329                &*client.pair_code_state.lock().await,
2330                PairCodeState::WaitingForPhoneConfirmation { pairing_ref, .. }
2331                    if pairing_ref.as_slice() == b"3@2:second"
2332            ),
2333            "the replacement's flow must be the one left standing"
2334        );
2335    }
2336
2337    /// Regression: the code's validity window and the link's are different
2338    /// clocks. A `primary_hello` accepted near the end of the window leaves
2339    /// `companion_finish` pending for up to a minute more, and a new request
2340    /// started in that gap races the pending pair-success for the adv secret.
2341    #[tokio::test]
2342    async fn a_pending_pair_success_still_owns_the_slot() {
2343        let (client, _transport) = create_iq_test_client().await;
2344        let expired =
2345            wacore::time::now_secs() - (PairCodeUtils::code_validity().as_secs() as i64 + 1);
2346        // Stage 2 ran: companion_finish is out, pair-success is pending.
2347        set_waiting(&client, vec![1, 2, 3, 4], expired, 1).await;
2348
2349        let err = client
2350            .pair_with_code(options())
2351            .await
2352            .expect_err("a pending link still owns the flow");
2353        assert!(
2354            matches!(
2355                err,
2356                PairError::PairCode(PairCodeError::CodeAlreadyOutstanding { .. })
2357            ),
2358            "expected CodeAlreadyOutstanding, got {err:?}"
2359        );
2360    }
2361
2362    /// Regression: an accepted retry deserves its own response window. Timers
2363    /// keyed only on the shared `pairing_ref` let the first attempt's timer
2364    /// retire a flow the second attempt had just renewed.
2365    #[tokio::test(start_paused = true)]
2366    async fn a_retry_gets_its_own_response_window() {
2367        let (client, transport) = create_iq_test_client().await;
2368        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2369        client.subscribe_handler(collector.clone()).detach();
2370        let pairing_ref = vec![1, 2, 3, 4];
2371        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2372
2373        let notif = primary_hello_notif(&pairing_ref);
2374        assert!(handle_pair_code_notification(&client, &notif.as_node_ref()).await);
2375        poll_until("the first companion_finish", || {
2376            !transport.sent().is_empty()
2377        })
2378        .await;
2379
2380        // Most of the first attempt's window goes by, then the phone retries.
2381        advance_past(std::time::Duration::from_secs(50)).await;
2382        let retry = primary_hello_notif(&pairing_ref);
2383        assert!(handle_pair_code_notification(&client, &retry.as_node_ref()).await);
2384        poll_until("the second companion_finish", || {
2385            transport.sent().len() >= 2
2386        })
2387        .await;
2388
2389        // The first attempt's timer is due about now; the retry's is not.
2390        advance_past(std::time::Duration::from_secs(15)).await;
2391        assert!(
2392            !collector
2393                .events()
2394                .iter()
2395                .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))),
2396            "the first attempt's timer must not cut the retry's window short"
2397        );
2398
2399        advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2400        poll_until("the retry's own timeout", || {
2401            collector
2402                .events()
2403                .iter()
2404                .any(|e| matches!(&**e, Event::PairingCodeRefresh(_)))
2405        })
2406        .await;
2407    }
2408
2409    /// Regression: the pairing ref and any in-flight `companion_hello` belong to
2410    /// the connection that carried them. Left standing across a teardown, they
2411    /// make the one-code guard reject the request that reconnecting is supposed
2412    /// to enable — for the rest of the validity window.
2413    #[tokio::test]
2414    async fn a_teardown_does_not_leave_the_slot_claimed() {
2415        let (client, _transport) = create_iq_test_client().await;
2416        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2417
2418        client.cleanup_connection_state().await;
2419
2420        assert!(
2421            matches!(&*client.pair_code_state.lock().await, PairCodeState::Idle),
2422            "a flow scoped to a dead connection must not outlive it"
2423        );
2424    }
2425
2426    /// Regression: a caller that gives up on the request — a `timeout` shorter
2427    /// than the IQ's own, say — used to leave its claim behind, and an orphaned
2428    /// claim rejects every later request for the rest of the validity window.
2429    #[tokio::test]
2430    async fn dropping_the_request_hands_the_claim_back() {
2431        let (client, transport) = create_iq_test_client().await;
2432
2433        {
2434            let client = client.clone();
2435            let task = tokio::spawn(async move { client.pair_with_code(options()).await });
2436            poll_until("the companion_hello to be on the wire", || {
2437                !transport.sent().is_empty()
2438            })
2439            .await;
2440            task.abort();
2441        }
2442
2443        poll_until("the abandoned claim to be released", || {
2444            matches!(
2445                client.pair_code_state.try_lock().as_deref(),
2446                Some(PairCodeState::Idle)
2447            )
2448        })
2449        .await;
2450    }
2451
2452    /// Regression: the claim has to be back *before* the error reaches the
2453    /// caller. Releasing it only from `Drop` schedules a detached task, and a
2454    /// caller that retries the moment it sees the failure gets
2455    /// `CodeAlreadyOutstanding` for a request that already gave up.
2456    // Paused clock: the failure here is the IQ timing out, and waiting 30s of
2457    // real time for it would be the slowest test in the suite.
2458    #[tokio::test(start_paused = true)]
2459    async fn a_failed_request_hands_the_claim_back_before_it_returns() {
2460        let (client, _transport) = create_iq_test_client().await;
2461        client.set_connected_for_test(false);
2462
2463        client
2464            .pair_with_code(options())
2465            .await
2466            .expect_err("stage 1 cannot complete while disconnected");
2467
2468        // Checked without awaiting: a detached release would not have run yet.
2469        assert!(
2470            matches!(
2471                client.pair_code_state.try_lock().as_deref(),
2472                Some(PairCodeState::Idle)
2473            ),
2474            "the slot must be free the moment the error is returned"
2475        );
2476    }
2477
2478    /// The predicate stage 1 rechecks before putting `companion_hello` on the
2479    /// wire. Sending after a withdrawal registers a second flow for this number
2480    /// on the server, which then routes `primary_hello` to whichever it likes —
2481    /// the overlap the claim exists to prevent. (The race itself has no
2482    /// deterministic test: the derivation it runs against is real CPU work.)
2483    #[tokio::test]
2484    async fn a_withdrawn_claim_stops_being_owned() {
2485        let (client, _transport) = create_iq_test_client().await;
2486        let claim = wacore::pair_code::PairCodeClaim::next();
2487        *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
2488            code_generation_ts: wacore::time::now_secs(),
2489            claim,
2490        };
2491
2492        assert!(client.owns_code_claim(claim).await);
2493        client.cancel_pair_code().await;
2494        assert!(
2495            !client.owns_code_claim(claim).await,
2496            "a cancelled request must not reach the wire"
2497        );
2498
2499        // Nor does a replacement's claim answer for the one it superseded.
2500        *client.pair_code_state.lock().await = PairCodeState::RequestingCode {
2501            code_generation_ts: wacore::time::now_secs(),
2502            claim: wacore::pair_code::PairCodeClaim::next(),
2503        };
2504        assert!(!client.owns_code_claim(claim).await);
2505    }
2506
2507    // ── Stage-2 liveness (WA Web parity) ─────────────────────────────────────
2508
2509    /// Regression: WA Web acks the `primary_hello` notification before stage 2
2510    /// runs — `Alt/DeviceLinkingHandleNotification.js` starts
2511    /// `handlePrimaryHello` without awaiting it and returns the ack in the same
2512    /// expression. Holding the ack behind a 131k-round PBKDF2 is a divergence
2513    /// the server sees. Runs on the default current-thread runtime, so the
2514    /// spawned stage-2 task provably has not been polled when the handler
2515    /// returns.
2516    #[tokio::test]
2517    async fn primary_hello_returns_before_stage_two_reaches_the_wire() {
2518        let (client, transport) = create_iq_test_client().await;
2519        let pairing_ref = vec![1, 2, 3, 4];
2520        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2521
2522        let notif = primary_hello_notif(&pairing_ref);
2523        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
2524
2525        assert!(handled, "a valid primary_hello is handled");
2526        assert!(
2527            transport.sent().is_empty(),
2528            "the ack must not wait on stage-2 crypto; companion_finish belongs to a later poll"
2529        );
2530        poll_until("companion_finish to reach the transport", || {
2531            !transport.sent().is_empty()
2532        })
2533        .await;
2534    }
2535
2536    /// Cancelling mid-request must not be undone when stage 1 lands: installing
2537    /// the flow anyway would revive one the caller dropped, and would overwrite
2538    /// whatever replaced it.
2539    #[tokio::test]
2540    async fn a_cancelled_request_does_not_install_its_flow() {
2541        let (client, transport) = create_iq_test_client().await;
2542
2543        let pending = {
2544            let client = client.clone();
2545            tokio::spawn(async move { client.pair_with_code(options()).await })
2546        };
2547        poll_until("the companion_hello to be on the wire", || {
2548            !transport.sent().is_empty()
2549        })
2550        .await;
2551
2552        client.cancel_pair_code().await;
2553        answer_companion_hello(&client, &transport, 0, b"3@2:late").await;
2554
2555        let err = pending
2556            .await
2557            .expect("the pair-code task should not panic")
2558            .expect_err("a cancelled request must not report a usable code");
2559        assert!(
2560            matches!(err, PairError::PairCode(PairCodeError::Cancelled)),
2561            "expected Cancelled, got {err:?}"
2562        );
2563        assert!(
2564            !is_waiting(&client).await,
2565            "the cancelled flow must stay cancelled"
2566        );
2567    }
2568
2569    /// Regression: a stage-2 task can be scheduled and then find, once it has
2570    /// the lock, that its flow was replaced rather than merely retired. Matching
2571    /// on the variant alone reads the replacement as its own flow, and it would
2572    /// then persist a retired adv secret and answer with a `companion_finish`
2573    /// keyed to the old ref — breaking the replacement that was about to work.
2574    #[tokio::test]
2575    async fn a_stage_two_task_does_not_answer_for_the_flow_that_replaced_it() {
2576        let (client, transport) = create_iq_test_client().await;
2577        // The replacement: a live flow, but not the one stage 2 was spawned for.
2578        set_waiting(&client, vec![9, 9, 9, 9], wacore::time::now_secs(), 0).await;
2579        let adv_before = adv(&client);
2580
2581        run_stage_two(
2582            client.clone(),
2583            vec![1, 2, 3, 4],
2584            "15551234567".to_string(),
2585            "ABCD1234".to_string(),
2586            KeyPair::generate(&mut rand::make_rng::<rand::rngs::StdRng>()),
2587            vec![7u8; 80],
2588            [9u8; 32],
2589            1,
2590        )
2591        .await;
2592
2593        assert_eq!(
2594            adv(&client),
2595            adv_before,
2596            "the replacement flow's adv secret must survive"
2597        );
2598        assert!(
2599            transport.sent().is_empty(),
2600            "no companion_finish may go out for a ref nobody is holding"
2601        );
2602    }
2603
2604    /// Regression: `companion_finish` leaving the socket is not the end of the
2605    /// flow — the server may still never send `pair-success` (a primary that
2606    /// could not open the key bundle simply goes quiet). WA Web arms a
2607    /// one-minute timer on `primary_hello_received`
2608    /// (`Link/DevicePhoneNumberCodeScreen.react.js`) and regenerates the code
2609    /// when it fires; we had no timeout at all, leaving the consumer with a
2610    /// code that will never complete and no signal that anything went wrong.
2611    #[tokio::test(start_paused = true)]
2612    async fn a_primary_hello_that_never_pairs_asks_for_a_new_code() {
2613        let (client, transport) = create_iq_test_client().await;
2614        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2615        client.subscribe_handler(collector.clone()).detach();
2616        let pairing_ref = vec![1, 2, 3, 4];
2617        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2618
2619        let notif = primary_hello_notif(&pairing_ref);
2620        assert!(handle_pair_code_notification(&client, &notif.as_node_ref()).await);
2621        poll_until("companion_finish to reach the transport", || {
2622            !transport.sent().is_empty()
2623        })
2624        .await;
2625
2626        advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2627        poll_until("the regeneration request", || {
2628            collector
2629                .events()
2630                .iter()
2631                .any(|e| matches!(&**e, Event::PairingCodeRefresh(r) if !r.force_manual))
2632        })
2633        .await;
2634        assert!(
2635            !is_waiting(&client).await,
2636            "the abandoned flow must not reject the replacement it just asked for"
2637        );
2638    }
2639
2640    /// A stage 2 that cannot even reach the socket ends the flow there and
2641    /// says so, rather than leaving the consumer to infer it from a minute of
2642    /// silence. WA Web does the same: `sendCompanionFinish` throwing reaches
2643    /// `handlePrimaryHello`, which fires `errorAltLinking` immediately
2644    /// (`Alt/DeviceLinkingApi.js`).
2645    #[tokio::test]
2646    async fn a_stage_two_that_cannot_send_reports_the_failure_at_once() {
2647        // No transport, so the companion_finish send fails.
2648        let client = create_test_client().await;
2649        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2650        client.subscribe_handler(collector.clone()).detach();
2651        let pairing_ref = vec![1, 2, 3, 4];
2652        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2653
2654        let notif = primary_hello_notif(&pairing_ref);
2655        assert!(handle_pair_code_notification(&client, &notif.as_node_ref()).await);
2656
2657        poll_until("the failure to reach the consumer", || {
2658            collector
2659                .events()
2660                .iter()
2661                .any(|e| matches!(&**e, Event::PairingCodeError(_)))
2662        })
2663        .await;
2664        assert!(
2665            !is_waiting(&client).await,
2666            "a flow that could not send its bundle must not keep the slot"
2667        );
2668    }
2669
2670    /// The timer must not fire once pairing actually completed, or a freshly
2671    /// linked client would be told to hand out a new code.
2672    #[tokio::test(start_paused = true)]
2673    async fn pair_success_silences_the_regeneration_timer() {
2674        let (client, transport) = create_iq_test_client().await;
2675        let collector = Arc::new(crate::test_utils::TestEventCollector::default());
2676        client.subscribe_handler(collector.clone()).detach();
2677        let pairing_ref = vec![1, 2, 3, 4];
2678        set_waiting(&client, pairing_ref.clone(), wacore::time::now_secs(), 0).await;
2679
2680        let notif = primary_hello_notif(&pairing_ref);
2681        assert!(handle_pair_code_notification(&client, &notif.as_node_ref()).await);
2682        poll_until("companion_finish to reach the transport", || {
2683            !transport.sent().is_empty()
2684        })
2685        .await;
2686
2687        // What `handle_pair_success` does once the server confirms the link.
2688        *client.pair_code_state.lock().await = PairCodeState::Completed;
2689
2690        advance_past(PairCodeUtils::primary_hello_pair_success_timeout()).await;
2691        // The timer task is spawned, so it needs turns of the executor, not just
2692        // a clock jump — `poll_until` would return on its first check here.
2693        for _ in 0..64 {
2694            tokio::task::yield_now().await;
2695        }
2696        assert!(
2697            !collector
2698                .events()
2699                .iter()
2700                .any(|e| matches!(&**e, Event::PairingCodeRefresh(_))),
2701            "a completed pairing must not ask the consumer for another code"
2702        );
2703    }
2704
2705    /// An unknown `stage` on the notification is ignored without touching the
2706    /// in-progress flow.
2707    #[tokio::test]
2708    async fn unknown_stage_is_ignored_and_preserves_state() {
2709        let client = create_test_client().await;
2710        set_waiting(&client, vec![1, 2, 3, 4], wacore::time::now_secs(), 0).await;
2711
2712        let notif = NodeBuilder::new("notification")
2713            .attr("type", "link_code_companion_reg")
2714            .attr("from", "s.whatsapp.net")
2715            .children([NodeBuilder::new("link_code_companion_reg")
2716                .attr("stage", "some_future_stage")
2717                .build()])
2718            .build();
2719        let handled = handle_pair_code_notification(&client, &notif.as_node_ref()).await;
2720
2721        assert!(!handled, "unknown stage must not be treated as handled");
2722        assert!(
2723            is_waiting(&client).await,
2724            "unknown stage must leave the outstanding flow untouched"
2725        );
2726    }
2727}