Skip to main content

hypercall_api/handlers/
rfq.rs

1use crate::error::ApiError;
2use crate::request_auth::verify_request;
3pub use crate::rfq::handler_state::RfqHandlerState;
4use crate::rfq::rfq_manager::{RfqLeg, RfqManager};
5use crate::sonic_json::SonicJson;
6use axum::extract::{Path, State};
7use hypercall_auth::SignedAcceptRfqQuote;
8use hypercall_runtime_api::RfqExecuteResult;
9use hypercall_types::{
10    AcceptRfqRequest, RfqAcceptResponse, RfqHistoryResponse, RfqLegResponse, RfqQuoteLegResponse,
11    RfqQuoteResponse, RfqStatus, RfqStatusResponse, SubmitRfqRequest, WalletAddress,
12};
13use rust_decimal::Decimal;
14use std::str::FromStr;
15use tracing::info_span;
16use tracing::Instrument;
17use uuid::Uuid;
18
19/// Core RFQ submission logic shared by the REST handler and the WebSocket handler.
20///
21/// Validates leg count/sizes, computes the canonical `legs_hash`, recovers the
22/// EIP-712 signer, checks agent authorization, and delegates to `RfqManager`.
23pub async fn submit_rfq_inner(
24    rfq_manager: &RfqManager,
25    agent_auth: &dyn hypercall_runtime_api::AgentAuthProvider,
26    rfq_id_str: &str,
27    legs: &[hypercall_types::RfqLegRequest],
28    wallet_address: WalletAddress,
29    nonce: u64,
30    signature: &str,
31    chain_id: u64,
32) -> Result<crate::rfq::rfq_manager::RfqRecord, String> {
33    let rfq_id = Uuid::parse_str(rfq_id_str).map_err(|_| "Invalid rfq_id format".to_string())?;
34
35    if legs.is_empty() || legs.len() > 10 {
36        return Err("RFQ must have 1-10 legs".to_string());
37    }
38
39    // Parse and validate leg sizes. Direction is carried by `side`, so
40    // sizes must be strictly positive; a negative or zero size would
41    // otherwise flow into execution, invert the premium/margin sign in
42    // `execute_rfq_inner` + `check_margin_for_order`, and let malformed
43    // RFQs pass risk checks or mutate positions incorrectly.
44    let parsed_legs: Vec<RfqLeg> = legs
45        .iter()
46        .map(|l| {
47            let size = l
48                .size
49                .parse::<Decimal>()
50                .map_err(|_| "Invalid leg size".to_string())?;
51            if size <= Decimal::ZERO {
52                return Err(format!(
53                    "Leg size must be strictly positive, got {} for instrument {}",
54                    size, l.instrument
55                ));
56            }
57            Ok(RfqLeg {
58                instrument: l.instrument.clone(),
59                side: l.side,
60                size,
61            })
62        })
63        .collect::<Result<Vec<_>, String>>()?;
64
65    // Compute legs_hash (canonical encoding for signature verification)
66    let legs_hash = compute_legs_hash_from_legs(legs)?;
67
68    let verification_request = SubmitRfqRequest {
69        rfq_id: rfq_id_str.to_string(),
70        legs: legs.to_vec(),
71        wallet_address,
72        nonce,
73        signature: signature.to_string(),
74        auto_accept_limit: None,
75    };
76    let authorized =
77        verify_request(agent_auth, &verification_request, chain_id).map_err(|e| e.to_string())?;
78
79    rfq_manager
80        .submit_rfq(
81            rfq_id,
82            wallet_address,
83            authorized.signer.signer_address,
84            parsed_legs,
85            legs_hash,
86            signature.to_string(),
87            nonce,
88            None,
89        )
90        .await
91        .map_err(|e| e.to_string())
92}
93
94/// Core RFQ auto-execute submission logic shared by the REST handler and the
95/// WebSocket handler. Similar to `submit_rfq_inner` but verifies the
96/// `SubmitAutoExecuteRfq` EIP-712 signature (which includes `limitPrice`)
97/// and passes the auto-accept limit through to the RFQ manager.
98#[allow(clippy::too_many_arguments)]
99pub async fn submit_auto_execute_rfq_inner(
100    rfq_manager: &RfqManager,
101    agent_auth: &dyn hypercall_runtime_api::AgentAuthProvider,
102    rfq_id_str: &str,
103    legs: &[hypercall_types::RfqLegRequest],
104    wallet_address: WalletAddress,
105    limit_price_str: &str,
106    nonce: u64,
107    signature: &str,
108    chain_id: u64,
109) -> Result<crate::rfq::rfq_manager::RfqRecord, String> {
110    let rfq_id = Uuid::parse_str(rfq_id_str).map_err(|_| "Invalid rfq_id format".to_string())?;
111
112    if legs.is_empty() || legs.len() > 10 {
113        return Err("RFQ must have 1-10 legs".to_string());
114    }
115
116    let limit_price = limit_price_str
117        .parse::<Decimal>()
118        .map_err(|_| "Invalid limit_price format".to_string())?;
119    if limit_price <= Decimal::ZERO {
120        return Err("limit_price must be positive".to_string());
121    }
122
123    // Parse and validate leg sizes (same validation as submit_rfq_inner)
124    let parsed_legs: Vec<RfqLeg> = legs
125        .iter()
126        .map(|l| {
127            let size = l
128                .size
129                .parse::<Decimal>()
130                .map_err(|_| "Invalid leg size".to_string())?;
131            if size <= Decimal::ZERO {
132                return Err(format!(
133                    "Leg size must be strictly positive, got {} for instrument {}",
134                    size, l.instrument
135                ));
136            }
137            Ok(RfqLeg {
138                instrument: l.instrument.clone(),
139                side: l.side,
140                size,
141            })
142        })
143        .collect::<Result<Vec<_>, String>>()?;
144
145    let legs_hash = compute_legs_hash_from_legs(legs)?;
146
147    let verification_request = SubmitRfqRequest {
148        rfq_id: rfq_id_str.to_string(),
149        legs: legs.to_vec(),
150        wallet_address,
151        nonce,
152        signature: signature.to_string(),
153        auto_accept_limit: Some(limit_price_str.to_string()),
154    };
155    let authorized =
156        verify_request(agent_auth, &verification_request, chain_id).map_err(|e| e.to_string())?;
157
158    rfq_manager
159        .submit_rfq(
160            rfq_id,
161            wallet_address,
162            authorized.signer.signer_address,
163            parsed_legs,
164            legs_hash,
165            signature.to_string(),
166            nonce,
167            Some(limit_price),
168        )
169        .await
170        .map_err(|e| e.to_string())
171}
172
173/// POST /rfq/request - Submit a new RFQ
174pub async fn submit_rfq(
175    State(state): State<RfqHandlerState>,
176    SonicJson(request): SonicJson<SubmitRfqRequest>,
177) -> Result<SonicJson<RfqStatusResponse>, ApiError> {
178    let span = info_span!("api.submit_rfq", wallet = %request.wallet_address);
179    async move {
180        // If auto_accept_limit is provided, use the auto-execute path
181        if let Some(ref limit_str) = request.auto_accept_limit {
182            let record = submit_auto_execute_rfq_inner(
183                &state.rfq_manager,
184                state.agent_auth.as_ref(),
185                &request.rfq_id,
186                &request.legs,
187                request.wallet_address,
188                limit_str,
189                request.nonce,
190                &request.signature,
191                state.signing_chain_id,
192            )
193            .await
194            .map_err(|e| ApiError::bad_request(&e))?;
195            return Ok(SonicJson(rfq_record_to_response(&record)));
196        }
197
198        let record = submit_rfq_inner(
199            &state.rfq_manager,
200            state.agent_auth.as_ref(),
201            &request.rfq_id,
202            &request.legs,
203            request.wallet_address,
204            request.nonce,
205            &request.signature,
206            state.signing_chain_id,
207        )
208        .await
209        .map_err(|e| ApiError::bad_request(&e))?;
210
211        Ok(SonicJson(rfq_record_to_response(&record)))
212    }
213    .instrument(span)
214    .await
215}
216
217/// Core RFQ accept logic shared by the REST handler and the WebSocket handler.
218///
219/// Parses IDs, verifies the taker EIP-712 `AcceptRFQQuote` signature, checks
220/// agent authorization, and delegates to `RfqManager::accept_quote`.
221pub async fn accept_rfq_quote_inner(
222    rfq_manager: &RfqManager,
223    agent_auth: &dyn hypercall_runtime_api::AgentAuthProvider,
224    rfq_id_str: &str,
225    quote_id_str: &str,
226    wallet_address: WalletAddress,
227    nonce: u64,
228    signature: &str,
229    chain_id: u64,
230) -> Result<RfqAcceptResponse, String> {
231    let rfq_id = Uuid::parse_str(rfq_id_str).map_err(|_| "Invalid rfq_id format".to_string())?;
232    let quote_id =
233        Uuid::parse_str(quote_id_str).map_err(|_| "Invalid quote_id format".to_string())?;
234
235    // We need the net_premium from the stored quote to verify the signature.
236    let rfq_record = rfq_manager
237        .get_rfq(&rfq_id)
238        .ok_or_else(|| "RFQ not found".to_string())?;
239    let quote = rfq_record
240        .quotes
241        .iter()
242        .find(|q| q.quote_id == quote_id)
243        .ok_or_else(|| "Quote not found".to_string())?;
244
245    use rust_decimal::prelude::ToPrimitive;
246    let premium_micro = (quote.net_premium * Decimal::new(1_000_000, 0))
247        .to_i64()
248        .ok_or_else(|| "Net premium overflow".to_string())?;
249    let net_premium_i256 = alloy::primitives::I256::try_from(premium_micro)
250        .map_err(|e| format!("I256 conversion: {}", e))?;
251
252    let verification_request = AcceptRfqRequest {
253        rfq_id: rfq_id_str.to_string(),
254        quote_id: quote_id_str.to_string(),
255        wallet_address,
256        nonce,
257        signature: signature.to_string(),
258    };
259    let authorized = verify_request(
260        agent_auth,
261        &SignedAcceptRfqQuote {
262            request: &verification_request,
263            net_premium: net_premium_i256,
264        },
265        chain_id,
266    )
267    .map_err(|e| e.to_string())?;
268
269    let result = rfq_manager
270        .accept_quote(
271            rfq_id,
272            quote_id,
273            wallet_address,
274            signature.to_string(),
275            authorized.signer.signer_address,
276            authorized.nonce,
277        )
278        .await
279        .map_err(|e| e.to_string())?;
280
281    match result {
282        RfqExecuteResult::Success { fill_id } => Ok(RfqAcceptResponse {
283            rfq_id: rfq_id_str.to_string(),
284            quote_id: quote_id_str.to_string(),
285            status: RfqStatus::Executed,
286            fill_id,
287        }),
288        RfqExecuteResult::Failed { reason } => Err(format!("RFQ execution failed: {}", reason)),
289    }
290}
291
292/// POST /rfq/accept - Accept a firm quote
293pub async fn accept_rfq_quote(
294    State(state): State<RfqHandlerState>,
295    SonicJson(request): SonicJson<AcceptRfqRequest>,
296) -> Result<SonicJson<RfqAcceptResponse>, ApiError> {
297    let span = info_span!("api.accept_rfq_quote", wallet = %request.wallet_address);
298    async move {
299        let response = accept_rfq_quote_inner(
300            &state.rfq_manager,
301            state.agent_auth.as_ref(),
302            &request.rfq_id,
303            &request.quote_id,
304            request.wallet_address,
305            request.nonce,
306            &request.signature,
307            state.signing_chain_id,
308        )
309        .await
310        .map_err(|e| ApiError::bad_request(&e))?;
311
312        Ok(SonicJson(response))
313    }
314    .instrument(span)
315    .await
316}
317
318/// GET /rfq/:rfq_id - Get RFQ status and quotes
319pub async fn get_rfq(
320    State(state): State<RfqHandlerState>,
321    Path(rfq_id): Path<String>,
322) -> Result<SonicJson<RfqStatusResponse>, ApiError> {
323    let rfq_uuid =
324        Uuid::parse_str(&rfq_id).map_err(|_| ApiError::bad_request("Invalid rfq_id format"))?;
325
326    let record = state
327        .rfq_manager
328        .get_rfq(&rfq_uuid)
329        .ok_or_else(|| ApiError::not_found("RFQ not found"))?;
330
331    Ok(SonicJson(rfq_record_to_response(&record)))
332}
333
334/// GET /rfq/history?wallet=0x... - List past RFQs for wallet
335pub async fn get_rfq_history(
336    State(state): State<RfqHandlerState>,
337    axum::extract::Query(params): axum::extract::Query<RfqHistoryQuery>,
338) -> Result<SonicJson<RfqHistoryResponse>, ApiError> {
339    let wallet = WalletAddress::from_str(&params.wallet)
340        .map_err(|_| ApiError::bad_request("Invalid wallet address"))?;
341
342    let records = state.rfq_manager.get_history(&wallet);
343    let rfqs = records.iter().map(rfq_record_to_response).collect();
344
345    Ok(SonicJson(RfqHistoryResponse { rfqs }))
346}
347
348#[derive(serde::Deserialize)]
349pub struct RfqHistoryQuery {
350    pub wallet: String,
351}
352
353// Helpers
354
355/// Compute the canonical legs hash from a slice of `RfqLegRequest`.
356///
357/// Canonical encoding: each leg is "instrument|side|size" joined by ";".
358/// `size` is parsed to Decimal and formatted back to its canonical
359/// `Decimal::to_string()` form so the hash matches what QPs later
360/// compute after receiving the same legs as parsed Decimals
361/// (`l.size.to_string()` in `rfq_responder::price_rfq`). Without this
362/// canonicalization, a taker that signs "01.0" or "+5" would have the
363/// submission accepted, then every QP quote would fail signature
364/// verification at accept time because the QP's `legs_hash` is over
365/// "1.0" / "5". Fail loudly at submit instead.
366pub(crate) fn compute_legs_hash_from_legs(
367    legs: &[hypercall_types::RfqLegRequest],
368) -> Result<[u8; 32], String> {
369    hypercall_auth::compute_rfq_legs_hash(legs)
370}
371
372fn rfq_record_to_response(record: &crate::rfq::rfq_manager::RfqRecord) -> RfqStatusResponse {
373    RfqStatusResponse {
374        rfq_id: record.rfq_id.to_string(),
375        status: record.status,
376        underlying: record.underlying.clone(),
377        legs: record
378            .legs
379            .iter()
380            .map(|l| RfqLegResponse {
381                instrument: l.instrument.clone(),
382                side: l.side,
383                size: l.size,
384            })
385            .collect(),
386        quotes: record
387            .quotes
388            .iter()
389            .map(|q| RfqQuoteResponse {
390                quote_id: q.quote_id.to_string(),
391                net_premium: q.net_premium,
392                legs: q
393                    .legs
394                    .iter()
395                    .map(|l| RfqQuoteLegResponse {
396                        instrument: l.instrument.clone(),
397                        side: l.side,
398                        price: l.price,
399                        size: l.size,
400                    })
401                    .collect(),
402                expires_at: q.received_at + q.valid_for_ms,
403            })
404            .collect(),
405        created_at: record.created_at,
406        expires_at: record.expires_at,
407    }
408}