Skip to main content

hypercall_sdk_types/directives/
signing.rs

1use std::fmt;
2use std::str::FromStr;
3
4use alloy::{
5    primitives::{Address, FixedBytes, I256, U256},
6    signers::Signer,
7};
8use sonic_rs::{JsonValueTrait, Value};
9
10use super::{
11    hypercall_api_domain, hypercall_manager_domain, hypercall_rsm_domain, ActionKey,
12    CancelOrderByCloid, CancelOrderByOid, CreditOption, CreditToken, HCUpdateApiWallet, HLCancel,
13    HLCancelByCloid, HLOrder, HLSendAsset, LimitOrder, SendAsset, StartLiquidation,
14    StopLiquidation, SystemCreditOption, SystemCreditToken, SystemStartLiquidation,
15    SystemStopLiquidation, SystemWithdrawToken, UpdateApiWallet, WithdrawToken,
16};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct DirectiveSigningError {
20    message: String,
21}
22
23impl DirectiveSigningError {
24    fn new(message: impl Into<String>) -> Self {
25        Self {
26            message: message.into(),
27        }
28    }
29}
30
31impl fmt::Display for DirectiveSigningError {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        write!(f, "{}", self.message)
34    }
35}
36
37impl std::error::Error for DirectiveSigningError {}
38
39fn parse_uint_field(action: &Value, field: &str) -> Result<u128, DirectiveSigningError> {
40    let value = action
41        .get(field)
42        .ok_or_else(|| DirectiveSigningError::new(format!("Missing action field '{}'", field)))?;
43
44    if value.is_number() {
45        value
46            .as_u64()
47            .map(u128::from)
48            .ok_or_else(|| DirectiveSigningError::new(format!("Invalid integer in '{}'", field)))
49    } else if value.is_str() {
50        let s = value.as_str().unwrap();
51        if s.is_empty() || !s.as_bytes().iter().all(|b| b.is_ascii_digit()) {
52            return Err(DirectiveSigningError::new(format!(
53                "Invalid decimal string in '{}'",
54                field
55            )));
56        }
57        s.parse::<u128>().map_err(|e| {
58            DirectiveSigningError::new(format!("Invalid numeric range in '{}': {}", field, e))
59        })
60    } else {
61        Err(DirectiveSigningError::new(format!(
62            "Field '{}' must be number or decimal string",
63            field
64        )))
65    }
66}
67
68fn parse_u256_field(action: &Value, field: &str) -> Result<U256, DirectiveSigningError> {
69    let value = action
70        .get(field)
71        .ok_or_else(|| DirectiveSigningError::new(format!("Missing action field '{}'", field)))?;
72
73    if value.is_number() {
74        value
75            .as_u64()
76            .map(U256::from)
77            .ok_or_else(|| DirectiveSigningError::new(format!("Invalid integer in '{}'", field)))
78    } else if value.is_str() {
79        let s = value.as_str().unwrap();
80        if s.is_empty() || !s.as_bytes().iter().all(|b| b.is_ascii_digit()) {
81            return Err(DirectiveSigningError::new(format!(
82                "Invalid decimal string in '{}'",
83                field
84            )));
85        }
86        U256::from_str(s).map_err(|e| {
87            DirectiveSigningError::new(format!("Invalid numeric range in '{}': {}", field, e))
88        })
89    } else {
90        Err(DirectiveSigningError::new(format!(
91            "Field '{}' must be number or decimal string",
92            field
93        )))
94    }
95}
96
97fn parse_bool_field(action: &Value, field: &str) -> Result<bool, DirectiveSigningError> {
98    action.get(field).and_then(|v| v.as_bool()).ok_or_else(|| {
99        DirectiveSigningError::new(format!("Missing boolean action field '{}'", field))
100    })
101}
102
103fn parse_address_field(action: &Value, field: &str) -> Result<Address, DirectiveSigningError> {
104    let value = action.get(field).and_then(|v| v.as_str()).ok_or_else(|| {
105        DirectiveSigningError::new(format!("Missing address action field '{}'", field))
106    })?;
107    Address::from_str(value)
108        .map_err(|e| DirectiveSigningError::new(format!("Invalid address in '{}': {}", field, e)))
109}
110
111fn parse_int_field(action: &Value, field: &str) -> Result<i128, DirectiveSigningError> {
112    let value = action
113        .get(field)
114        .ok_or_else(|| DirectiveSigningError::new(format!("Missing action field '{}'", field)))?;
115
116    if value.is_i64() {
117        value
118            .as_i64()
119            .map(i128::from)
120            .ok_or_else(|| DirectiveSigningError::new(format!("Invalid integer in '{}'", field)))
121    } else if value.is_str() {
122        value.as_str().unwrap().parse::<i128>().map_err(|e| {
123            DirectiveSigningError::new(format!("Invalid numeric range in '{}': {}", field, e))
124        })
125    } else {
126        Err(DirectiveSigningError::new(format!(
127            "Field '{}' must be signed integer or decimal string",
128            field
129        )))
130    }
131}
132
133fn parse_bytes32_field(action: &Value, field: &str) -> Result<[u8; 32], DirectiveSigningError> {
134    let value = action.get(field).and_then(|v| v.as_str()).ok_or_else(|| {
135        DirectiveSigningError::new(format!("Missing bytes32 action field '{}'", field))
136    })?;
137    let hex_str = value.strip_prefix("0x").ok_or_else(|| {
138        DirectiveSigningError::new(format!("Field '{}' must be 0x-prefixed hex", field))
139    })?;
140    if hex_str.len() != 64 {
141        return Err(DirectiveSigningError::new(format!(
142            "Field '{}' must be 32 bytes (64 hex chars)",
143            field
144        )));
145    }
146    let bytes = hex::decode(hex_str).map_err(|e| {
147        DirectiveSigningError::new(format!("Invalid bytes32 in '{}': {}", field, e))
148    })?;
149    bytes.try_into().map_err(|_| {
150        DirectiveSigningError::new(format!("Field '{}' must decode to exactly 32 bytes", field))
151    })
152}
153
154fn normalize_signature(signature: impl fmt::Display) -> String {
155    let sig = format!("{}", signature);
156    if sig.starts_with("0x") {
157        sig
158    } else {
159        format!("0x{}", sig)
160    }
161}
162
163pub async fn sign_directive_with_signer<S>(
164    signer: &S,
165    action_key: ActionKey,
166    account: Address,
167    nonce: u64,
168    action: &Value,
169    chain_id: u64,
170) -> Result<String, DirectiveSigningError>
171where
172    S: Signer + Sync,
173{
174    match action_key {
175        ActionKey::HlLimitOrder | ActionKey::RsmHlLimitOrder => {
176            let asset = parse_uint_field(action, "asset")?;
177            let is_buy = parse_bool_field(action, "isBuy")?;
178            let limit_px = parse_uint_field(action, "limitPx")?;
179            let sz = parse_uint_field(action, "sz")?;
180            let reduce_only = parse_bool_field(action, "reduceOnly")?;
181            let encoded_tif = parse_uint_field(action, "encodedTif")?;
182            let cloid = parse_uint_field(action, "cloid")?;
183
184            let encoded_tif_u8 = u8::try_from(encoded_tif).map_err(|e| {
185                DirectiveSigningError::new(format!("Invalid encodedTif range: {}", e))
186            })?;
187            if !matches!(encoded_tif_u8, 1..=3) {
188                return Err(DirectiveSigningError::new(
189                    "encodedTif must be one of 1, 2, or 3",
190                ));
191            }
192
193            let message = HLOrder {
194                account,
195                nonce,
196                action: LimitOrder {
197                    asset: u32::try_from(asset).map_err(|e| {
198                        DirectiveSigningError::new(format!("Invalid asset range: {}", e))
199                    })?,
200                    isBuy: is_buy,
201                    limitPx: u64::try_from(limit_px).map_err(|e| {
202                        DirectiveSigningError::new(format!("Invalid limitPx range: {}", e))
203                    })?,
204                    sz: u64::try_from(sz).map_err(|e| {
205                        DirectiveSigningError::new(format!("Invalid sz range: {}", e))
206                    })?,
207                    reduceOnly: reduce_only,
208                    encodedTif: encoded_tif_u8,
209                    cloid,
210                },
211            };
212
213            let domain = match action_key {
214                ActionKey::HlLimitOrder => hypercall_api_domain(chain_id).map_err(|e| {
215                    DirectiveSigningError::new(format!("Invalid API signing domain: {}", e))
216                })?,
217                ActionKey::RsmHlLimitOrder => hypercall_rsm_domain(chain_id).map_err(|e| {
218                    DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
219                })?,
220                _ => unreachable!(),
221            };
222            let signature = signer
223                .sign_typed_data(&message, &domain)
224                .await
225                .map_err(|e| {
226                    DirectiveSigningError::new(format!("Failed to sign HLOrder: {}", e))
227                })?;
228            Ok(normalize_signature(signature))
229        }
230        ActionKey::HlCancelByOid | ActionKey::RsmHlCancelByOid => {
231            let asset = parse_uint_field(action, "asset")?;
232            let oid = parse_uint_field(action, "oid")?;
233
234            let message = HLCancel {
235                account,
236                nonce,
237                action: CancelOrderByOid {
238                    asset: u32::try_from(asset).map_err(|e| {
239                        DirectiveSigningError::new(format!("Invalid asset range: {}", e))
240                    })?,
241                    oid: u64::try_from(oid).map_err(|e| {
242                        DirectiveSigningError::new(format!("Invalid oid range: {}", e))
243                    })?,
244                },
245            };
246
247            let domain = match action_key {
248                ActionKey::HlCancelByOid => hypercall_api_domain(chain_id).map_err(|e| {
249                    DirectiveSigningError::new(format!("Invalid API signing domain: {}", e))
250                })?,
251                ActionKey::RsmHlCancelByOid => hypercall_rsm_domain(chain_id).map_err(|e| {
252                    DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
253                })?,
254                _ => unreachable!(),
255            };
256            let signature = signer
257                .sign_typed_data(&message, &domain)
258                .await
259                .map_err(|e| {
260                    DirectiveSigningError::new(format!("Failed to sign HLCancel: {}", e))
261                })?;
262            Ok(normalize_signature(signature))
263        }
264        ActionKey::HlCancelByCloid | ActionKey::RsmHlCancelByCloid => {
265            let asset = parse_uint_field(action, "asset")?;
266            let cloid = parse_uint_field(action, "cloid")?;
267
268            let message = HLCancelByCloid {
269                account,
270                nonce,
271                action: CancelOrderByCloid {
272                    asset: u32::try_from(asset).map_err(|e| {
273                        DirectiveSigningError::new(format!("Invalid asset range: {}", e))
274                    })?,
275                    cloid,
276                },
277            };
278
279            let domain = match action_key {
280                ActionKey::HlCancelByCloid => hypercall_api_domain(chain_id).map_err(|e| {
281                    DirectiveSigningError::new(format!("Invalid API signing domain: {}", e))
282                })?,
283                ActionKey::RsmHlCancelByCloid => hypercall_rsm_domain(chain_id).map_err(|e| {
284                    DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
285                })?,
286                _ => unreachable!(),
287            };
288            let signature = signer
289                .sign_typed_data(&message, &domain)
290                .await
291                .map_err(|e| {
292                    DirectiveSigningError::new(format!("Failed to sign HLCancelByCloid: {}", e))
293                })?;
294            Ok(normalize_signature(signature))
295        }
296        ActionKey::HcUpdateApiWallet => {
297            let name = parse_bytes32_field(action, "name")?;
298            let addr = parse_address_field(action, "addr")?;
299
300            let message = HCUpdateApiWallet {
301                account,
302                nonce,
303                action: UpdateApiWallet {
304                    name: name.into(),
305                    addr,
306                },
307            };
308
309            let domain = hypercall_manager_domain(chain_id).map_err(|e| {
310                DirectiveSigningError::new(format!("Invalid manager signing domain: {}", e))
311            })?;
312            let signature = signer
313                .sign_typed_data(&message, &domain)
314                .await
315                .map_err(|e| {
316                    DirectiveSigningError::new(format!("Failed to sign HCUpdateApiWallet: {}", e))
317                })?;
318            Ok(normalize_signature(signature))
319        }
320        ActionKey::HlSendAsset | ActionKey::RsmHlSendAsset => {
321            let destination = parse_address_field(action, "destination")?;
322            let sub_account = parse_address_field(action, "subAccount")?;
323            let src_dex = parse_uint_field(action, "srcDex")?;
324            let dst_dex = parse_uint_field(action, "dstDex")?;
325            let token = parse_uint_field(action, "token")?;
326            let amount_wei = parse_uint_field(action, "amountWei")?;
327
328            let message = HLSendAsset {
329                account,
330                nonce,
331                action: SendAsset {
332                    destination,
333                    subAccount: sub_account,
334                    srcDex: u32::try_from(src_dex).map_err(|e| {
335                        DirectiveSigningError::new(format!("Invalid srcDex range: {}", e))
336                    })?,
337                    dstDex: u32::try_from(dst_dex).map_err(|e| {
338                        DirectiveSigningError::new(format!("Invalid dstDex range: {}", e))
339                    })?,
340                    token: u64::try_from(token).map_err(|e| {
341                        DirectiveSigningError::new(format!("Invalid token range: {}", e))
342                    })?,
343                    amountWei: u64::try_from(amount_wei).map_err(|e| {
344                        DirectiveSigningError::new(format!("Invalid amountWei range: {}", e))
345                    })?,
346                },
347            };
348
349            let domain = match action_key {
350                ActionKey::HlSendAsset => hypercall_manager_domain(chain_id).map_err(|e| {
351                    DirectiveSigningError::new(format!("Invalid manager signing domain: {}", e))
352                })?,
353                ActionKey::RsmHlSendAsset => hypercall_rsm_domain(chain_id).map_err(|e| {
354                    DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
355                })?,
356                _ => unreachable!(),
357            };
358            let signature = signer
359                .sign_typed_data(&message, &domain)
360                .await
361                .map_err(|e| {
362                    DirectiveSigningError::new(format!("Failed to sign HLSendAsset: {}", e))
363                })?;
364            Ok(normalize_signature(signature))
365        }
366        ActionKey::SystemCreditToken => {
367            let src_dex = parse_uint_field(action, "srcDex")?;
368            let dst_dex = parse_uint_field(action, "dstDex")?;
369            let token = parse_uint_field(action, "token")?;
370            let amount_wei = parse_uint_field(action, "amountWei")?;
371
372            let message = SystemCreditToken {
373                account,
374                nonce,
375                action: CreditToken {
376                    srcDex: u32::try_from(src_dex).map_err(|e| {
377                        DirectiveSigningError::new(format!("Invalid srcDex range: {}", e))
378                    })?,
379                    dstDex: u32::try_from(dst_dex).map_err(|e| {
380                        DirectiveSigningError::new(format!("Invalid dstDex range: {}", e))
381                    })?,
382                    token: u64::try_from(token).map_err(|e| {
383                        DirectiveSigningError::new(format!("Invalid token range: {}", e))
384                    })?,
385                    amountWei: u64::try_from(amount_wei).map_err(|e| {
386                        DirectiveSigningError::new(format!("Invalid amountWei range: {}", e))
387                    })?,
388                },
389            };
390
391            let domain = hypercall_rsm_domain(chain_id).map_err(|e| {
392                DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
393            })?;
394            let signature = signer
395                .sign_typed_data(&message, &domain)
396                .await
397                .map_err(|e| {
398                    DirectiveSigningError::new(format!("Failed to sign SystemCreditToken: {}", e))
399                })?;
400            Ok(normalize_signature(signature))
401        }
402        ActionKey::SystemCreditOption => {
403            let underlying = parse_bytes32_field(action, "underlying")?;
404            let expiry = parse_u256_field(action, "expiry")?;
405            let strike = parse_u256_field(action, "strike")?;
406            let is_call = parse_bool_field(action, "isCall")?;
407            let amount_wei = parse_u256_field(action, "amountWei")?;
408
409            let message = SystemCreditOption {
410                account,
411                nonce,
412                action: CreditOption {
413                    underlying: FixedBytes::<32>::from(underlying),
414                    expiry,
415                    strike,
416                    isCall: is_call,
417                    amountWei: amount_wei,
418                },
419            };
420
421            let domain = hypercall_rsm_domain(chain_id).map_err(|e| {
422                DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
423            })?;
424            let signature = signer
425                .sign_typed_data(&message, &domain)
426                .await
427                .map_err(|e| {
428                    DirectiveSigningError::new(format!("Failed to sign SystemCreditOption: {}", e))
429                })?;
430            Ok(normalize_signature(signature))
431        }
432        ActionKey::SystemStartLiquidation => {
433            let equity = parse_int_field(action, "equity")?;
434            let margin_needed = parse_u256_field(action, "marginNeeded")?;
435
436            let message = SystemStartLiquidation {
437                account,
438                nonce,
439                action: StartLiquidation {
440                    equity: I256::try_from(equity).map_err(|e| {
441                        DirectiveSigningError::new(format!("Invalid equity range: {}", e))
442                    })?,
443                    marginNeeded: margin_needed,
444                },
445            };
446
447            let domain = hypercall_rsm_domain(chain_id).map_err(|e| {
448                DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
449            })?;
450            let signature = signer
451                .sign_typed_data(&message, &domain)
452                .await
453                .map_err(|e| {
454                    DirectiveSigningError::new(format!(
455                        "Failed to sign SystemStartLiquidation: {}",
456                        e
457                    ))
458                })?;
459            Ok(normalize_signature(signature))
460        }
461        ActionKey::SystemStopLiquidation => {
462            let start_time = parse_u256_field(action, "startTime")?;
463
464            let message = SystemStopLiquidation {
465                account,
466                nonce,
467                action: StopLiquidation {
468                    startTime: start_time,
469                },
470            };
471
472            let domain = hypercall_rsm_domain(chain_id).map_err(|e| {
473                DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
474            })?;
475            let signature = signer
476                .sign_typed_data(&message, &domain)
477                .await
478                .map_err(|e| {
479                    DirectiveSigningError::new(format!(
480                        "Failed to sign SystemStopLiquidation: {}",
481                        e
482                    ))
483                })?;
484            Ok(normalize_signature(signature))
485        }
486        ActionKey::SystemWithdrawToken => {
487            let destination = parse_address_field(action, "destination")?;
488            let sub_account = parse_address_field(action, "subAccount")?;
489            let src_dex = parse_uint_field(action, "srcDex")?;
490            let dst_dex = parse_uint_field(action, "dstDex")?;
491            let token = parse_uint_field(action, "token")?;
492            let amount_wei = parse_uint_field(action, "amountWei")?;
493
494            let message = SystemWithdrawToken {
495                account,
496                nonce,
497                action: WithdrawToken {
498                    destination,
499                    subAccount: sub_account,
500                    srcDex: u32::try_from(src_dex).map_err(|e| {
501                        DirectiveSigningError::new(format!("Invalid srcDex range: {}", e))
502                    })?,
503                    dstDex: u32::try_from(dst_dex).map_err(|e| {
504                        DirectiveSigningError::new(format!("Invalid dstDex range: {}", e))
505                    })?,
506                    token: u64::try_from(token).map_err(|e| {
507                        DirectiveSigningError::new(format!("Invalid token range: {}", e))
508                    })?,
509                    amountWei: u64::try_from(amount_wei).map_err(|e| {
510                        DirectiveSigningError::new(format!("Invalid amountWei range: {}", e))
511                    })?,
512                },
513            };
514
515            let domain = hypercall_rsm_domain(chain_id).map_err(|e| {
516                DirectiveSigningError::new(format!("Invalid RSM signing domain: {}", e))
517            })?;
518            let signature = signer
519                .sign_typed_data(&message, &domain)
520                .await
521                .map_err(|e| {
522                    DirectiveSigningError::new(format!("Failed to sign SystemWithdrawToken: {}", e))
523                })?;
524            Ok(normalize_signature(signature))
525        }
526        ActionKey::HcTransferOption => Err(DirectiveSigningError::new(format!(
527            "Unsupported directive action key: {}",
528            action_key.as_str()
529        ))),
530    }
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use sonic_rs::json;
537
538    #[test]
539    fn parse_bool_field_accepts_boolean_values() {
540        let action = json!({
541            "isCall": true,
542            "reduceOnly": false,
543        });
544
545        assert!(parse_bool_field(&action, "isCall").unwrap());
546        assert!(!parse_bool_field(&action, "reduceOnly").unwrap());
547    }
548
549    #[test]
550    fn parse_bool_field_rejects_missing_or_non_boolean_values() {
551        let action = json!({
552            "isCall": "true",
553            "reduceOnly": 0,
554        });
555
556        assert_eq!(
557            parse_bool_field(&action, "missing")
558                .unwrap_err()
559                .to_string(),
560            "Missing boolean action field 'missing'"
561        );
562        assert_eq!(
563            parse_bool_field(&action, "isCall").unwrap_err().to_string(),
564            "Missing boolean action field 'isCall'"
565        );
566        assert_eq!(
567            parse_bool_field(&action, "reduceOnly")
568                .unwrap_err()
569                .to_string(),
570            "Missing boolean action field 'reduceOnly'"
571        );
572    }
573
574    #[test]
575    fn parse_u256_field_accepts_numbers_and_decimal_strings() {
576        let max_u256 = U256::MAX.to_string();
577        let action = json!({
578            "expiry": 1_778_361_600_u64,
579            "strike": "5000000000000",
580            "amountWei": max_u256,
581        });
582
583        assert_eq!(
584            parse_u256_field(&action, "expiry").unwrap(),
585            U256::from(1_778_361_600_u64)
586        );
587        assert_eq!(
588            parse_u256_field(&action, "strike").unwrap(),
589            U256::from(5_000_000_000_000_u64)
590        );
591        assert_eq!(parse_u256_field(&action, "amountWei").unwrap(), U256::MAX);
592    }
593
594    #[test]
595    fn parse_u256_field_rejects_missing_invalid_and_negative_values() {
596        let action = json!({
597            "empty": "",
598            "negative": "-1",
599            "alpha": "123abc",
600            "bool": true,
601        });
602
603        assert_eq!(
604            parse_u256_field(&action, "missing")
605                .unwrap_err()
606                .to_string(),
607            "Missing action field 'missing'"
608        );
609        assert_eq!(
610            parse_u256_field(&action, "empty").unwrap_err().to_string(),
611            "Invalid decimal string in 'empty'"
612        );
613        assert_eq!(
614            parse_u256_field(&action, "negative")
615                .unwrap_err()
616                .to_string(),
617            "Invalid decimal string in 'negative'"
618        );
619        assert_eq!(
620            parse_u256_field(&action, "alpha").unwrap_err().to_string(),
621            "Invalid decimal string in 'alpha'"
622        );
623        assert_eq!(
624            parse_u256_field(&action, "bool").unwrap_err().to_string(),
625            "Field 'bool' must be number or decimal string"
626        );
627    }
628}