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}