o2_sdk/
crypto.rs

1use secp256k1::ecdsa::RecoverableSignature;
2/// Cryptographic operations for O2 Exchange: key generation, signing, and address derivation.
3///
4/// Implements:
5/// - Fuel-native key generation (SHA-256 address derivation)
6/// - EVM key generation (keccak256 address derivation)
7/// - personalSign (Fuel prefix + SHA-256)
8/// - rawSign (plain SHA-256)
9/// - evm_personal_sign (Ethereum prefix + keccak256)
10/// - fuel_compact_sign with low-s normalization and recovery ID in MSB of byte 32
11use secp256k1::{Message, PublicKey, Secp256k1, SecretKey};
12use sha2::{Digest as Sha256Digest, Sha256};
13use sha3::Keccak256;
14
15use crate::errors::O2Error;
16
17/// Half of the secp256k1 group order, used for low-s normalization.
18const SECP256K1_ORDER_HALF: [u8; 32] = [
19    0x7F, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
20    0x5D, 0x57, 0x6E, 0x73, 0x57, 0xA4, 0x50, 0x1D, 0xDF, 0xE9, 0x2F, 0x46, 0x68, 0x1B, 0x20, 0xA0,
21];
22
23/// Full secp256k1 group order.
24const SECP256K1_ORDER: [u8; 32] = [
25    0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFE,
26    0xBA, 0xAE, 0xDC, 0xE6, 0xAF, 0x48, 0xA0, 0x3B, 0xBF, 0xD2, 0x5E, 0x8C, 0xD0, 0x36, 0x41, 0x41,
27];
28
29/// A Fuel-native wallet with SHA-256 derived B256 address.
30#[derive(Debug, Clone)]
31pub struct Wallet {
32    pub private_key: [u8; 32],
33    pub public_key: [u8; 65],
34    pub b256_address: [u8; 32],
35}
36
37/// An EVM-compatible wallet with keccak256 derived address, zero-padded to B256.
38#[derive(Debug, Clone)]
39pub struct EvmWallet {
40    pub private_key: [u8; 32],
41    pub public_key: [u8; 65],
42    pub evm_address: [u8; 20],
43    pub b256_address: [u8; 32],
44}
45
46/// Generate a Fuel-native secp256k1 keypair.
47/// Address = SHA-256(uncompressed_pubkey[1..65])
48pub fn generate_keypair() -> Result<Wallet, O2Error> {
49    let secp = Secp256k1::new();
50    let mut rng = rand::thread_rng();
51    let (secret_key, public_key) = secp.generate_keypair(&mut rng);
52
53    let pubkey_bytes = public_key.serialize_uncompressed();
54    let address = Sha256::digest(&pubkey_bytes[1..65]);
55
56    Ok(Wallet {
57        private_key: secret_key.secret_bytes(),
58        public_key: pubkey_bytes,
59        b256_address: address.into(),
60    })
61}
62
63/// Generate an EVM-compatible keypair.
64/// EVM address = last 20 bytes of keccak256(uncompressed_pubkey[1..65])
65/// B256 address = 12 zero bytes + 20 EVM address bytes
66pub fn generate_evm_keypair() -> Result<EvmWallet, O2Error> {
67    let secp = Secp256k1::new();
68    let mut rng = rand::thread_rng();
69    let (secret_key, public_key) = secp.generate_keypair(&mut rng);
70
71    let pubkey_bytes = public_key.serialize_uncompressed();
72    let keccak_hash = Keccak256::digest(&pubkey_bytes[1..65]);
73
74    let mut evm_address = [0u8; 20];
75    evm_address.copy_from_slice(&keccak_hash[12..32]);
76
77    let mut b256_address = [0u8; 32];
78    b256_address[12..32].copy_from_slice(&evm_address);
79
80    Ok(EvmWallet {
81        private_key: secret_key.secret_bytes(),
82        public_key: pubkey_bytes,
83        evm_address,
84        b256_address,
85    })
86}
87
88/// Load a Fuel-native wallet from a private key.
89pub fn load_wallet(private_key: &[u8; 32]) -> Result<Wallet, O2Error> {
90    let secp = Secp256k1::new();
91    let secret_key = SecretKey::from_slice(private_key)
92        .map_err(|e| O2Error::CryptoError(format!("Invalid private key: {e}")))?;
93    let public_key = PublicKey::from_secret_key(&secp, &secret_key);
94    let pubkey_bytes = public_key.serialize_uncompressed();
95    let address = Sha256::digest(&pubkey_bytes[1..65]);
96
97    Ok(Wallet {
98        private_key: *private_key,
99        public_key: pubkey_bytes,
100        b256_address: address.into(),
101    })
102}
103
104/// Load an EVM wallet from a private key.
105pub fn load_evm_wallet(private_key: &[u8; 32]) -> Result<EvmWallet, O2Error> {
106    let secp = Secp256k1::new();
107    let secret_key = SecretKey::from_slice(private_key)
108        .map_err(|e| O2Error::CryptoError(format!("Invalid private key: {e}")))?;
109    let public_key = PublicKey::from_secret_key(&secp, &secret_key);
110    let pubkey_bytes = public_key.serialize_uncompressed();
111    let keccak_hash = Keccak256::digest(&pubkey_bytes[1..65]);
112
113    let mut evm_address = [0u8; 20];
114    evm_address.copy_from_slice(&keccak_hash[12..32]);
115
116    let mut b256_address = [0u8; 32];
117    b256_address[12..32].copy_from_slice(&evm_address);
118
119    Ok(EvmWallet {
120        private_key: *private_key,
121        public_key: pubkey_bytes,
122        evm_address,
123        b256_address,
124    })
125}
126
127/// Compare two 32-byte big-endian numbers: returns true if a > b.
128fn gt_be(a: &[u8; 32], b: &[u8; 32]) -> bool {
129    for i in 0..32 {
130        if a[i] > b[i] {
131            return true;
132        }
133        if a[i] < b[i] {
134            return false;
135        }
136    }
137    false
138}
139
140/// Negate a 32-byte big-endian number modulo the secp256k1 order.
141/// result = ORDER - value
142fn negate_s(s: &[u8; 32]) -> [u8; 32] {
143    let mut result = [0u8; 32];
144    let mut borrow: u16 = 0;
145    for i in (0..32).rev() {
146        let diff = SECP256K1_ORDER[i] as u16 - s[i] as u16 - borrow;
147        result[i] = diff as u8;
148        borrow = if diff > 255 { 1 } else { 0 };
149    }
150    result
151}
152
153/// Sign a 32-byte digest and return a 64-byte Fuel compact signature.
154///
155/// The recovery ID is embedded in the MSB of byte 32 (first byte of s).
156/// Low-s normalization is applied: if s > order/2, negate s and flip recovery_id.
157pub fn fuel_compact_sign(private_key: &[u8; 32], digest: &[u8; 32]) -> Result<[u8; 64], O2Error> {
158    let secp = Secp256k1::new();
159    let secret_key = SecretKey::from_slice(private_key)
160        .map_err(|e| O2Error::CryptoError(format!("Invalid private key: {e}")))?;
161    let message = Message::from_digest(*digest);
162
163    let recoverable_sig: RecoverableSignature = secp.sign_ecdsa_recoverable(&message, &secret_key);
164    let (rec_id, compact) = recoverable_sig.serialize_compact();
165    let mut recovery_id = rec_id.to_i32() as u8;
166
167    let mut r = [0u8; 32];
168    let mut s = [0u8; 32];
169    r.copy_from_slice(&compact[0..32]);
170    s.copy_from_slice(&compact[32..64]);
171
172    // Low-s normalization
173    if gt_be(&s, &SECP256K1_ORDER_HALF) {
174        s = negate_s(&s);
175        recovery_id ^= 1;
176    }
177
178    // Embed recovery ID in MSB of s[0]
179    s[0] = (recovery_id << 7) | (s[0] & 0x7F);
180
181    let mut result = [0u8; 64];
182    result[0..32].copy_from_slice(&r);
183    result[32..64].copy_from_slice(&s);
184    Ok(result)
185}
186
187/// Sign using Fuel's personalSign format (for session creation).
188/// prefix = b"\x19Fuel Signed Message:\n" + str(len(message)) + message
189/// digest = sha256(prefix)
190pub fn personal_sign(private_key: &[u8; 32], message: &[u8]) -> Result<[u8; 64], O2Error> {
191    let prefix = b"\x19Fuel Signed Message:\n";
192    let length_str = message.len().to_string();
193
194    let mut hasher = Sha256::new();
195    hasher.update(prefix);
196    hasher.update(length_str.as_bytes());
197    hasher.update(message);
198    let digest: [u8; 32] = hasher.finalize().into();
199
200    fuel_compact_sign(private_key, &digest)
201}
202
203/// Sign using raw SHA-256 hash, no prefix (for session actions).
204/// digest = sha256(message)
205pub fn raw_sign(private_key: &[u8; 32], message: &[u8]) -> Result<[u8; 64], O2Error> {
206    let digest: [u8; 32] = Sha256::digest(message).into();
207    fuel_compact_sign(private_key, &digest)
208}
209
210/// Sign using Ethereum's personal_sign format (for EVM owner session creation).
211/// prefix = "\x19Ethereum Signed Message:\n" + str(len(message))
212/// digest = keccak256(prefix_bytes + message)
213pub fn evm_personal_sign(private_key: &[u8; 32], message: &[u8]) -> Result<[u8; 64], O2Error> {
214    let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
215
216    let mut hasher = Keccak256::new();
217    hasher.update(prefix.as_bytes());
218    hasher.update(message);
219    let digest: [u8; 32] = hasher.finalize().into();
220
221    fuel_compact_sign(private_key, &digest)
222}
223
224/// Trait for wallets that can sign messages for O2 Exchange operations.
225///
226/// Implemented for both [`Wallet`] (Fuel-native, SHA-256) and [`EvmWallet`] (keccak256).
227pub trait SignableWallet {
228    /// The B256 address used as the owner identity.
229    fn b256_address(&self) -> &[u8; 32];
230    /// Sign a message using the wallet's personal_sign scheme.
231    ///
232    /// - Fuel wallets use `\x19Fuel Signed Message:\n` prefix + SHA-256.
233    /// - EVM wallets use `\x19Ethereum Signed Message:\n` prefix + keccak256.
234    fn personal_sign(&self, message: &[u8]) -> Result<[u8; 64], O2Error>;
235}
236
237impl SignableWallet for Wallet {
238    fn b256_address(&self) -> &[u8; 32] {
239        &self.b256_address
240    }
241    fn personal_sign(&self, message: &[u8]) -> Result<[u8; 64], O2Error> {
242        personal_sign(&self.private_key, message)
243    }
244}
245
246impl SignableWallet for EvmWallet {
247    fn b256_address(&self) -> &[u8; 32] {
248        &self.b256_address
249    }
250    fn personal_sign(&self, message: &[u8]) -> Result<[u8; 64], O2Error> {
251        evm_personal_sign(&self.private_key, message)
252    }
253}
254
255/// Derive a Fuel B256 address from a public key (65 bytes, 0x04 prefix).
256pub fn address_from_pubkey(public_key: &[u8; 65]) -> [u8; 32] {
257    Sha256::digest(&public_key[1..65]).into()
258}
259
260/// Derive an EVM address from a public key (65 bytes, 0x04 prefix).
261pub fn evm_address_from_pubkey(public_key: &[u8; 65]) -> [u8; 20] {
262    let hash = Keccak256::digest(&public_key[1..65]);
263    let mut addr = [0u8; 20];
264    addr.copy_from_slice(&hash[12..32]);
265    addr
266}
267
268/// Format a 32-byte array as a "0x"-prefixed hex string.
269pub fn to_hex_string(bytes: &[u8]) -> String {
270    format!("0x{}", hex::encode(bytes))
271}
272
273/// Parse a "0x"-prefixed hex string into a 32-byte array.
274pub fn parse_hex_32(s: &str) -> Result<[u8; 32], O2Error> {
275    let s = s.strip_prefix("0x").unwrap_or(s);
276    let bytes = hex::decode(s).map_err(|e| O2Error::CryptoError(format!("Invalid hex: {e}")))?;
277    if bytes.len() != 32 {
278        return Err(O2Error::CryptoError(format!(
279            "Expected 32 bytes, got {}",
280            bytes.len()
281        )));
282    }
283    let mut result = [0u8; 32];
284    result.copy_from_slice(&bytes);
285    Ok(result)
286}