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 pub hl_info_url: Option<String>,
45 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 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, 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}