Skip to main content

futu_backend/
crypto_trade.rs

1//! Crypto trade backend wire helpers.
2//!
3//! Crypto trade support added by C++ OpenD 10.5.6508 uses several different
4//! server-side request shapes, but they all start from the same account facts:
5//! public long account id, optional native intra account id, broker id, and
6//! trade cipher. Keep that extraction here so handlers do not re-invent it.
7
8#[cfg(test)]
9mod tests;
10
11use futu_cache::trd_cache::{CachedTrdAcc, TrdCache};
12use futu_core::error::{FutuError, Result};
13
14use crate::{
15    msg_header,
16    proto_internal::{odr_sys_cmn, trade_cmn},
17};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct CryptoAccountContext {
21    pub acc_id: u64,
22    pub intra_acc_id: Option<u64>,
23    pub broker_id: Option<u32>,
24    pub customer_id: Option<u64>,
25    pub cipher: Vec<u8>,
26}
27
28impl CryptoAccountContext {
29    /// Build the `odr_sys_cmn::MsgHeader` used by crypto asset reads.
30    ///
31    /// C++ `NNProto_Trd_AccCrypto.cpp:197-215` sends
32    /// `asset_pl::AccountInfoReq` through `M_SendProto_SetReqMsgHeader`;
33    /// `NNProtoCenter_Inner_Macro_Send.h:16-24` always sets `cipher` bytes
34    /// even when the cipher length is zero.
35    ///
36    /// v1.4.110 P0-1: delegate to [`crate::msg_header::build_real`].
37    /// `_op` 参数保留作 caller-side 语义标注 (test fixture / log key 可能用),
38    /// 当前实现忽略 (与 v1.4.110 之前 `self.req_id(op)` 的 `_op` 行为一致).
39    pub fn build_asset_msg_header(&self, _op: &str) -> odr_sys_cmn::MsgHeader {
40        msg_header::build_real(self.acc_id, Some(self.cipher.clone()), None, None)
41    }
42
43    /// Build the `trade_cmn::CryptoMsgHeader` used by crypto order paths.
44    ///
45    /// C++ `NNProto_Trd_OrderOpCrypto.cpp:40-48,94-102` sets `req_id` and
46    /// `account_id`, but only writes `cipher` when `GetAccCipher` returned a
47    /// non-empty buffer. Keep that distinction because backend crypto order
48    /// services use this lighter header rather than `odr_sys_cmn::MsgHeader`.
49    ///
50    /// v1.4.110 P0-1: delegate to [`crate::msg_header::build_crypto`].
51    pub fn build_crypto_msg_header(&self, _op: &str) -> trade_cmn::CryptoMsgHeader {
52        msg_header::build_crypto(self.acc_id, self.cipher.clone())
53    }
54
55    pub fn require_intra_acc_id(&self, op: &str) -> Result<u64> {
56        self.intra_acc_id.ok_or_else(|| {
57            crypto_context_error(format!(
58                "Crypto {op}: account {} missing intra_acc_id",
59                self.acc_id
60            ))
61        })
62    }
63
64    pub fn require_broker_id(&self, op: &str) -> Result<u32> {
65        self.broker_id.ok_or_else(|| {
66            crypto_context_error(format!(
67                "Crypto {op}: account {} missing broker_id",
68                self.acc_id
69            ))
70        })
71    }
72
73    pub fn require_customer_id(&self, op: &str) -> Result<u64> {
74        self.customer_id.ok_or_else(|| {
75            crypto_context_error(format!(
76                "Crypto {op}: account {} missing customer_id",
77                self.acc_id
78            ))
79        })
80    }
81}
82
83pub fn lookup_crypto_account_context(
84    cache: &TrdCache,
85    acc_id: u64,
86) -> Result<CryptoAccountContext> {
87    let acc = cache.lookup_account(acc_id).ok_or_else(|| {
88        crypto_context_error(format!("Crypto account {acc_id} not found in trade cache"))
89    })?;
90    crypto_account_context_from_acc(cache, &acc)
91}
92
93/// Lookup the C++ read-only Crypto context used by startup warmup.
94///
95/// C++ asset reads always serialize the cipher field, including zero bytes,
96/// while Crypto order reads omit the field when the buffer is empty. Both wire
97/// shapes are produced by [`CryptoAccountContext`] from the same empty vector.
98/// Strict/write callers must continue using [`lookup_crypto_account_context`].
99pub fn lookup_crypto_read_account_context(
100    cache: &TrdCache,
101    acc_id: u64,
102) -> Result<CryptoAccountContext> {
103    let acc = cache.lookup_account(acc_id).ok_or_else(|| {
104        crypto_context_error(format!("Crypto account {acc_id} not found in trade cache"))
105    })?;
106    if !acc.is_crypto_account() {
107        return Err(crypto_context_error(format!(
108            "Account {} is not a crypto account",
109            acc.acc_id
110        )));
111    }
112    // C++ read-only startup accepts a missing cipher as a zero-length buffer;
113    // the two header builders below preserve present-empty vs absent on wire.
114    let cipher = cache.get_cipher(acc.acc_id).unwrap_or_default();
115    Ok(crypto_account_context_from_parts(&acc, cipher))
116}
117
118pub fn crypto_account_context_from_acc(
119    cache: &TrdCache,
120    acc: &CachedTrdAcc,
121) -> Result<CryptoAccountContext> {
122    if !acc.is_crypto_account() {
123        return Err(crypto_context_error(format!(
124            "Account {} is not a crypto account",
125            acc.acc_id
126        )));
127    }
128    // v1.4.111 P2-1 follow-through: cipher missing → fail-closed Err (mirror P2-1
129    // trade handler check_cipher_or_short_circuit pattern). 之前 unwrap_or_default
130    // 让 cipher empty Vec 进 CryptoMsgHeader.cipher → 发 backend → crypto trade
131    // auth fail → 浪费 backend round-trip + 错误信息泛化. 改为早 reject 让 caller
132    // 通过 `?` propagate Err 转 ret_type=-1 + 清晰 hint "Crypto cipher 未解锁".
133    // 对应 v1.4.111-prep merge 后 audit 11 D 桶 verify 后剩 1 真 D 桶, pitfall #45
134    // silent-success polish.
135    let cipher = cache.get_cipher(acc.acc_id).ok_or_else(|| {
136        crypto_context_error(format!(
137            "Crypto account {} cipher 未解锁 — 先调 /api/unlock-trade",
138            acc.acc_id
139        ))
140    })?;
141    Ok(crypto_account_context_from_parts(acc, cipher))
142}
143
144fn crypto_account_context_from_parts(acc: &CachedTrdAcc, cipher: Vec<u8>) -> CryptoAccountContext {
145    CryptoAccountContext {
146        acc_id: acc.acc_id,
147        intra_acc_id: acc.intra_acc_id,
148        broker_id: broker_id_from_account(acc),
149        // C++ `NNProto_Trd_MaxQtyCrypto.cpp:49-54` writes `m_nUserID`
150        // into `crypto_risk_comm::Account.cid`. Rust account projection keeps
151        // the same user id in owner_uid/opr_uid.
152        customer_id: acc
153            .owner_uid
154            .filter(|uid| *uid != 0)
155            .or_else(|| acc.opr_uid.filter(|uid| *uid != 0)),
156        cipher,
157    }
158}
159
160/// Crypto native account requests need broker ids such as 1001/1007.
161///
162/// Prefer the C++ sort key `(BrokerID << 48) | (TrdMkt << 32) | IntraAccID`
163/// stored by `bridge/account/real_projection.rs:323-326`. If a historical
164/// cache entry lacks that key, fall back to `Trd_Common.SecurityFirm` mapping
165/// used by C++ `NetCallback::BrokerIDToTcpCategory`.
166pub fn broker_id_from_account(acc: &CachedTrdAcc) -> Option<u32> {
167    let from_sort_key = (acc.sort_key >> 48) as u32;
168    if from_sort_key != 0 {
169        return Some(from_sort_key);
170    }
171    acc.security_firm.and_then(security_firm_to_broker_id)
172}
173
174pub fn security_firm_to_broker_id(sf: i32) -> Option<u32> {
175    futu_core::trade_broker::broker_id_for_security_firm_like_cpp(sf)
176}
177
178/// v1.4.110 codex audit P1 #3: 用户唯一已开户 crypto account 的 broker_id.
179///
180/// 对齐 C++ `INNData_Trd_MainBrokerage::GetCryptoSupportedDefaultMainBroker`
181/// (line 70-123) 优先级 1: 如果只开了一个 crypto account, 直接取该 account 的 broker.
182///
183/// 返:
184/// - `Some(broker_id)`: trd_cache 恰好 1 个 `is_crypto_account()`, 取其 broker
185/// - `None`: 0 个或 ≥ 2 个 crypto account, caller 走 9419 crypto_brokers / fallback 路径
186///
187/// 此值作 `resolve_qot_broker_for_request` / `resolve_or_reject_broker` 第 5
188/// 参数注入, QOT handler `securityFirm=Unknown(0)` 时决定 default broker.
189pub fn single_crypto_account_broker(trd_cache: &futu_cache::trd_cache::TrdCache) -> Option<u32> {
190    let crypto_brokers: std::collections::HashSet<u32> = trd_cache
191        .accounts
192        .iter()
193        .filter(|r| r.value().is_crypto_account())
194        .filter_map(|r| broker_id_from_account(r.value()))
195        .collect();
196    if crypto_brokers.len() == 1 {
197        crypto_brokers.into_iter().next()
198    } else {
199        None
200    }
201}
202
203fn crypto_context_error(msg: String) -> FutuError {
204    FutuError::ServerError { ret_type: -1, msg }
205}