Skip to main content

hypercall_api/directives/
handlers.rs

1use crate::sonic_json::SonicJson;
2use alloy::primitives::Address;
3use axum::{
4    body::Bytes as AxumBytes,
5    extract::{FromRef, Path, State},
6    http::StatusCode,
7};
8use serde::de::DeserializeOwned;
9use sonic_rs::json;
10use tracing::{error, info, warn};
11use uuid::Uuid;
12
13use crate::directives::encoding::encode_directive_for_action;
14use crate::directives::engine_check::{check_hl_limit_order, EngineCheckResult};
15use crate::directives::json_types::SignatureHex;
16use crate::directives::models::{
17    DirectiveRejection, DirectiveStage, ParsedAction, SubmitRequest, SubmitResponse,
18    TypedDataRequest,
19};
20use crate::directives::typed_data::build_typed_data;
21use crate::error::ApiError;
22use crate::{handlers::AppState, state::DirectiveHydromancerFeed};
23use hypercall_runtime_api::UnifiedEngineRequest;
24use hypercall_types::utils::get_timestamp_millis;
25use hypercall_types::{
26    directives::{ActionKey, SignerType},
27    WalletAddress,
28};
29use hypercall_types::{EngineMessage, SignedDirectiveTx, TransactionRequest, TransactionType};
30use std::{str::FromStr, sync::Arc};
31use tokio::sync::{mpsc, RwLock};
32
33#[derive(Clone)]
34pub struct DirectiveAppState {
35    pub order_sender: mpsc::Sender<UnifiedEngineRequest>,
36    pub event_bus_sender: mpsc::UnboundedSender<EngineMessage>,
37    pub chain_auth: Arc<dyn crate::directives::onchain::ChainAuthReader>,
38    pub transaction_request_journal:
39        Option<Arc<dyn crate::boundary::engine::TransactionRequestJournal>>,
40    pub trading_halt: Arc<RwLock<crate::trading_halt::TradingHaltState>>,
41    pub collateral_registry: Arc<catalog_manager::CollateralRegistry>,
42    pub directive_chain_id: u64,
43    /// HL info API URL for best-effort fill checks after submission (REST fallback).
44    pub hl_info_url: Option<String>,
45    /// Hydromancer feed service for real-time fill confirmation.
46    pub hydromancer_feed: Option<Arc<dyn DirectiveHydromancerFeed>>,
47}
48
49impl FromRef<AppState> for DirectiveAppState {
50    fn from_ref(input: &AppState) -> Self {
51        Self {
52            order_sender: input.order_sender.clone(),
53            event_bus_sender: input.event_bus_sender.clone(),
54            chain_auth: input.chain_auth.clone(),
55            transaction_request_journal: input.transaction_request_journal.clone(),
56            trading_halt: input.trading_halt.clone(),
57            collateral_registry: input.collateral_registry.clone(),
58            directive_chain_id: input.runtime_config.directive_chain_id,
59            hl_info_url: input.hl_info_url.clone(),
60            hydromancer_feed: input.hydromancer_feed.clone(),
61        }
62    }
63}
64
65fn parse_json_body<T: DeserializeOwned>(body: &[u8]) -> Result<T, ApiError> {
66    sonic_rs::from_slice(body).map_err(|e| ApiError::invalid_field(e.to_string()))
67}
68
69fn parse_supported_action_key(value: &str) -> Result<ActionKey, ApiError> {
70    let action_key = ActionKey::from_str(value)
71        .map_err(|_| ApiError::unsupported_action(format!("Unsupported action key: {}", value)))?;
72
73    if matches!(action_key.spec().signer_type, SignerType::Rsm) || !action_key.spec().supported {
74        return Err(ApiError::unsupported_action(format!(
75            "Action '{}' is not supported by this API",
76            value
77        )));
78    }
79
80    Ok(action_key)
81}
82
83macro_rules! log_with_submit_context {
84    ($level:ident, $context:expr, $($fields:tt)*) => {{
85        match ($context.account, $context.recovered_signer) {
86            (Some(account), Some(recovered_signer)) => {
87                $level!(
88                    account = %account,
89                    recovered_signer = %recovered_signer,
90                    $($fields)*
91                )
92            }
93            (Some(account), None) => {
94                $level!(account = %account, $($fields)*)
95            }
96            (None, Some(recovered_signer)) => {
97                $level!(recovered_signer = %recovered_signer, $($fields)*)
98            }
99            (None, None) => {
100                $level!($($fields)*)
101            }
102        }
103    }};
104}
105
106#[derive(Debug, Default, Clone, Copy)]
107struct SubmitLogContext {
108    account: Option<WalletAddress>,
109    recovered_signer: Option<WalletAddress>,
110}
111
112fn log_submit_outcome(
113    action_key: ActionKey,
114    directive_id: &str,
115    context: SubmitLogContext,
116    result: &Result<SubmitResponse, ApiError>,
117) {
118    match result {
119        Ok(response) if response.stage == DirectiveStage::Rejected => {
120            let rej = response
121                .rejection
122                .as_ref()
123                .map(|r| r.message.as_str())
124                .unwrap_or("unknown");
125            log_with_submit_context!(
126                info,
127                context,
128                directive_id = %directive_id,
129                action_key = action_key.as_str(),
130                outcome = "rejected",
131                rejection_message = rej,
132                "Directive rejected by engine policy"
133            );
134        }
135        Ok(response) => {
136            log_with_submit_context!(
137                info,
138                context,
139                directive_id = %directive_id,
140                action_key = action_key.as_str(),
141                outcome = ?response.stage,
142                tx_hash = ?response.tx_hash,
143                "Directive accepted and enqueued"
144            );
145        }
146        Err(error_body) => {
147            let server_side = error_body.status.is_server_error()
148                || error_body.status == StatusCode::GATEWAY_TIMEOUT;
149            if server_side {
150                log_with_submit_context!(
151                    error,
152                    context,
153                    directive_id = %directive_id,
154                    action_key = action_key.as_str(),
155                    outcome = error_body.error.as_str(),
156                    error_message = error_body.message.as_str(),
157                    "Directive submit failed"
158                );
159            } else {
160                log_with_submit_context!(
161                    warn,
162                    context,
163                    directive_id = %directive_id,
164                    action_key = action_key.as_str(),
165                    outcome = error_body.error.as_str(),
166                    error_message = error_body.message.as_str(),
167                    "Directive submit failed"
168                );
169            }
170        }
171    }
172}
173
174fn recover_signer(
175    action_key: ActionKey,
176    account: Address,
177    nonce: u64,
178    action: &ParsedAction,
179    signature: &[u8; 65],
180    chain_id: u64,
181) -> Result<Address, ApiError> {
182    let signer = match (action_key, action) {
183        (ActionKey::HlLimitOrder, ParsedAction::HlLimitOrder(action)) => {
184            hypercall_auth::SignatureRecovery::recover_hl_order_signer(
185                account,
186                nonce,
187                action.into(),
188                signature,
189                chain_id,
190            )
191        }
192        (ActionKey::HlCancelByOid, ParsedAction::HlCancelByOid(action)) => {
193            hypercall_auth::SignatureRecovery::recover_hl_cancel_by_oid_signer(
194                account,
195                nonce,
196                action.into(),
197                signature,
198                chain_id,
199            )
200        }
201        (ActionKey::HlCancelByCloid, ParsedAction::HlCancelByCloid(action)) => {
202            hypercall_auth::SignatureRecovery::recover_hl_cancel_by_cloid_signer(
203                account,
204                nonce,
205                action.into(),
206                signature,
207                chain_id,
208            )
209        }
210        (ActionKey::HcUpdateApiWallet, ParsedAction::HcUpdateApiWallet(action)) => {
211            hypercall_auth::SignatureRecovery::recover_hc_update_api_wallet_signer(
212                account,
213                nonce,
214                action.into(),
215                signature,
216                chain_id,
217            )
218        }
219        _ => {
220            return Err(ApiError::internal_error(
221                "action key and action payload mismatch during signature recovery",
222            ))
223        }
224    };
225
226    signer.map_err(|e| ApiError::invalid_signature(e.to_string()))
227}
228
229async fn authorize_signer(
230    state: &DirectiveAppState,
231    action_key: ActionKey,
232    account: Address,
233    recovered_signer: Address,
234) -> Result<(), ApiError> {
235    let manager = state.chain_auth.get_manager(account).await?;
236    if manager == Address::ZERO {
237        return Err(ApiError::unknown_account(format!(
238            "Unknown account: {}",
239            account
240        )));
241    }
242
243    match action_key.spec().signer_type {
244        SignerType::Manager => {
245            if recovered_signer != manager {
246                return Err(ApiError::invalid_signature(
247                    "signature signer does not match account manager",
248                ));
249            }
250        }
251        SignerType::ApiWallet => {
252            let active = state
253                .chain_auth
254                .is_api_wallet_active(account, recovered_signer)
255                .await?;
256            if !active {
257                return Err(ApiError::invalid_signature(
258                    "signature signer is not an active API wallet for account",
259                ));
260            }
261        }
262        SignerType::Rsm => {
263            unreachable!("RSM action keys must be rejected before signer authorization");
264        }
265    }
266
267    Ok(())
268}
269
270async fn enqueue_transaction_request(
271    state: &DirectiveAppState,
272    tx_request: TransactionRequest,
273    action_key: ActionKey,
274    account: WalletAddress,
275    nonce: u64,
276    recovered_signer: WalletAddress,
277) -> Result<(), ApiError> {
278    let request_id = tx_request.request_id.clone();
279    let timestamp = tx_request.timestamp;
280    let message = EngineMessage::TransactionRequest(tx_request);
281    let request_uuid = request_id.parse::<uuid::Uuid>().map_err(|e| {
282        ApiError::internal_error(format!(
283            "Transaction request_id '{}' is not a valid UUID: {}",
284            request_id, e
285        ))
286    })?;
287    let command_data = hypercall_types::serialize_to_wire_bytes(&json!({
288        "account": account,
289        "actionKey": action_key.as_str(),
290        "nonce": nonce,
291        "recoveredSigner": recovered_signer,
292    }));
293
294    let journal = state
295        .transaction_request_journal
296        .clone()
297        .ok_or_else(|| ApiError::internal_error("Transaction request journal is not configured"))?;
298    journal
299        .persist_transaction_request(timestamp, command_data, message.clone(), request_uuid)
300        .await
301        .map_err(|e| {
302            ApiError::internal_error(format!(
303                "Failed to append transaction request to journal: {}",
304                e
305            ))
306        })?;
307
308    state
309        .event_bus_sender
310        .send(message)
311        .map_err(|_| ApiError::internal_error("Failed to enqueue transaction request"))
312}
313
314pub async fn typed_data(
315    Path(action_key): Path<String>,
316    State(state): State<DirectiveAppState>,
317    body: AxumBytes,
318) -> Result<SonicJson<sonic_rs::Value>, ApiError> {
319    let action_key = parse_supported_action_key(&action_key)?;
320    let request: TypedDataRequest = parse_json_body(&body)?;
321    let parsed_action = ParsedAction::parse(action_key, request.action)?;
322
323    let response = build_typed_data(
324        action_key,
325        request.account,
326        request.nonce,
327        &parsed_action,
328        state.directive_chain_id,
329    )?;
330
331    Ok(SonicJson(sonic_rs::to_value(&response).map_err(|e| {
332        ApiError::internal_error(format!("Failed to build typed-data response: {}", e))
333    })?))
334}
335
336pub async fn submit(
337    Path(action_key): Path<String>,
338    State(state): State<DirectiveAppState>,
339    body: AxumBytes,
340) -> Result<SonicJson<SubmitResponse>, ApiError> {
341    let action_key = parse_supported_action_key(&action_key)?;
342    let directive_id = Uuid::now_v7().to_string();
343    let mut context = SubmitLogContext::default();
344
345    let result: Result<SubmitResponse, ApiError> = async {
346        let request: SubmitRequest = parse_json_body(&body)?;
347        context.account = Some(request.account);
348
349        let parsed_action = ParsedAction::parse(action_key, request.action)?;
350        let signature = SignatureHex::parse(&request.signature)?;
351        let chain_id = state.directive_chain_id;
352
353        let account = request.account.inner();
354        let nonce = request.nonce.into_inner();
355
356        let recovered_signer = recover_signer(
357            action_key,
358            account,
359            nonce,
360            &parsed_action,
361            signature.bytes(),
362            chain_id,
363        )?;
364        let recovered_signer_wallet = WalletAddress::from(recovered_signer);
365        context.recovered_signer = Some(recovered_signer_wallet);
366
367        authorize_signer(&state, action_key, account, recovered_signer).await?;
368
369        // Ensure the Hydromancer feed is tracking this account for fill/position updates
370        if let Some(feed) = &state.hydromancer_feed {
371            feed.add_account(request.account).await;
372        }
373
374        let base_response = SubmitResponse {
375            stage: DirectiveStage::Rejected, // overwritten below
376            directive_id: directive_id.clone(),
377            action_key: action_key.as_str().to_string(),
378            account: request.account,
379            nonce: request.nonce,
380            recovered_signer: Some(recovered_signer_wallet),
381            tx_hash: None,
382            rejection: None,
383            fills: None,
384        };
385
386        if let ParsedAction::HlLimitOrder(action) = &parsed_action {
387            match check_hl_limit_order(
388                &state.order_sender,
389                &state.trading_halt,
390                state.collateral_registry.as_ref(),
391                request.account,
392                recovered_signer_wallet,
393                nonce,
394                action,
395            )
396            .await?
397            {
398                EngineCheckResult::Rejected(message) => {
399                    return Ok(SubmitResponse {
400                        stage: DirectiveStage::Rejected,
401                        rejection: Some(DirectiveRejection {
402                            code: "policy_rejected".to_string(),
403                            message,
404                        }),
405                        ..base_response
406                    });
407                }
408                EngineCheckResult::Accepted => {}
409            }
410        }
411
412        let directive = encode_directive_for_action(action_key, account, nonce, &parsed_action)?;
413
414        let now_ms = get_timestamp_millis();
415        let tx_request = TransactionRequest {
416            request_id: directive_id.clone(),
417            wallet_address: request.account,
418            account_contract: request.account,
419            transaction_type: TransactionType::UserDirective(SignedDirectiveTx {
420                directive,
421                signature: signature.original().to_string(),
422            }),
423            timestamp: now_ms,
424            expires_at: now_ms + 300_000,
425        };
426
427        enqueue_transaction_request(
428            &state,
429            tx_request,
430            action_key,
431            request.account,
432            nonce,
433            recovered_signer_wallet,
434        )
435        .await?;
436
437        Ok(SubmitResponse {
438            stage: DirectiveStage::Enqueued,
439            ..base_response
440        })
441    }
442    .await;
443
444    log_submit_outcome(action_key, &directive_id, context, &result);
445
446    result.map(SonicJson)
447}