1use 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
16pub const MAX_SHORT_STRING_BYTES: usize = 31;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub struct OptionTokenDeployment {
22 pub option_registry: Address,
24 pub beacon_proxy_init_code_hash: B256,
26}
27
28impl OptionTokenDeployment {
29 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#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ShortStringEncodingError {
41 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#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum OptionTokenDerivationError {
62 EmptyUnderlying,
64 InvalidUnderlying(ShortStringEncodingError),
66 InvalidExpiry(ExpiryDateConversionError),
68 InvalidStrike(StrikeScaleError),
70 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
104pub 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
121pub 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
144pub 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}