Skip to main content

futu_cache/trd_cache/
cipher_exchange.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::sync::atomic::Ordering;
3
4use dashmap::mapref::entry::Entry;
5use futu_core::trade_broker::broker_id_for_security_firm_like_cpp;
6
7use super::{AccKey, TrdCache};
8
9#[derive(Clone, Copy, PartialEq, Eq)]
10pub(super) enum CipherFreshness {
11    Unbound,
12    Pending {
13        broker_id: u32,
14        connection_epoch: u64,
15        request_id: u64,
16    },
17    Fresh {
18        broker_id: u32,
19        connection_epoch: u64,
20    },
21    Failed {
22        broker_id: u32,
23        connection_epoch: u64,
24    },
25}
26
27/// Secret-bearing cache record. Deliberately does not implement `Debug`.
28pub(super) struct CipherRecord {
29    revision: u64,
30    cipher: Option<Vec<u8>>,
31    freshness: CipherFreshness,
32}
33
34/// One CMD2902 request row bound to the account revision captured at send time.
35/// Deliberately does not implement `Debug` because it owns cipher bytes.
36pub struct CipherExchangeAccount {
37    account_id: AccKey,
38    revision: u64,
39    trade_cipher: Vec<u8>,
40}
41
42impl CipherExchangeAccount {
43    #[must_use]
44    pub const fn account_id(&self) -> AccKey {
45        self.account_id
46    }
47
48    #[must_use]
49    pub const fn revision(&self) -> u64 {
50        self.revision
51    }
52
53    #[must_use]
54    pub fn trade_cipher(&self) -> &[u8] {
55        &self.trade_cipher
56    }
57}
58
59/// In-flight CMD2902 ownership token. `request_id` distinguishes overlapping
60/// requests on the same connection epoch; account revision rejects a response
61/// racing with unlock/lock/reload.
62pub struct CipherExchangeLease {
63    broker_id: u32,
64    connection_epoch: u64,
65    request_id: u64,
66    broker_generation: u64,
67    accounts: Vec<CipherExchangeAccount>,
68}
69
70impl CipherExchangeLease {
71    #[must_use]
72    pub const fn broker_id(&self) -> u32 {
73        self.broker_id
74    }
75
76    #[must_use]
77    pub const fn connection_epoch(&self) -> u64 {
78        self.connection_epoch
79    }
80
81    #[must_use]
82    pub const fn request_id(&self) -> u64 {
83        self.request_id
84    }
85
86    #[must_use]
87    pub const fn broker_generation(&self) -> u64 {
88        self.broker_generation
89    }
90
91    #[must_use]
92    pub fn accounts(&self) -> &[CipherExchangeAccount] {
93        &self.accounts
94    }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CipherExchangeError {
99    DuplicateAccount,
100    EmptyCipher,
101    AccountSetMismatch,
102    LeaseStale,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
106pub struct CipherExchangePublishReport {
107    published: usize,
108    stale: usize,
109    broker_generation: u64,
110}
111
112impl CipherExchangePublishReport {
113    #[must_use]
114    pub const fn published(self) -> usize {
115        self.published
116    }
117
118    #[must_use]
119    pub const fn stale(self) -> usize {
120        self.stale
121    }
122
123    #[must_use]
124    pub const fn broker_generation(self) -> u64 {
125        self.broker_generation
126    }
127}
128
129impl TrdCache {
130    #[must_use]
131    pub fn get_cipher(&self, acc_id: AccKey) -> Option<Vec<u8>> {
132        self.cipher_records
133            .get(&acc_id)
134            .and_then(|record| record.cipher.clone())
135    }
136
137    /// Publish a newly unlocked cipher and invalidate every in-flight CMD2902
138    /// lease for this account by incrementing its revision.
139    pub fn set_cipher(&self, acc_id: AccKey, cipher: Vec<u8>) {
140        let broker_id = self.broker_id_for_cipher_account(acc_id);
141        let _publication = self.cipher_publication_lock.lock();
142        match self.cipher_records.entry(acc_id) {
143            Entry::Occupied(mut entry) => {
144                let record = entry.get_mut();
145                record.revision = record.revision.saturating_add(1);
146                record.cipher = Some(cipher);
147                record.freshness = CipherFreshness::Unbound;
148            }
149            Entry::Vacant(entry) => {
150                entry.insert(CipherRecord {
151                    revision: 1,
152                    cipher: Some(cipher),
153                    freshness: CipherFreshness::Unbound,
154                });
155            }
156        }
157        if let Some(broker_id) = broker_id {
158            self.bump_broker_cipher_generation_locked(broker_id);
159        }
160    }
161
162    #[must_use]
163    pub fn get_cipher_revision(&self, acc_id: AccKey) -> u64 {
164        self.cipher_records
165            .get(&acc_id)
166            .map_or(0, |record| record.revision)
167    }
168
169    #[must_use]
170    pub fn cipher_count(&self) -> usize {
171        self.cipher_records
172            .iter()
173            .filter(|record| record.cipher.is_some())
174            .count()
175    }
176
177    /// Clear one account cipher while retaining a revision tombstone.
178    #[must_use]
179    pub fn clear_cipher(&self, acc_id: AccKey) -> bool {
180        let broker_id = self.broker_id_for_cipher_account(acc_id);
181        let _publication = self.cipher_publication_lock.lock();
182        let Some(mut record) = self.cipher_records.get_mut(&acc_id) else {
183            return false;
184        };
185        if record.cipher.take().is_none() {
186            return false;
187        }
188        record.revision = record.revision.saturating_add(1);
189        record.freshness = CipherFreshness::Failed {
190            broker_id: 0,
191            connection_epoch: 0,
192        };
193        if let Some(broker_id) = broker_id {
194            self.bump_broker_cipher_generation_locked(broker_id);
195        }
196        true
197    }
198
199    #[must_use]
200    pub fn clear_all_ciphers_and_bump_versions(&self) -> (usize, Vec<(u64, u64)>) {
201        let acc_ids: Vec<_> = self
202            .cipher_records
203            .iter()
204            .filter_map(|record| record.cipher.as_ref().map(|_| *record.key()))
205            .collect();
206        let mut bumped = Vec::with_capacity(acc_ids.len());
207        for acc_id in acc_ids {
208            if self.clear_cipher(acc_id) {
209                bumped.push((acc_id, self.bump_cipher_state_version(acc_id)));
210            }
211        }
212        (bumped.len(), bumped)
213    }
214
215    /// Freeze the exact real-account cipher set for one broker connection.
216    /// C++ sorts the broker account list before building CMD2902; Rust preserves
217    /// deterministic account order for the same wire behavior.
218    #[must_use]
219    pub fn begin_cipher_exchange(
220        &self,
221        broker_id: u32,
222        connection_epoch: u64,
223    ) -> CipherExchangeLease {
224        let _publication = self.cipher_publication_lock.lock();
225        let request_id = self
226            .next_cipher_exchange_request_id
227            .fetch_add(1, Ordering::SeqCst)
228            .wrapping_add(1);
229        let mut account_ids: Vec<_> = self
230            .accounts
231            .iter()
232            .filter(|account| account.trd_env == 1)
233            .filter(|account| {
234                account
235                    .security_firm
236                    .and_then(broker_id_for_security_firm_like_cpp)
237                    == Some(broker_id)
238            })
239            .map(|account| *account.key())
240            .collect();
241        account_ids.sort_unstable();
242
243        let mut accounts = Vec::new();
244        for account_id in account_ids {
245            let Some(mut record) = self.cipher_records.get_mut(&account_id) else {
246                continue;
247            };
248            let Some(cipher) = record
249                .cipher
250                .as_ref()
251                .filter(|cipher| !cipher.is_empty())
252                .cloned()
253            else {
254                continue;
255            };
256            let revision = record.revision;
257            record.freshness = CipherFreshness::Pending {
258                broker_id,
259                connection_epoch,
260                request_id,
261            };
262            accounts.push(CipherExchangeAccount {
263                account_id,
264                revision,
265                trade_cipher: cipher,
266            });
267        }
268
269        CipherExchangeLease {
270            broker_id,
271            connection_epoch,
272            request_id,
273            broker_generation: self.broker_cipher_generation_locked(broker_id),
274            accounts,
275        }
276    }
277
278    /// Complete CMD2902 only after validating an exact, duplicate-free,
279    /// non-empty response set. Each account publish is then guarded by the
280    /// captured revision + broker + connection epoch + request id CAS tuple.
281    pub fn complete_cipher_exchange(
282        &self,
283        lease: &CipherExchangeLease,
284        response: Vec<(AccKey, Vec<u8>)>,
285    ) -> Result<CipherExchangePublishReport, CipherExchangeError> {
286        let expected: BTreeSet<_> = lease.accounts.iter().map(|row| row.account_id).collect();
287        let mut response_by_account = BTreeMap::new();
288        for (account_id, cipher) in response {
289            if cipher.is_empty() {
290                self.fail_cipher_exchange(lease);
291                return Err(CipherExchangeError::EmptyCipher);
292            }
293            if response_by_account.insert(account_id, cipher).is_some() {
294                self.fail_cipher_exchange(lease);
295                return Err(CipherExchangeError::DuplicateAccount);
296            }
297        }
298        let actual: BTreeSet<_> = response_by_account.keys().copied().collect();
299        if actual != expected {
300            self.fail_cipher_exchange(lease);
301            return Err(CipherExchangeError::AccountSetMismatch);
302        }
303
304        let _publication = self.cipher_publication_lock.lock();
305        if self.broker_cipher_generation_locked(lease.broker_id) != lease.broker_generation {
306            self.fail_cipher_exchange_locked(lease);
307            return Err(CipherExchangeError::LeaseStale);
308        }
309        let mut published = 0;
310        let mut stale = 0;
311        for requested in &lease.accounts {
312            let Some(mut record) = self.cipher_records.get_mut(&requested.account_id) else {
313                stale += 1;
314                continue;
315            };
316            let owns_lease = record.revision == requested.revision
317                && record.freshness
318                    == (CipherFreshness::Pending {
319                        broker_id: lease.broker_id,
320                        connection_epoch: lease.connection_epoch,
321                        request_id: lease.request_id,
322                    });
323            if !owns_lease {
324                stale += 1;
325                continue;
326            }
327            record.revision = record.revision.saturating_add(1);
328            record.cipher = response_by_account.remove(&requested.account_id);
329            record.freshness = CipherFreshness::Fresh {
330                broker_id: lease.broker_id,
331                connection_epoch: lease.connection_epoch,
332            };
333            published += 1;
334            self.bump_broker_cipher_generation_locked(lease.broker_id);
335        }
336        Ok(CipherExchangePublishReport {
337            published,
338            stale,
339            broker_generation: self.broker_cipher_generation_locked(lease.broker_id),
340        })
341    }
342
343    /// Mark only the still-owned lease rows failed. A newer unlock/exchange is
344    /// left untouched.
345    pub fn fail_cipher_exchange(&self, lease: &CipherExchangeLease) {
346        let _publication = self.cipher_publication_lock.lock();
347        self.fail_cipher_exchange_locked(lease);
348    }
349
350    fn fail_cipher_exchange_locked(&self, lease: &CipherExchangeLease) {
351        for requested in &lease.accounts {
352            let Some(mut record) = self.cipher_records.get_mut(&requested.account_id) else {
353                continue;
354            };
355            if record.revision == requested.revision
356                && record.freshness
357                    == (CipherFreshness::Pending {
358                        broker_id: lease.broker_id,
359                        connection_epoch: lease.connection_epoch,
360                        request_id: lease.request_id,
361                    })
362            {
363                record.freshness = CipherFreshness::Failed {
364                    broker_id: lease.broker_id,
365                    connection_epoch: lease.connection_epoch,
366                };
367            }
368        }
369    }
370
371    /// Run route publication only while the broker's cipher generation still
372    /// equals the completed/empty lease generation. Cipher set/clear/publish
373    /// share this lock, closing the last-check-to-install race.
374    pub fn with_current_broker_cipher_generation<T>(
375        &self,
376        broker_id: u32,
377        expected_generation: u64,
378        publish: impl FnOnce() -> T,
379    ) -> Option<T> {
380        let _publication = self.cipher_publication_lock.lock();
381        (self.broker_cipher_generation_locked(broker_id) == expected_generation).then(publish)
382    }
383
384    /// Financial write gate: pending/failed/stale-epoch ciphers are not usable.
385    /// A newly unlocked cipher is initially unbound and remains usable until a
386    /// reconnect lease explicitly transitions it to Pending.
387    #[must_use]
388    pub fn get_cipher_for_broker_epoch(
389        &self,
390        acc_id: AccKey,
391        broker_id: u32,
392        connection_epoch: u64,
393    ) -> Option<Vec<u8>> {
394        let account = self.accounts.get(&acc_id)?;
395        if account.trd_env != 1
396            || account
397                .security_firm
398                .and_then(broker_id_for_security_firm_like_cpp)
399                != Some(broker_id)
400        {
401            return None;
402        }
403        let record = self.cipher_records.get(&acc_id)?;
404        let usable = match record.freshness {
405            CipherFreshness::Unbound => true,
406            CipherFreshness::Fresh {
407                broker_id: fresh_broker,
408                connection_epoch: fresh_epoch,
409            } => fresh_broker == broker_id && fresh_epoch == connection_epoch,
410            CipherFreshness::Pending { .. } | CipherFreshness::Failed { .. } => false,
411        };
412        usable.then(|| record.cipher.clone()).flatten()
413    }
414
415    fn broker_id_for_cipher_account(&self, acc_id: AccKey) -> Option<u32> {
416        let account = self.accounts.get(&acc_id)?;
417        (account.trd_env == 1)
418            .then(|| {
419                account
420                    .security_firm
421                    .and_then(broker_id_for_security_firm_like_cpp)
422            })
423            .flatten()
424    }
425
426    fn broker_cipher_generation_locked(&self, broker_id: u32) -> u64 {
427        self.cipher_broker_generations
428            .get(&broker_id)
429            .map_or(0, |generation| *generation.value())
430    }
431
432    fn bump_broker_cipher_generation_locked(&self, broker_id: u32) -> u64 {
433        let mut generation = self.cipher_broker_generations.entry(broker_id).or_insert(0);
434        *generation = generation.saturating_add(1);
435        *generation
436    }
437}