Skip to main content

hypercall_api/
signed_actions.rs

1use axum::http::{Method, StatusCode};
2use hypercall_auth::SignatureRecovery;
3use hypercall_types::{
4    observability::AuthFailureReason, WalletAddress, API_ROUTE_MARGIN_MODE,
5    API_ROUTE_NOTIFICATIONS_MARK_ALL_READ, API_ROUTE_NOTIFICATIONS_MARK_READ, API_ROUTE_ORDER,
6    API_ROUTE_ORDER_CLOID, API_ROUTE_PROFILE_IMAGE, API_ROUTE_PUSH_PREFERENCES,
7    API_ROUTE_PUSH_SUBSCRIBE, API_ROUTE_PUSH_UNSUBSCRIBE, API_ROUTE_SETTLEMENT_PAYOUTS_SEEN,
8    API_ROUTE_USERNAME,
9};
10use sonic_rs::{JsonContainerTrait, JsonValueTrait};
11use std::str::FromStr;
12
13#[derive(Clone, Debug)]
14pub struct RecoveredSignedAction {
15    pub wallet: WalletAddress,
16    pub signer: WalletAddress,
17    pub nonce: u64,
18}
19
20#[derive(Debug)]
21pub struct SignedActionError {
22    pub status: StatusCode,
23    pub error: &'static str,
24    pub message: String,
25    pub auth_failure_reason: Option<AuthFailureReason>,
26}
27
28impl SignedActionError {
29    fn bad_request(error: &'static str, message: impl Into<String>) -> Self {
30        Self {
31            status: StatusCode::BAD_REQUEST,
32            error,
33            message: message.into(),
34            auth_failure_reason: None,
35        }
36    }
37
38    fn signature(
39        reason: AuthFailureReason,
40        message: impl Into<String>,
41        source: impl std::fmt::Display,
42    ) -> Self {
43        Self::signature_message(reason, format!("{}: {}", message.into(), source))
44    }
45
46    fn signature_message(reason: AuthFailureReason, message: impl Into<String>) -> Self {
47        Self {
48            status: StatusCode::BAD_REQUEST,
49            error: "signature_verification_failed",
50            message: message.into(),
51            auth_failure_reason: Some(reason),
52        }
53    }
54}
55
56fn required_str<'a>(
57    request_data: &'a sonic_rs::Value,
58    field: &'static str,
59) -> Result<&'a str, SignedActionError> {
60    request_data
61        .get(field)
62        .and_then(|v| v.as_str())
63        .ok_or_else(|| {
64            SignedActionError::bad_request(
65                "missing_field",
66                format!("Missing '{field}' field in request"),
67            )
68        })
69}
70
71fn required_u64(
72    request_data: &sonic_rs::Value,
73    field: &'static str,
74) -> Result<u64, SignedActionError> {
75    request_data
76        .get(field)
77        .and_then(|v| v.as_u64())
78        .ok_or_else(|| {
79            SignedActionError::bad_request(
80                "missing_field",
81                format!("Missing '{field}' field in request"),
82            )
83        })
84}
85
86fn required_string_parameter<'a>(
87    request_data: &'a sonic_rs::Value,
88    field: &'static str,
89) -> Result<&'a str, SignedActionError> {
90    request_data
91        .get(field)
92        .and_then(|v| v.as_str())
93        .ok_or_else(|| {
94            SignedActionError::bad_request(
95                "invalid_parameter",
96                format!("{field} must be provided as a string for signature verification"),
97            )
98        })
99}
100
101fn order_id_as_string(request_data: &sonic_rs::Value) -> Result<String, SignedActionError> {
102    if let Some(s) = request_data.get("order_id").and_then(|v| v.as_str()) {
103        Ok(s.to_string())
104    } else if let Some(n) = request_data.get("order_id").and_then(|v| v.as_u64()) {
105        Ok(n.to_string())
106    } else if let Some(n) = request_data.get("order_id").and_then(|v| v.as_i64()) {
107        Ok(n.to_string())
108    } else {
109        Err(SignedActionError::bad_request(
110            "missing_field",
111            "Missing 'order_id' field in request",
112        ))
113    }
114}
115
116fn parse_settlement_payout_ids(
117    request_data: &sonic_rs::Value,
118) -> Result<Vec<i64>, SignedActionError> {
119    let raw_ids = request_data
120        .get("ids")
121        .and_then(|v| v.as_array())
122        .ok_or_else(|| {
123            SignedActionError::bad_request("missing_field", "Missing 'ids' field in request")
124        })?;
125
126    if raw_ids.is_empty() {
127        return Err(SignedActionError::bad_request(
128            "invalid_parameter",
129            "'ids' must not be empty",
130        ));
131    }
132
133    let mut ids = Vec::with_capacity(raw_ids.len());
134    for raw_id in raw_ids {
135        let id = if let Some(v) = raw_id.as_i64() {
136            v
137        } else if let Some(v) = raw_id.as_u64() {
138            if v > i64::MAX as u64 {
139                return Err(SignedActionError::bad_request(
140                    "invalid_parameter",
141                    "payout id exceeds supported integer range",
142                ));
143            }
144            v as i64
145        } else {
146            return Err(SignedActionError::bad_request(
147                "invalid_parameter",
148                "all ids must be integers",
149            ));
150        };
151
152        if id <= 0 {
153            return Err(SignedActionError::bad_request(
154                "invalid_parameter",
155                "all ids must be positive integers",
156            ));
157        }
158
159        ids.push(id);
160    }
161
162    Ok(ids)
163}
164
165fn canonicalize_settlement_payout_ids(ids: &[i64]) -> String {
166    ids.iter()
167        .map(|id| id.to_string())
168        .collect::<Vec<_>>()
169        .join(",")
170}
171
172pub fn recover_signed_action(
173    path: &str,
174    method: &Method,
175    request_data: &sonic_rs::Value,
176    chain_id: u64,
177) -> Result<RecoveredSignedAction, SignedActionError> {
178    let wallet_str = required_str(request_data, "wallet")?;
179    let wallet = WalletAddress::from_str(wallet_str)
180        .map_err(|_| SignedActionError::bad_request("invalid_wallet", "Invalid wallet address"))?;
181    let nonce = required_u64(request_data, "nonce")?;
182    let signature = required_str(request_data, "signature")?;
183
184    let signer = match path {
185        path if path == API_ROUTE_ORDER && method == Method::POST => {
186            let symbol = required_str(request_data, "symbol")?;
187            let side = required_str(request_data, "side")?;
188            let size = required_string_parameter(request_data, "size")?;
189            let price = required_string_parameter(request_data, "price")?;
190            let tif = request_data
191                .get("tif")
192                .and_then(|v| v.as_str())
193                .unwrap_or("GTC");
194            let client_id = request_data
195                .get("client_id")
196                .and_then(|v| v.as_str())
197                .unwrap_or("");
198
199            SignatureRecovery::recover_place_order_signer(
200                wallet, symbol, side, size, price, tif, client_id, nonce, signature, chain_id,
201            )
202            .map_err(|e| {
203                SignedActionError::signature_message(
204                    AuthFailureReason::PlaceOrderSignature,
205                    format!(
206                        "Signature verification failed: {e}. Ensure size and price strings match exactly what was signed."
207                    ),
208                )
209            })?
210        }
211        path if path == API_ROUTE_ORDER && method == Method::DELETE => {
212            let order_id = order_id_as_string(request_data)?;
213            SignatureRecovery::recover_cancel_order_signer(
214                wallet, &order_id, nonce, signature, chain_id,
215            )
216            .map_err(|e| {
217                SignedActionError::signature(
218                    AuthFailureReason::CancelOrderSignature,
219                    "Signature verification failed",
220                    e,
221                )
222            })?
223        }
224        path if path == API_ROUTE_ORDER && method == Method::PUT => {
225            let order_id = order_id_as_string(request_data)?;
226            let symbol = required_str(request_data, "symbol")?;
227            let side = required_str(request_data, "side")?;
228            let size = required_string_parameter(request_data, "size")?;
229            let price = required_string_parameter(request_data, "price")?;
230            let tif = request_data
231                .get("tif")
232                .and_then(|v| v.as_str())
233                .unwrap_or("gtc");
234            let client_id = request_data
235                .get("client_id")
236                .and_then(|v| v.as_str())
237                .unwrap_or("");
238
239            SignatureRecovery::recover_replace_order_signer(
240                wallet, &order_id, symbol, side, size, price, tif, client_id, nonce, signature,
241                chain_id,
242            )
243            .map_err(|e| {
244                SignedActionError::signature_message(
245                    AuthFailureReason::PlaceOrderSignature,
246                    format!(
247                        "Signature verification failed: {e}. Ensure size and price strings match exactly what was signed."
248                    ),
249                )
250            })?
251        }
252        path if path == API_ROUTE_ORDER_CLOID && method == Method::DELETE => {
253            let client_id = required_str(request_data, "client_id")?;
254            SignatureRecovery::recover_cancel_order_by_client_id_signer(
255                wallet, client_id, nonce, signature, chain_id,
256            )
257            .map_err(|e| {
258                SignedActionError::signature(
259                    AuthFailureReason::CancelOrderCloidSignature,
260                    "Signature verification failed",
261                    e,
262                )
263            })?
264        }
265        path if path == API_ROUTE_MARGIN_MODE && method == Method::POST => {
266            let margin_mode = required_str(request_data, "margin_mode")?;
267            SignatureRecovery::recover_set_margin_mode_signer(
268                &wallet.to_string(),
269                margin_mode,
270                nonce,
271                signature,
272                chain_id,
273            )
274            .map_err(|e| {
275                SignedActionError::signature(
276                    AuthFailureReason::MarginModeSignature,
277                    "Signature verification failed",
278                    e,
279                )
280            })?
281        }
282        path if path == API_ROUTE_SETTLEMENT_PAYOUTS_SEEN && method == Method::POST => {
283            let ids = parse_settlement_payout_ids(request_data)?;
284            let canonical_ids = canonicalize_settlement_payout_ids(&ids);
285
286            SignatureRecovery::recover_settlement_payout_seen_signer(
287                wallet,
288                &canonical_ids,
289                true,
290                nonce,
291                signature,
292                chain_id,
293            )
294            .map_err(|e| {
295                SignedActionError::signature(
296                    AuthFailureReason::SettlementPayoutSeenSignature,
297                    "Signature verification failed",
298                    e,
299                )
300            })?
301        }
302        path if path == API_ROUTE_USERNAME && method == Method::POST => {
303            let username = required_str(request_data, "username")?;
304            SignatureRecovery::recover_set_username_signer(
305                wallet, username, nonce, signature, chain_id,
306            )
307            .map_err(|e| {
308                SignedActionError::signature(
309                    AuthFailureReason::SignatureRecovery,
310                    "Signature verification failed",
311                    e,
312                )
313            })?
314        }
315        path if path == API_ROUTE_PROFILE_IMAGE && method == Method::POST => {
316            let image_sha256 = required_str(request_data, "image_sha256")?;
317            SignatureRecovery::recover_set_profile_image_signer(
318                wallet,
319                image_sha256,
320                nonce,
321                signature,
322                chain_id,
323            )
324            .map_err(|e| {
325                SignedActionError::signature(
326                    AuthFailureReason::SignatureRecovery,
327                    "Signature verification failed",
328                    e,
329                )
330            })?
331        }
332        path if path == API_ROUTE_USERNAME && method == Method::DELETE => {
333            SignatureRecovery::recover_delete_username_signer(wallet, nonce, signature, chain_id)
334                .map_err(|e| {
335                    SignedActionError::signature(
336                        AuthFailureReason::SignatureRecovery,
337                        "Signature verification failed",
338                        e,
339                    )
340                })?
341        }
342        path if matches!(
343            path,
344            API_ROUTE_PUSH_SUBSCRIBE
345                | API_ROUTE_PUSH_UNSUBSCRIBE
346                | API_ROUTE_PUSH_PREFERENCES
347                | API_ROUTE_NOTIFICATIONS_MARK_READ
348                | API_ROUTE_NOTIFICATIONS_MARK_ALL_READ
349        ) && method == Method::POST =>
350        {
351            let action = required_str(request_data, "action")?;
352            SignatureRecovery::recover_push_action_signer(
353                wallet, action, nonce, signature, chain_id,
354            )
355            .map_err(|e| {
356                SignedActionError::signature(
357                    AuthFailureReason::SignatureRecovery,
358                    "Signature verification failed",
359                    e,
360                )
361            })?
362        }
363        "/withdraw/option" if method == Method::POST => {
364            let account = required_str(request_data, "account")?;
365            let symbol = required_str(request_data, "symbol")?;
366            let amount = required_str(request_data, "amount")?;
367            let account_addr = account.parse::<WalletAddress>().map_err(|_| {
368                SignedActionError::bad_request("bad_request", "invalid account address")
369            })?;
370            SignatureRecovery::recover_withdraw_option_signer(
371                wallet,
372                account_addr,
373                symbol,
374                amount,
375                nonce,
376                signature,
377                chain_id,
378            )
379            .map_err(|e| {
380                SignedActionError::signature(
381                    AuthFailureReason::SignatureRecovery,
382                    "Signature verification failed",
383                    e,
384                )
385            })?
386        }
387        "/withdraw/usdc" if method == Method::POST => {
388            let account = required_str(request_data, "account")?;
389            let destination = required_str(request_data, "destination")?;
390            let amount = required_str(request_data, "amount")?;
391            let account_addr = account.parse::<WalletAddress>().map_err(|_| {
392                SignedActionError::bad_request("bad_request", "invalid account address")
393            })?;
394            let destination_addr = destination.parse::<WalletAddress>().map_err(|_| {
395                SignedActionError::bad_request("bad_request", "invalid destination address")
396            })?;
397            SignatureRecovery::recover_withdraw_usdc_signer(
398                wallet,
399                account_addr,
400                destination_addr,
401                amount,
402                nonce,
403                signature,
404                chain_id,
405            )
406            .map_err(|e| {
407                SignedActionError::signature(
408                    AuthFailureReason::SignatureRecovery,
409                    "Signature verification failed",
410                    e,
411                )
412            })?
413        }
414        _ => {
415            return Err(SignedActionError::bad_request(
416                "unsupported_endpoint",
417                format!("Unsupported endpoint for signature middleware: {path}"),
418            ));
419        }
420    };
421
422    Ok(RecoveredSignedAction {
423        wallet,
424        signer: WalletAddress::from(signer),
425        nonce,
426    })
427}