Skip to main content

hypercall_sdk_types/
option_token_address.rs

1//! Pure option-token Create2 derivation helpers.
2
3use crate::{
4    utils::{
5        expiry_date_to_timestamp_checked, strike_to_e8, ExpiryDateConversionError, StrikeScaleError,
6    },
7    OptionType, WalletAddress,
8};
9use alloy::{
10    primitives::{keccak256, Address, B256, U256},
11    sol_types::SolValue,
12};
13use rust_decimal::Decimal;
14use std::str::FromStr;
15
16/// Maximum UTF-8 byte length supported by OpenZeppelin `ShortStrings`.
17pub const MAX_SHORT_STRING_BYTES: usize = 31;
18
19/// Explicit Create2 deployment parameters for option-token derivation.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct OptionTokenDeployment {
22    /// Address of the deployed `OptionRegistry`.
23    pub option_registry: Address,
24    /// Hash of the beacon proxy init code used by the registry.
25    pub beacon_proxy_init_code_hash: B256,
26}
27
28impl OptionTokenDeployment {
29    /// Construct a new set of explicit deployment parameters.
30    pub const fn new(option_registry: Address, beacon_proxy_init_code_hash: B256) -> Self {
31        Self {
32            option_registry,
33            beacon_proxy_init_code_hash,
34        }
35    }
36}
37
38/// Error returned when encoding a canonical ShortString-backed `bytes32`.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ShortStringEncodingError {
41    /// UTF-8 input exceeds the 31-byte OpenZeppelin `ShortStrings` limit.
42    TooLong { byte_len: usize },
43}
44
45impl std::fmt::Display for ShortStringEncodingError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Self::TooLong { byte_len } => write!(
49                f,
50                "Underlying UTF-8 byte length {} exceeds ShortString max of {}",
51                byte_len, MAX_SHORT_STRING_BYTES
52            ),
53        }
54    }
55}
56
57impl std::error::Error for ShortStringEncodingError {}
58
59/// Error returned when deriving an option token address.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum OptionTokenDerivationError {
62    /// Empty underlying symbols are invalid at the public derivation boundary.
63    EmptyUnderlying,
64    /// Underlying failed canonical ShortString encoding.
65    InvalidUnderlying(ShortStringEncodingError),
66    /// Expiry code failed checked conversion to the contract timestamp format.
67    InvalidExpiry(ExpiryDateConversionError),
68    /// Strike failed exact 1e8 scaling.
69    InvalidStrike(StrikeScaleError),
70    /// Option type string is unsupported.
71    UnsupportedOptionType { option_type: String },
72}
73
74impl std::fmt::Display for OptionTokenDerivationError {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            Self::EmptyUnderlying => write!(
78                f,
79                "Underlying symbol must not be empty when deriving option token address"
80            ),
81            Self::InvalidUnderlying(err) => err.fmt(f),
82            Self::InvalidExpiry(err) => err.fmt(f),
83            Self::InvalidStrike(err) => err.fmt(f),
84            Self::UnsupportedOptionType { option_type } => write!(
85                f,
86                "Unsupported option_type '{}' while deriving option token address",
87                option_type
88            ),
89        }
90    }
91}
92
93impl std::error::Error for OptionTokenDerivationError {
94    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
95        match self {
96            Self::InvalidUnderlying(err) => Some(err),
97            Self::InvalidExpiry(err) => Some(err),
98            Self::InvalidStrike(err) => Some(err),
99            Self::EmptyUnderlying | Self::UnsupportedOptionType { .. } => None,
100        }
101    }
102}
103
104/// Encode a UTF-8 string into the canonical OpenZeppelin `ShortStrings` `bytes32` layout.
105///
106/// Empty strings are encoded canonically as `bytes32(0)`.
107pub fn encode_short_string_bytes32(value: &str) -> Result<B256, ShortStringEncodingError> {
108    let raw = value.as_bytes();
109    if raw.len() > MAX_SHORT_STRING_BYTES {
110        return Err(ShortStringEncodingError::TooLong {
111            byte_len: raw.len(),
112        });
113    }
114
115    let mut encoded = [0u8; 32];
116    encoded[..raw.len()].copy_from_slice(raw);
117    encoded[31] = raw.len() as u8;
118    Ok(B256::from(encoded))
119}
120
121/// Derive an option token address from a normalized option-type string.
122pub fn derive_option_token_address(
123    deployment: OptionTokenDeployment,
124    underlying: &str,
125    expiry_yyyymmdd: u64,
126    strike: Decimal,
127    option_type: &str,
128) -> Result<WalletAddress, OptionTokenDerivationError> {
129    let option_type = OptionType::from_str(option_type).map_err(|_| {
130        OptionTokenDerivationError::UnsupportedOptionType {
131            option_type: option_type.to_string(),
132        }
133    })?;
134
135    derive_option_token_address_for_type(
136        deployment,
137        underlying,
138        expiry_yyyymmdd,
139        strike,
140        option_type,
141    )
142}
143
144/// Derive an option token address from explicit deployment parameters and parsed option type.
145pub fn derive_option_token_address_for_type(
146    deployment: OptionTokenDeployment,
147    underlying: &str,
148    expiry_yyyymmdd: u64,
149    strike: Decimal,
150    option_type: OptionType,
151) -> Result<WalletAddress, OptionTokenDerivationError> {
152    if underlying.is_empty() {
153        return Err(OptionTokenDerivationError::EmptyUnderlying);
154    }
155
156    let encoded_underlying = encode_short_string_bytes32(underlying)
157        .map_err(OptionTokenDerivationError::InvalidUnderlying)?;
158    let expiry_timestamp = expiry_date_to_timestamp_checked(underlying, expiry_yyyymmdd)
159        .map_err(OptionTokenDerivationError::InvalidExpiry)?;
160    let strike_e8 = strike_to_e8(strike).map_err(OptionTokenDerivationError::InvalidStrike)?;
161
162    let salt = option_token_salt(encoded_underlying, expiry_timestamp, strike_e8, option_type);
163    let predicted = deployment
164        .option_registry
165        .create2(salt, deployment.beacon_proxy_init_code_hash);
166
167    Ok(WalletAddress::from(predicted))
168}
169
170fn option_token_salt(
171    encoded_underlying: B256,
172    expiry_timestamp: u64,
173    strike_e8: u128,
174    option_type: OptionType,
175) -> B256 {
176    let salt_input = (
177        encoded_underlying,
178        U256::from(expiry_timestamp),
179        U256::from(strike_e8),
180        option_type.is_call(),
181    )
182        .abi_encode_packed();
183    keccak256(salt_input)
184}
185
186#[cfg(test)]
187mod tests {
188    use super::{
189        derive_option_token_address, derive_option_token_address_for_type,
190        encode_short_string_bytes32, OptionTokenDeployment, OptionTokenDerivationError,
191        ShortStringEncodingError,
192    };
193    use crate::{OptionType, WalletAddress};
194    use alloy::primitives::{address, b256, keccak256, Address, B256};
195    use rust_decimal::Decimal;
196    use std::str::FromStr;
197
198    const TEST_DEPLOYMENT: OptionTokenDeployment = OptionTokenDeployment::new(
199        address!("1111111111111111111111111111111111111111"),
200        b256!("2222222222222222222222222222222222222222222222222222222222222222"),
201    );
202
203    #[test]
204    fn test_encode_short_string_bytes32_allows_empty() {
205        assert_eq!(encode_short_string_bytes32("").unwrap(), B256::ZERO);
206    }
207
208    #[test]
209    fn test_encode_short_string_bytes32_matches_canonical_layout() {
210        let encoded = encode_short_string_bytes32("BTC").unwrap();
211        let mut expected = [0u8; 32];
212        expected[0] = b'B';
213        expected[1] = b'T';
214        expected[2] = b'C';
215        expected[31] = 3;
216
217        assert_eq!(encoded, B256::from(expected));
218    }
219
220    #[test]
221    fn test_encode_short_string_bytes32_rejects_too_long_values() {
222        let err = encode_short_string_bytes32("12345678901234567890123456789012").unwrap_err();
223        assert_eq!(err, ShortStringEncodingError::TooLong { byte_len: 32 });
224    }
225
226    #[test]
227    fn test_derive_option_token_address_rejects_empty_underlying() {
228        let err = derive_option_token_address(
229            TEST_DEPLOYMENT,
230            "",
231            20260110,
232            Decimal::from_str("95000").unwrap(),
233            "call",
234        )
235        .unwrap_err();
236
237        assert_eq!(err, OptionTokenDerivationError::EmptyUnderlying);
238    }
239
240    #[test]
241    fn test_derive_option_token_address_rejects_too_long_underlying() {
242        let err = derive_option_token_address(
243            TEST_DEPLOYMENT,
244            "12345678901234567890123456789012",
245            20260110,
246            Decimal::from_str("95000").unwrap(),
247            "call",
248        )
249        .unwrap_err();
250
251        assert_eq!(
252            err,
253            OptionTokenDerivationError::InvalidUnderlying(ShortStringEncodingError::TooLong {
254                byte_len: 32
255            })
256        );
257    }
258
259    #[test]
260    fn test_derive_option_token_address_rejects_invalid_expiry() {
261        let err = derive_option_token_address(
262            TEST_DEPLOYMENT,
263            "BTC",
264            20260230,
265            Decimal::from_str("95000").unwrap(),
266            "call",
267        )
268        .unwrap_err()
269        .to_string();
270
271        assert!(err.contains("Invalid expiry date components"));
272    }
273
274    #[test]
275    fn test_derive_option_token_address_rejects_invalid_strike() {
276        let err = derive_option_token_address(
277            TEST_DEPLOYMENT,
278            "BTC",
279            20260110,
280            Decimal::from_str("95000.000000001").unwrap(),
281            "call",
282        )
283        .unwrap_err()
284        .to_string();
285
286        assert!(err.contains("precision exceeds 8 decimals"));
287    }
288
289    #[test]
290    fn test_derive_option_token_address_rejects_unsupported_option_type() {
291        let err = derive_option_token_address(
292            TEST_DEPLOYMENT,
293            "BTC",
294            20260110,
295            Decimal::from_str("95000").unwrap(),
296            "CALLS",
297        )
298        .unwrap_err();
299
300        assert_eq!(
301            err,
302            OptionTokenDerivationError::UnsupportedOptionType {
303                option_type: "CALLS".to_string()
304            }
305        );
306    }
307
308    #[test]
309    fn test_derive_option_token_address_is_case_sensitive_for_underlying() {
310        let uppercase = derive_option_token_address(
311            TEST_DEPLOYMENT,
312            "BTC",
313            20260110,
314            Decimal::from_str("95000").unwrap(),
315            "call",
316        )
317        .unwrap();
318        let lowercase = derive_option_token_address(
319            TEST_DEPLOYMENT,
320            "btc",
321            20260110,
322            Decimal::from_str("95000").unwrap(),
323            "call",
324        )
325        .unwrap();
326
327        assert_ne!(uppercase, lowercase);
328    }
329
330    #[test]
331    fn test_derive_option_token_address_matches_manual_create2_formula() {
332        let derived = derive_option_token_address_for_type(
333            TEST_DEPLOYMENT,
334            "bTc",
335            20260110,
336            Decimal::from_str("95000").unwrap(),
337            OptionType::Call,
338        )
339        .unwrap();
340
341        let underlying = encode_short_string_bytes32("bTc").unwrap();
342        let expiry_timestamp =
343            crate::utils::expiry_date_to_timestamp_checked("bTc", 20260110).unwrap();
344        let strike_e8 = 9_500_000_000_000u128;
345
346        let mut salt_input = Vec::with_capacity(97);
347        salt_input.extend_from_slice(underlying.as_slice());
348        salt_input.extend_from_slice(&uint256_bytes_from_u64(expiry_timestamp));
349        salt_input.extend_from_slice(&uint256_bytes_from_u128(strike_e8));
350        salt_input.push(1);
351        let salt = keccak256(salt_input);
352
353        let expected = manual_create2_address(
354            TEST_DEPLOYMENT.option_registry,
355            salt,
356            TEST_DEPLOYMENT.beacon_proxy_init_code_hash,
357        );
358
359        assert_eq!(derived, WalletAddress::from(expected));
360    }
361
362    fn uint256_bytes_from_u64(value: u64) -> [u8; 32] {
363        let mut encoded = [0u8; 32];
364        encoded[24..].copy_from_slice(&value.to_be_bytes());
365        encoded
366    }
367
368    fn uint256_bytes_from_u128(value: u128) -> [u8; 32] {
369        let mut encoded = [0u8; 32];
370        encoded[16..].copy_from_slice(&value.to_be_bytes());
371        encoded
372    }
373
374    fn manual_create2_address(deployer: Address, salt: B256, init_code_hash: B256) -> Address {
375        let mut preimage = Vec::with_capacity(85);
376        preimage.push(0xff);
377        preimage.extend_from_slice(deployer.as_slice());
378        preimage.extend_from_slice(salt.as_slice());
379        preimage.extend_from_slice(init_code_hash.as_slice());
380
381        Address::from_slice(&keccak256(preimage).as_slice()[12..])
382    }
383}