diff --git a/Cargo.lock b/Cargo.lock index 626ffede..67634674 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1567,6 +1567,7 @@ dependencies = [ "serde_json", "serial_test", "sha2", + "siegel-uniffi", "strum", "subtle", "tar", @@ -5146,6 +5147,29 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siegel" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e7be3bd3792e86519ef639757407f1abd86d21aa65bb3313d4327172f0f9b0" +dependencies = [ + "libc", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "siegel-uniffi" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b1af96d641e2c8c7cc8d8c2c16d4b8ddd73f9068a2100721f46c23533012e1" +dependencies = [ + "getrandom 0.4.1", + "siegel", + "thiserror 2.0.18", + "uniffi", +] + [[package]] name = "signature" version = "2.2.0" diff --git a/bedrock/Cargo.toml b/bedrock/Cargo.toml index 1546acc2..a0ad6706 100644 --- a/bedrock/Cargo.toml +++ b/bedrock/Cargo.toml @@ -86,6 +86,7 @@ x509-cert = { version = "0.2.5", features = ["pem"] } zeroize = "1.8" hex-literal = "1.1.0" # Provides the `hex!` macro for compile-time hex decoding http = "1.4.0" +siegel-uniffi = "=0.2.0" # TODO: once `rand` can be bumped to 0.9, add the explicit feature flag `os_rng`. this explicitly determines no `thread_rng`; # bumping this requires crypto_box to update rand: https://github.com/RustCrypto/nacl-compat/issues/176 diff --git a/bedrock/src/lib.rs b/bedrock/src/lib.rs index 0e51af3e..680d30dc 100644 --- a/bedrock/src/lib.rs +++ b/bedrock/src/lib.rs @@ -52,3 +52,4 @@ pub mod siwe; pub mod test_utils; uniffi::setup_scaffolding!("bedrock"); +siegel_uniffi::uniffi_reexport_scaffolding!(); diff --git a/bedrock/src/siwe/test.rs b/bedrock/src/siwe/test.rs index fd817ea6..c4eb34ca 100644 --- a/bedrock/src/siwe/test.rs +++ b/bedrock/src/siwe/test.rs @@ -21,7 +21,7 @@ const TEST_KEY: &str = const TEST_WALLET: &str = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; fn test_smart_account() -> SafeSmartAccount { - SafeSmartAccount::new(TEST_KEY.into(), TEST_WALLET).unwrap() + SafeSmartAccount::from_private_key_hex(TEST_KEY.to_string(), TEST_WALLET).unwrap() } fn make_valid_message(datetime: &str) -> String { @@ -470,7 +470,9 @@ fn parse_accepts_message_at_max_length() { fn sign_produces_verifiable_signature() { let signer = PrivateKeySigner::from_str(TEST_KEY).unwrap(); let eoa_address = signer.address(); - let account = SafeSmartAccount::new(TEST_KEY.into(), TEST_WALLET).unwrap(); + let account = + SafeSmartAccount::from_private_key_hex(TEST_KEY.to_string(), TEST_WALLET) + .unwrap(); let msg = SiweMessage { scheme: Some(Scheme::HTTPS), diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index f317d7bc..c1adcb1c 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -1,12 +1,11 @@ -use std::str::FromStr; +use std::{str::FromStr, sync::Arc}; -use alloy::{ - dyn_abi::TypedData, - primitives::Address, - signers::{k256::ecdsa::SigningKey, local::LocalSigner}, -}; +use alloy::{dyn_abi::TypedData, primitives::Address, signers::local::LocalSigner}; +use k256::ecdsa::SigningKey; +use siegel_uniffi::{SessionError, SiegelSession}; pub use signer::{Eip191Signer, EoaSigner, SafeSmartAccountSigner}; pub use transaction_4337::Is4337Encodable; +use zeroize::Zeroizing; #[cfg(any(test, doc))] use crate::primitives::Network; @@ -79,6 +78,17 @@ pub enum SafeSmartAccountError { /// For security reasons, the contract is restricted from directly signing `TypedData`. #[error("the contract {0} is restricted from TypedData signing.")] RestrictedContract(String), + /// Error originating from the siegel secure-memory session backing the EOA key. + /// + /// `kind` is a stable machine-readable discriminant (e.g. `"consumed"`, + /// `"invalid_state"`); `message` is the human-readable description. + #[error("siegel session error ({kind}): {error_message}")] + SiegelSession { + /// Stable machine-readable discriminant for the underlying [`SessionError`] variant. + kind: String, + /// Full description of the session error. + error_message: String, + }, /// A provided raw input could not be parsed, is incorrectly formatted, incorrectly encoded or otherwise invalid. #[error("invalid input on {attribute}: {error_message}")] InvalidInput { @@ -98,60 +108,104 @@ impl From for SafeSmartAccountError { } } +impl std::fmt::Debug for SafeSmartAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SafeSmartAccount") + .field("eoa_address", &self.eoa_address) + .field("wallet_address", &self.wallet_address) + .finish_non_exhaustive() + } +} + +impl From for SafeSmartAccountError { + fn from(e: SessionError) -> Self { + let kind = match &e { + SessionError::InvalidLength => "invalid_length", + SessionError::LengthMismatch => "length_mismatch", + SessionError::InvalidState => "invalid_state", + SessionError::Consumed => "consumed", + SessionError::AllocationFailed { .. } => "allocation_failed", + SessionError::ProtectionFailed { .. } => "protection_failed", + SessionError::LockFailed { .. } => "lock_failed", + SessionError::CanaryCorrupted => "canary_corrupted", + SessionError::TooManyActiveSessions => "too_many_active_sessions", + SessionError::HandleAllocationFailed { .. } => "handle_allocation_failed", + }; + Self::SiegelSession { + kind: kind.to_string(), + error_message: e.to_string(), + } + } +} + /// A Safe Smart Account (previously Gnosis Safe) is the representation of a Safe smart contract. /// /// It is used to sign messages, transactions and typed data on behalf of the Safe smart contract. /// /// Reference: -#[derive(Debug, uniffi::Object)] +#[derive(uniffi::Object)] pub struct SafeSmartAccount { - /// The Ethereum signer from the EOA which is an owner for the Safe Smart Account. - signer: LocalSigner, + /// Foreign-side key store that yields a fresh siegel session per signing call. + key_manager: Arc, + /// The EOA address derived from the private key, cached at construction so that + /// the secret only has to be touched on actual signing calls. + eoa_address: Address, /// The address of the Safe Smart Account (i.e. the deployed smart contract) pub wallet_address: Address, } #[bedrock_export] impl SafeSmartAccount { - /// Initializes a new `SafeSmartAccount` instance with the given EOA signing key. + /// Initializes a new `SafeSmartAccount` instance, deriving the EOA address + /// from the private key obtained through the foreign-side key manager. + /// + /// The key is read exactly once at construction so the [`SiegelSession`] + /// can be zeroized immediately. Subsequent signing calls request a fresh + /// session from the key manager. /// /// # Arguments - /// - `private_key`: A hex-encoded string representing the **secret key** of the EOA who is an owner in the Safe. + /// - `key_manager`: Foreign-side [`SmartAccountKeyManager`] that delivers the EOA private key in a one-shot + /// siegel-protected session. /// - `wallet_address`: The address of the Safe Smart Account (i.e. the deployed smart contract). This is required because /// some legacy versions of the wallet were computed differently. Today, it cannot be deterministically computed for all /// users. This is also necessary to support signing for Safes deployed by third-party Mini App devs, where the /// wallet address is only known at runtime. /// /// # Errors + /// - Will return an error if the wallet address is not a valid hex-encoded address. /// - Will return an error if the key is not a validly encoded hex string. - /// - Will return an error if the key is not a valid point in the k256 curve. + /// - Will return an error if the decoded key is not a valid k256 scalar (zero or out of range). + /// - Will return an error if the siegel session cannot be read. #[uniffi::constructor] pub fn new( - private_key: String, + key_manager: Arc, wallet_address: &str, ) -> Result { - debug!( - "Initializing SafeSmartAccount with wallet address: {}", - wallet_address - ); - - let signer = LocalSigner::from_slice( - &hex::decode(private_key) - .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?, - ) - .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; - let wallet_address = Address::from_str(wallet_address).map_err(|_| { SafeSmartAccountError::AddressParsing(wallet_address.to_string()) })?; + // Read the key once to validate it and derive the EOA address. + // The session is consumed and zeroized inside `read_once`. + let siegel = key_manager.get_eoa_private_key(); + let eoa_address = siegel.read_once( + |private_key| -> Result { + let signer = signer_from_siegel_bytes(private_key)?; + let address = signer.address(); + // Redundant (`signer` get immediately zeroized) but added for an abundance of clarity + drop(signer); + Ok(address) + }, + )??; + debug!( "Successfully initialized SafeSmartAccount for wallet: {}", wallet_address ); Ok(Self { - signer, + key_manager, + eoa_address, wallet_address, }) } @@ -206,40 +260,45 @@ impl SafeSmartAccount { /// - Will throw an error if the signature process unexpectedly fails. /// /// # Examples - /// ```rust - /// use bedrock::smart_account::{SafeSmartAccount}; + /// ```no_run + /// use std::sync::Arc; + /// use bedrock::smart_account::{ + /// SafeSmartAccount, SafeSmartAccountError, SmartAccountKeyManager, + /// }; /// use bedrock::transactions::foreign::UnparsedUserOperation; /// use bedrock::primitives::Network; /// - /// let safe = SafeSmartAccount::new( - /// // this is Anvil's default private key, it is a test secret - /// "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(), - /// "0x4564420674EA68fcc61b463C0494807C759d47e6", - /// ) - /// .unwrap(); - /// - /// // This would normally be crafted by the user, or requested by Mini Apps. - /// let user_op = UnparsedUserOperation { - /// sender:"0xf1390a26bd60d83a4e38c7be7be1003c616296ad".to_string(), - /// nonce: "0xb14292cd79fae7d79284d4e6304fb58e21d579c13a75eed80000000000000000".to_string(), - /// call_data: "0x7bb3742800000000000000000000000079a02482a880bce3f13e09da970dc34db4cd24d10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000044a9059cbb000000000000000000000000ce2111f9ab8909b71ebadc9b6458daefe069eda4000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000".to_string(), - /// signature: "0x000012cea6000000967a7600ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff".to_string(), - /// call_gas_limit: "0xabb8".to_string(), - /// verification_gas_limit: "0xfa07".to_string(), - /// pre_verification_gas: "0x8e4d78".to_string(), - /// max_fee_per_gas: "0x1af6f".to_string(), - /// max_priority_fee_per_gas: "0x1adb0".to_string(), - /// paymaster: Some("0xEF725Aa22d43Ea69FB22bE2EBe6ECa205a6BCf5B".to_string()), - /// paymaster_verification_gas_limit: Some("0x7415".to_string()), - /// paymaster_post_op_gas_limit: Some("0x".to_string()), - /// paymaster_data: Some("000000000000000067789a97c4af0f8ae7acc9237c8f9611a0eb4662009d366b8defdf5f68fed25d22ca77be64b8eef49d917c3f8642ca539571594a84be9d0ee717c099160b79a845bea2111b".to_string()), - /// factory: None, - /// factory_data: None, - /// }; + /// fn sign( + /// key_manager: Arc, + /// ) -> Result<(), SafeSmartAccountError> { + /// let safe = SafeSmartAccount::new( + /// key_manager, + /// "0x4564420674EA68fcc61b463C0494807C759d47e6", + /// )?; /// - /// let signature = safe.sign_4337_op(Network::WorldChain as u32, user_op).unwrap(); + /// // Crafted by the user, or requested by Mini Apps. + /// let user_op = UnparsedUserOperation { + /// sender: "0xf1390a26bd60d83a4e38c7be7be1003c616296ad".to_string(), + /// nonce: "0x0".to_string(), + /// call_data: "0x".to_string(), + /// signature: "0x".to_string(), + /// call_gas_limit: "0x0".to_string(), + /// verification_gas_limit: "0x0".to_string(), + /// pre_verification_gas: "0x0".to_string(), + /// max_fee_per_gas: "0x0".to_string(), + /// max_priority_fee_per_gas: "0x0".to_string(), + /// paymaster: None, + /// paymaster_verification_gas_limit: None, + /// paymaster_post_op_gas_limit: None, + /// paymaster_data: None, + /// factory: None, + /// factory_data: None, + /// }; /// - /// println!("Signature: {}", signature.to_hex_string()); + /// let signature = safe.sign_4337_op(Network::WorldChain as u32, user_op)?; + /// println!("Signature: {}", signature.to_hex_string()); + /// Ok(()) + /// } /// ``` pub fn sign_4337_op( &self, @@ -343,9 +402,8 @@ impl SafeSmartAccount { impl SafeSmartAccount { /// Returns the underlying externally owned address (EOA) for the Safe. #[must_use] - #[expect(clippy::missing_const_for_fn, reason = "cannot be constructed const")] - pub fn eoa_address(&self) -> Address { - self.signer.address() + pub const fn eoa_address(&self) -> Address { + self.eoa_address } } @@ -400,6 +458,73 @@ pub struct SafeTransaction { pub nonce: String, } +/// Foreign-side key store that delivers the EOA private key wrapped in a +/// fresh [`SiegelSession`] each time it is needed. +/// +/// Implementations live on the Swift/Kotlin side: they fetch the secret from +/// the platform's secure storage (e.g. iOS Keychain), allocate a fresh +/// `SiegelSession` and fill it via `siegel_fill`. Rust consumes the session +/// with a single `read_once` call which zeroizes the protected buffer. +/// +/// # Foreign contract +/// +/// - **Session length**: 64 bytes — the EOA private key as ASCII hex (the +/// secp256k1 scalar is 32 raw bytes, encoded as 64 hex characters). +/// - **Byte format**: ASCII hex (lowercase or uppercase, no `0x` prefix). +/// - **Lifetime**: each call returns a brand-new session. The previous one is +/// consumed and zeroized as soon as Rust finishes signing. +/// - **Call frequency**: invoked once per signing operation (and once at +/// construction to validate the key and cache the EOA address). A typical +/// user flow may trigger several invocations. +#[uniffi::export(with_foreign)] +pub trait SmartAccountKeyManager: Send + Sync { + /// Returns a freshly allocated and filled [`SiegelSession`] containing + /// the hex-encoded EOA private key. See the [trait docs](Self) for the + /// expected length, encoding and call semantics. + fn get_eoa_private_key(&self) -> Arc; +} + +/// **Warning**. Only to be used within a Siegel `read_once` closure. +/// +/// Performs validation and parsing of the raw bytes of the private key +/// to instantiate a `secp256k1` signer. +fn signer_from_siegel_bytes( + bytes: &[u8], +) -> Result, SafeSmartAccountError> { + if bytes.len() != 64 { + return Err(SafeSmartAccountError::KeyDecoding( + "encoded key was not the right length (64 hex)".to_string(), + )); + } + + // The key is passed by foreign code as hex bytes. These hex bytes need to be + // parsed. We do this in a Zeroizing closure to ensure the result gets zeroized. + let raw_key: Zeroizing> = Zeroizing::new( + hex::decode(bytes) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?, + ); + LocalSigner::from_slice(&raw_key) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string())) +} + +#[cfg(any(test, feature = "test_utils"))] +impl SafeSmartAccount { + /// Test-only constructor that wraps a hex-encoded private key into an + /// [`InMemoryKeyManager`](crate::test_utils::InMemoryKeyManager) before + /// delegating to [`SafeSmartAccount::new`]. + /// + /// # Errors + /// - Same conditions as [`SafeSmartAccount::new`]. + pub fn from_private_key_hex( + private_key_hex: String, + wallet_address: &str, + ) -> Result { + let manager: Arc = + Arc::new(crate::test_utils::InMemoryKeyManager::new(private_key_hex)); + Self::new(manager, wallet_address) + } +} + #[cfg(test)] impl SafeSmartAccount { /// Creates a new `SafeSmartAccount` instance with a random EOA signing key. @@ -411,12 +536,13 @@ impl SafeSmartAccount { #[must_use] pub fn random() -> Self { let signer = LocalSigner::random(); - let wallet_address = - Address::from_str("0x0000000000000000000000000000000000000000").unwrap(); // TODO: compute address correctly - Self { - signer, - wallet_address, - } + let private_key_hex = hex::encode(signer.to_bytes()); + // TODO: compute address correctly + Self::from_private_key_hex( + private_key_hex, + "0x0000000000000000000000000000000000000000", + ) + .expect("random SafeSmartAccount construction must succeed") } } @@ -441,22 +567,24 @@ mod tests { #[test] fn test_cannot_initialize_with_invalid_hex_secret() { let invalid_hex = "invalid_hex"; - let result = SafeSmartAccount::new( + let result = SafeSmartAccount::from_private_key_hex( invalid_hex.to_string(), "0x0000000000000000000000000000000000000042", ); assert!(result.is_err()); assert_eq!( result.unwrap_err().to_string(), - format!("failed to decode hex-encoded secret into k256 signer: Odd number of digits") + format!("failed to decode hex-encoded secret into k256 signer: encoded key was not the right length (64 hex)") ); } #[test] fn test_cannot_initialize_with_invalid_curve_point() { - let invalid_hex = "2a"; // `42` is not a valid point on the curve - let result = SafeSmartAccount::new( - invalid_hex.to_string(), + // This is the `secp256k1` modulus, not a valid field element + let invalid_curve_point = + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"; + let result = SafeSmartAccount::from_private_key_hex( + invalid_curve_point.to_string(), "0x0000000000000000000000000000000000000042", ); assert!(result.is_err()); @@ -477,7 +605,7 @@ mod tests { ]; for invalid_address in invalid_addresses { - let result = SafeSmartAccount::new( + let result = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), invalid_address, ); @@ -491,7 +619,7 @@ mod tests { #[test] fn test_sign_transaction() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", @@ -515,7 +643,7 @@ mod tests { #[test] fn test_sign_4337_user_op() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", @@ -546,7 +674,7 @@ mod tests { #[test] fn test_sign_typed_data() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", diff --git a/bedrock/src/smart_account/signer.rs b/bedrock/src/smart_account/signer.rs index d4677e9e..210e1902 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -8,7 +8,10 @@ use alloy::{ use k256::ecdsa::SigningKey; use ruint::aliases::U256; -use crate::primitives::{address::BedrockAddress, HexEncodedData}; +use crate::{ + primitives::{address::BedrockAddress, HexEncodedData}, + smart_account::signer_from_siegel_bytes, +}; use super::{SafeSmartAccount, SafeSmartAccountError}; @@ -101,9 +104,7 @@ impl SafeSmartAccountSigner for SafeSmartAccount { chain_id: u32, ) -> Result { let message_hash = self.get_message_hash_for_safe(message, chain_id, None); - self.signer - .sign_hash_sync(&message_hash) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) + self.sign_hash_sync(&message_hash) } fn sign_digest( @@ -114,13 +115,36 @@ impl SafeSmartAccountSigner for SafeSmartAccount { ) -> Result { let message_hash = self.eip_712_hash(digest, chain_id, domain_separator_address); - self.signer - .sign_hash_sync(&message_hash) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) + self.sign_hash_sync(&message_hash) } } impl SafeSmartAccount { + /// Signs a fully encoded digest using the wallet's private key. + /// + /// The key is pulled from the [`SmartAccountKeyManager`] via a one-shot + /// siegel session that is zeroized as soon as the closure returns, so the + /// secret only lives on the Rust heap for the duration of the signature. + /// + /// # Arguments + /// - `final_digest`: the digest to sign. the output must come from a collision-resistant hash function. + fn sign_hash_sync( + &self, + final_digest: &FixedBytes<32>, + ) -> Result { + let siegel = self.key_manager.get_eoa_private_key(); + siegel.read_once(|private_key| -> Result { + let signer = signer_from_siegel_bytes(private_key)?; + let signature = signer + .sign_hash_sync(final_digest) + .map_err(|e| SafeSmartAccountError::Signing(e.to_string())); + + // Redundant, but added for an abundance of clarity. Only the signature escapes the closure. + drop(signer); + signature + })? + } + /// Computes the digest for a specific message to be signed by the Safe Smart Account. /// /// This is equivalent to the contract's `getMessageHashForSafe` method (including also the `encodeMessageDataForSafe` logic). @@ -275,7 +299,7 @@ mod tests { fn test_get_domain_separator() { // https://optimistic.etherscan.io/address/0x4564420674EA68fcc61b463C0494807C759d47e6 - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x4564420674EA68fcc61b463C0494807C759d47e6", ) @@ -290,7 +314,7 @@ mod tests { ); // https://etherscan.io/address/0xdab5dc22350f9a6aff03cf3d9341aad0ba42d2a6 - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0xdab5dc22350f9a6aff03cf3d9341aad0ba42d2a6", ) @@ -305,7 +329,7 @@ mod tests { ); // 1.4.1 Safe - https://optimistic.etherscan.io/address/0x75c9553956dfe249c815700b1e7076a5738f3d6d#readProxyContract - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x75c9553956dfe249C815700b1E7076A5738F3d6d", ) @@ -322,7 +346,7 @@ mod tests { #[test] fn test_compute_domain_separator_world_chain() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x29fcB43b46531BcA003ddC8FCB67FFE91900C762", ) @@ -338,7 +362,7 @@ mod tests { #[test] fn test_compute_domain_separator_world_chain_alt() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x619525ED4E862B62cFEDACCc4dA5a9864D6f4A97", ) @@ -356,7 +380,7 @@ mod tests { /// Reference: #[test] fn test_get_message_hash_for_safe() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x4564420674EA68fcc61b463C0494807C759d47e6", ) @@ -382,7 +406,7 @@ mod tests { /// Reference: #[test] fn test_get_message_hash_for_safe_alt() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0x4564420674EA68fcc61b463C0494807C759d47e6", ) @@ -410,7 +434,7 @@ mod tests { /// Reference: #[test] fn test_get_message_hash_for_safe_ethereum_chain() { - let smart_account = SafeSmartAccount::new( + let smart_account = SafeSmartAccount::from_private_key_hex( hex::encode(PrivateKeySigner::random().to_bytes()), "0xdab5dc22350f9a6aff03cf3d9341aad0ba42d2a6", ) diff --git a/bedrock/src/smart_account/transaction_4337.rs b/bedrock/src/smart_account/transaction_4337.rs index 33a42416..c5ba2c03 100644 --- a/bedrock/src/smart_account/transaction_4337.rs +++ b/bedrock/src/smart_account/transaction_4337.rs @@ -481,7 +481,7 @@ mod tests { #[test] fn test_sign_user_operation_produces_valid_77_byte_signature() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", diff --git a/bedrock/src/test_utils.rs b/bedrock/src/test_utils.rs index 92c775ba..5609f60a 100644 --- a/bedrock/src/test_utils.rs +++ b/bedrock/src/test_utils.rs @@ -1,6 +1,7 @@ //! Test utilities for unit tests and E2E tests for mocking RPC responses either from Anvil or hard-coded for unit tests. #![allow(clippy::all)] use std::str::FromStr; +use std::sync::Arc; use alloy::{ network::Ethereum, @@ -9,16 +10,66 @@ use alloy::{ sol, sol_types::{SolEvent, SolValue}, }; +use siegel_uniffi::{siegel_fill, SiegelSession, FILL_OK}; +use zeroize::{Zeroize, ZeroizeOnDrop}; use crate::{ primitives::{ http_client::{AuthenticatedHttpClient, HttpError, HttpHeader, HttpMethod}, PrimitiveError, }, - smart_account::UserOperation, + smart_account::{SmartAccountKeyManager, UserOperation}, transactions::foreign::UnparsedUserOperation, }; +/// In-memory [`SmartAccountKeyManager`] for unit and integration tests. +/// +/// Wraps a hex-encoded private key as bytes and seeds a fresh +/// [`SiegelSession`] on every access mirroring how a real key store +/// (e.g. iOS Keychain) would deliver the secret one use at a time. +#[derive(Zeroize, ZeroizeOnDrop)] +pub struct InMemoryKeyManager { + hex_bytes: Vec, +} + +impl InMemoryKeyManager { + /// Wraps a hex-encoded private key. + #[must_use] + pub const fn new(private_key_hex: String) -> Self { + // Normally we'd verify the sk length here, but because we have tests that require + // passing invalid secrets, we don't enforce it. + Self { + hex_bytes: private_key_hex.into_bytes(), + } + } +} + +impl SmartAccountKeyManager for InMemoryKeyManager { + fn get_eoa_private_key(&self) -> Arc { + let len = + u32::try_from(self.hex_bytes.len()).expect("secret len must fit in u32"); + let session = SiegelSession::new(len).expect("failed to create siegel session"); + // SAFETY: + // - `session.handle()` was just returned by `SiegelSession::new` and the + // session is still alive (held by `session` until the end of this fn). + // - `self.hex_bytes` is owned by `&self` and lives until the function + // returns, so the pointer is valid for `self.hex_bytes.len()` reads. + // - the session was allocated with capacity `len == self.hex_bytes.len()`, + // matching what `siegel_fill` expects. + let rc = unsafe { + siegel_fill( + session.handle_id(), + self.hex_bytes.as_ptr(), + self.hex_bytes.len(), + ) + }; + // Test-only: a non-OK return code indicates broken test setup, not a + // user-actionable error, so we panic with the raw status. + assert!(rc == FILL_OK, "siegel_fill failed with code {rc}"); + session + } +} + /// Represents a response from '`wa_sponsorUserOperation`' rpc method #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] diff --git a/bedrock/src/transactions/mod.rs b/bedrock/src/transactions/mod.rs index 61592f75..f2f76ac0 100644 --- a/bedrock/src/transactions/mod.rs +++ b/bedrock/src/transactions/mod.rs @@ -64,14 +64,17 @@ impl SafeSmartAccount { /// /// # Example /// - /// ```rust,no_run - /// use bedrock::smart_account::SafeSmartAccount; + /// ```no_run + /// use std::sync::Arc; + /// use bedrock::smart_account::{SafeSmartAccount, SmartAccountKeyManager}; /// use bedrock::transactions::TransactionError; /// use bedrock::primitives::Network; /// - /// # async fn example() -> Result<(), TransactionError> { + /// # async fn example( + /// # key_manager: Arc, + /// # ) -> Result<(), TransactionError> { /// // Assume we have a configured SafeSmartAccount - /// # let safe_account = SafeSmartAccount::new("test_key".to_string(), "0x1234567890123456789012345678901234567890").unwrap(); + /// # let safe_account = SafeSmartAccount::new(key_manager, "0x1234567890123456789012345678901234567890").unwrap(); /// /// // Transfer USDC on World Chain /// let tx_hash = safe_account.transaction_transfer( diff --git a/bedrock/tests/test_permit2_approval_processor.rs b/bedrock/tests/test_permit2_approval_processor.rs index a27c8fba..fd5ed27a 100644 --- a/bedrock/tests/test_permit2_approval_processor.rs +++ b/bedrock/tests/test_permit2_approval_processor.rs @@ -56,7 +56,7 @@ async fn test_permit2_approval_processor_full_flow() -> anyhow::Result<()> { set_http_client(Arc::new(client)); // 6) Create the processor - let safe_account = Arc::new(SafeSmartAccount::new( + let safe_account = Arc::new(SafeSmartAccount::from_private_key_hex( owner_key_hex, &safe_address.to_string(), )?); diff --git a/bedrock/tests/test_smart_account_bundler_sponsored.rs b/bedrock/tests/test_smart_account_bundler_sponsored.rs index 164cc0ff..372dd8c9 100644 --- a/bedrock/tests/test_smart_account_bundler_sponsored.rs +++ b/bedrock/tests/test_smart_account_bundler_sponsored.rs @@ -182,7 +182,10 @@ async fn test_send_bundler_sponsored_user_operation() -> anyhow::Result<()> { }; // 9) Execute via send_bundler_sponsored_user_operation - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; let _user_op_hash = safe_account .send_bundler_sponsored_user_operation(unparsed_user_op, bundler_url.clone()) .await @@ -268,7 +271,8 @@ async fn test_send_bundler_sponsored_user_operation_live() -> anyhow::Result<()> factory_data: None, }; - let safe_account = SafeSmartAccount::new(owner_key, &safe_address.to_string())?; + let safe_account = + SafeSmartAccount::from_private_key_hex(owner_key, &safe_address.to_string())?; let user_op_hash = safe_account .send_bundler_sponsored_user_operation(unparsed_user_op, rpc_url) .await @@ -301,8 +305,9 @@ async fn test_send_bundler_sponsored_user_operation_bundler_rejected() { ); let owner_key_hex = hex::encode(alloy::signers::local::PrivateKeySigner::random().to_bytes()); - let safe_account = SafeSmartAccount::new(owner_key_hex, safe_address) - .expect("failed to create SafeSmartAccount"); + let safe_account = + SafeSmartAccount::from_private_key_hex(owner_key_hex, safe_address) + .expect("failed to create SafeSmartAccount"); let err = safe_account .send_bundler_sponsored_user_operation( @@ -337,8 +342,9 @@ async fn test_send_bundler_sponsored_user_operation_http_error_is_generic() { let owner_key_hex = hex::encode(alloy::signers::local::PrivateKeySigner::random().to_bytes()); let safe_address = "0x1234567890123456789012345678901234567890"; - let safe_account = SafeSmartAccount::new(owner_key_hex, safe_address) - .expect("failed to create SafeSmartAccount"); + let safe_account = + SafeSmartAccount::from_private_key_hex(owner_key_hex, safe_address) + .expect("failed to create SafeSmartAccount"); let err = safe_account .send_bundler_sponsored_user_operation( diff --git a/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs b/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs index e1c9cd14..6c70cc04 100644 --- a/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs +++ b/bedrock/tests/test_smart_account_erc4337_transaction_execution.rs @@ -94,8 +94,11 @@ async fn test_integration_erc4337_transaction_execution() -> anyhow::Result<()> let mut user_op: UserOperation = user_op.try_into().unwrap(); // Sign the userOp and prepend validity timestamps - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let (va, vu) = user_op.extract_validity_timestamps()?; let op_hash = EncodedSafeOpStruct::from_user_op_with_validity(&user_op, va, vu) .unwrap() diff --git a/bedrock/tests/test_smart_account_morpho.rs b/bedrock/tests/test_smart_account_morpho.rs index 8f0f29e8..a071bd44 100644 --- a/bedrock/tests/test_smart_account_morpho.rs +++ b/bedrock/tests/test_smart_account_morpho.rs @@ -91,7 +91,10 @@ async fn test_erc4626_deposit_wld() -> anyhow::Result<()> { set_http_client(Arc::new(client)); // 8) Create and execute ERC4626 deposit using the generic implementation - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; // First create the transaction to log call data for unit tests let rpc_client = bedrock::transactions::rpc::get_rpc_client().unwrap(); diff --git a/bedrock/tests/test_smart_account_permit2_transfer.rs b/bedrock/tests/test_smart_account_permit2_transfer.rs index 21b20001..66927e70 100644 --- a/bedrock/tests/test_smart_account_permit2_transfer.rs +++ b/bedrock/tests/test_smart_account_permit2_transfer.rs @@ -87,7 +87,10 @@ async fn test_integration_permit2_transfer() -> anyhow::Result<()> { // Step 2: Deploy a Safe (World App User) let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; // Step 3: Give the Safe some simulated WLD balance let wld_token_address = address!("0x2cFc85d8E48F8EAB294be644d9E25C3030863003"); @@ -243,7 +246,10 @@ async fn test_integration_permit2_approve_and_allowance_transfer() -> anyhow::Re // Step 2: Deploy a Safe (World App User) let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; // Step 3: Give the Safe some simulated WLD balance let wld_token_address = address!("0x2cFc85d8E48F8EAB294be644d9E25C3030863003"); diff --git a/bedrock/tests/test_smart_account_personal_sign.rs b/bedrock/tests/test_smart_account_personal_sign.rs index 9bb62965..2c6692c9 100644 --- a/bedrock/tests/test_smart_account_personal_sign.rs +++ b/bedrock/tests/test_smart_account_personal_sign.rs @@ -38,8 +38,11 @@ async fn test_integration_personal_sign() { let message = "Hello from Safe integration test!"; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .personal_sign(chain_id, message.to_string()) @@ -119,8 +122,11 @@ async fn test_integration_personal_sign_failure_on_incorrect_chain_id() { let message = "Hello from Safe integration test!"; let chain_id = 10; // Note: This is not World Chain, verification will fail - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .personal_sign(chain_id, message.to_string()) @@ -180,8 +186,11 @@ async fn test_integration_personal_sign_failure_on_incorrect_eip_191_prefix() { let message = "Hello from Safe integration test!"; let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .personal_sign(chain_id, message.to_string()) diff --git a/bedrock/tests/test_smart_account_sign_typed_data.rs b/bedrock/tests/test_smart_account_sign_typed_data.rs index 284929e6..be5e8738 100644 --- a/bedrock/tests/test_smart_account_sign_typed_data.rs +++ b/bedrock/tests/test_smart_account_sign_typed_data.rs @@ -105,8 +105,11 @@ async fn test_integration_sign_typed_data() { let chain_id = Network::WorldChain as u32; - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string()) - .expect("Failed to create SafeSmartAccount"); + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + ) + .expect("Failed to create SafeSmartAccount"); let signature = safe_account .sign_typed_data(chain_id, &typed_data.to_string()) diff --git a/bedrock/tests/test_smart_account_transfer.rs b/bedrock/tests/test_smart_account_transfer.rs index e62c5bb5..45ff6f3b 100644 --- a/bedrock/tests/test_smart_account_transfer.rs +++ b/bedrock/tests/test_smart_account_transfer.rs @@ -71,7 +71,10 @@ async fn test_transaction_transfer_full_flow_executes_user_operation( set_http_client(Arc::new(client)); // 8) Execute high-level transfer via transaction_transfer - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; let amount = "1000000000000000000"; // 1 WLD let _user_op_hash = safe_account .transaction_transfer( diff --git a/bedrock/tests/test_smart_account_usd_vault.rs b/bedrock/tests/test_smart_account_usd_vault.rs index 850bb2c7..825451ec 100644 --- a/bedrock/tests/test_smart_account_usd_vault.rs +++ b/bedrock/tests/test_smart_account_usd_vault.rs @@ -54,7 +54,10 @@ async fn test_usd_vault_migration() -> anyhow::Result<()> { let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; println!("✓ Deployed Safe at: {safe_address}"); - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; provider .anvil_set_balance(safe_address, parse_ether("1").unwrap()) diff --git a/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs b/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs index eeab7e26..ed767765 100644 --- a/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs +++ b/bedrock/tests/test_smart_account_wa_get_user_operation_receipt.rs @@ -28,8 +28,10 @@ async fn test_wa_get_user_operation_receipt_uses_mocked_response() -> anyhow::Re set_http_client(Arc::new(client)); // Construct a SafeSmartAccount; the on-chain state is irrelevant for this test - let safe_account = - SafeSmartAccount::new(owner_key_hex, &owner_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &owner_address.to_string(), + )?; let user_op_hash = "0x3a9b7d5e1f0a4c2e6b8d7f9a1c3e5f0b2d4a6c8e9f1b3d5c7a9e0f2c4b6d8a0"; diff --git a/bedrock/tests/test_smart_account_wld_vault.rs b/bedrock/tests/test_smart_account_wld_vault.rs index 1a7496a0..d112c1e8 100644 --- a/bedrock/tests/test_smart_account_wld_vault.rs +++ b/bedrock/tests/test_smart_account_wld_vault.rs @@ -60,7 +60,10 @@ async fn test_wld_vault_migration() -> anyhow::Result<()> { let safe_address = deploy_safe(&provider, owner, U256::ZERO).await?; println!("✓ Deployed Safe at: {safe_address}"); - let safe_account = SafeSmartAccount::new(owner_key_hex, &safe_address.to_string())?; + let safe_account = SafeSmartAccount::from_private_key_hex( + owner_key_hex, + &safe_address.to_string(), + )?; provider .anvil_set_balance(safe_address, parse_ether("1").unwrap()) diff --git a/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs b/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs index 144a0b8c..397990f3 100644 --- a/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs +++ b/bedrock/tests/test_smart_account_world_gift_manager_gift_cancel.rs @@ -64,8 +64,10 @@ async fn test_transaction_world_gift_manager_gift_cancel_user_operations( set_http_client(Arc::new(client)); - let safe_account_giftor = - SafeSmartAccount::new(owner_key_hex.clone(), &safe_address_giftor.to_string())?; + let safe_account_giftor = SafeSmartAccount::from_private_key_hex( + owner_key_hex.clone(), + &safe_address_giftor.to_string(), + )?; let amount = U256::from(1e18); let gift_result = safe_account_giftor diff --git a/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs b/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs index 887644b1..a3d88e70 100644 --- a/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs +++ b/bedrock/tests/test_smart_account_world_gift_manager_gift_redeem.rs @@ -72,10 +72,14 @@ async fn test_transaction_world_gift_manager_gift_redeem_user_operations( set_http_client(Arc::new(client)); - let safe_account_giftor = - SafeSmartAccount::new(owner_key_hex.clone(), &safe_address_giftor.to_string())?; - let safe_account_giftee = - SafeSmartAccount::new(owner_key_hex.clone(), &safe_address_giftee.to_string())?; + let safe_account_giftor = SafeSmartAccount::from_private_key_hex( + owner_key_hex.clone(), + &safe_address_giftor.to_string(), + )?; + let safe_account_giftee = SafeSmartAccount::from_private_key_hex( + owner_key_hex.clone(), + &safe_address_giftee.to_string(), + )?; let amount = U256::from(1e18); let gift_result = safe_account_giftor diff --git a/kotlin/bedrock-tests/build.gradle.kts b/kotlin/bedrock-tests/build.gradle.kts index 6dc17562..7a0b8404 100644 --- a/kotlin/bedrock-tests/build.gradle.kts +++ b/kotlin/bedrock-tests/build.gradle.kts @@ -19,7 +19,8 @@ dependencies { sourceSets { test { kotlin.srcDirs( - "$rootDir/bedrock-android/src/main/java/uniffi/bedrock" + "$rootDir/bedrock-android/src/main/java/uniffi/bedrock", + "$rootDir/bedrock-android/src/main/java/uniffi/siegel_uniffi", ) } } diff --git a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt index 8e4548da..358eaa59 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt @@ -13,18 +13,23 @@ class BedrockSmartAccountTests { private val testWalletAddress = "0x4564420674EA68fcc61b463C0494807C759d47e6" private val chainId: UInt = 10u // Optimism + private fun account( + privateKey: String = testPrivateKey, + walletAddress: String = testWalletAddress, + ) = SafeSmartAccount(TestKeyManager(privateKey), walletAddress) + // No explicit library preload is necessary: UniFFI-generated bindings // load the native `bedrock` library on first access. @Test fun testSafeSmartAccountCreation() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() assertNotNull(account) } @Test fun testPersonalSign() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() // Test message signing - using same parameters as Rust test val message = "Hello, Safe Smart Account!" @@ -46,7 +51,7 @@ class BedrockSmartAccountTests { @Test fun testMultipleMessages() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() val messages = listOf( "Message 1", @@ -66,27 +71,27 @@ class BedrockSmartAccountTests { @Test fun testInvalidPrivateKey() { assertFailsWith { - SafeSmartAccount("invalid_key", testWalletAddress) + account(privateKey = "invalid_key") } } @Test fun testEmptyPrivateKey() { assertFailsWith { - SafeSmartAccount("", testWalletAddress) + account(privateKey = "") } } @Test fun testInvalidWalletAddress() { assertFailsWith { - SafeSmartAccount(testPrivateKey, "invalid_address") + account(walletAddress = "invalid_address") } } @Test fun testDifferentChainIds() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() val chainIds = listOf(1u, 10u, 137u, 42161u) val message = "Testing different chains" val signatures = mutableSetOf() @@ -101,7 +106,7 @@ class BedrockSmartAccountTests { @Test fun testLongMessage() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() val longMsg = "Lorem ipsum dolor sit amet. ".repeat(100) val sig = account.personalSign(chainId, longMsg).toHexString() assertTrue(sig.isNotEmpty(), "Signature for long message should not be empty") @@ -110,7 +115,7 @@ class BedrockSmartAccountTests { @Test fun testUnicodeMessage() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() val unicodeMsg = "Hello 世界 🌍 Здравствуй мир" val sig = account.personalSign(chainId, unicodeMsg).toHexString() assertTrue(sig.isNotEmpty(), "Signature for unicode message should not be empty") @@ -119,7 +124,14 @@ class BedrockSmartAccountTests { @Test fun testComputeWalletAddressForFreshAccount() { - val walletAddress = uniffi.bedrock.computeWalletAddressForFreshAccount("0xa4eb68ce21c862f42e26ff31bb8351bf87f2c41a") - assertEquals(walletAddress, "0xd462bac17966fd7a9ee76b55191a6083edf6f80b", "computeFreshWalletAddress did not yield the expected result") + val walletAddress = + uniffi.bedrock.computeWalletAddressForFreshAccount( + "0xa4eb68ce21c862f42e26ff31bb8351bf87f2c41a", + ) + assertEquals( + walletAddress, + "0xd462bac17966fd7a9ee76b55191a6083edf6f80b", + "computeFreshWalletAddress did not yield the expected result", + ) } } diff --git a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSolMacroTests.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSolMacroTests.kt index b558bbc1..81237806 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSolMacroTests.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSolMacroTests.kt @@ -46,7 +46,7 @@ class BedrockSolMacroTests { fun `test sign Permit2 transfer integration`() { // Test that the unparsed types work with the signing function val safeAccount = SafeSmartAccount( - privateKey = "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583", + keyManager = TestKeyManager("4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583"), walletAddress = "0x4564420674EA68fcc61b463C0494807C759d47e6" ) diff --git a/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt new file mode 100644 index 00000000..4d7bf408 --- /dev/null +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt @@ -0,0 +1,57 @@ +package bedrock + +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import uniffi.bedrock.SmartAccountKeyManager +import uniffi.siegel_uniffi.SiegelSession + +/** + * JNA binding for `siegel_fill`, the only siegel API exposed as a raw + * `extern "C"` symbol. The function lives in `libbedrock` because the + * bedrock cdylib statically links `siegel-uniffi`. + */ +internal object SiegelNative { + init { + Native.register(SiegelNative::class.java, "bedrock") + } + + @JvmStatic + @Suppress("ktlint:standard:function-naming") + external fun siegel_fill( + handle: Long, + src: Pointer, + len: Long, + ): Int +} + +/** + * Test [SmartAccountKeyManager] that delivers a fixed hex-encoded private + * key in a fresh [SiegelSession] on every call. Production foreign code + * would fetch the secret from the platform key store (e.g. Keychain). + * + * + * JNA can't safely pass a pointer to JVM heap memory (the GC may relocate + * it), so the bytes are copied into an off-heap [Memory] buffer. All buffers + * are zeroized after filling the Siegel. + */ +internal class TestKeyManager( + private val hexKey: String, +) : SmartAccountKeyManager { + override fun getEoaPrivateKey(): SiegelSession { + val raw = hexKey.toByteArray(Charsets.US_ASCII) + val bytes = if (raw.isEmpty()) byteArrayOf(0) else raw + val session = SiegelSession(bytes.size.toUInt()) + val mem = Memory(bytes.size.toLong()) + try { + mem.write(0, bytes, 0, bytes.size) + val rc = SiegelNative.siegel_fill(session.handleId().toLong(), mem, bytes.size.toLong()) + check(rc == 0) { "siegel_fill failed with code $rc" } + } finally { + mem.clear() + mem.close() + bytes.fill(0) + } + return session + } +} diff --git a/swift/build_swift.sh b/swift/build_swift.sh index f458bc0b..8f72321b 100755 --- a/swift/build_swift.sh +++ b/swift/build_swift.sh @@ -3,7 +3,7 @@ set -e # Creates a Swift build of the `Bedrock` library. # This script can be used directly or called by other scripts. -# +# # Usage: build_swift.sh [OUTPUT_DIR] # OUTPUT_DIR: Directory where the XCFramework should be placed (default: swift/) @@ -18,25 +18,25 @@ FRAMEWORK="Bedrock.xcframework" # Parse arguments while [[ $# -gt 0 ]]; do case $1 in - --help|-h) - echo "Usage: $0 [OUTPUT_DIR]" - echo "" - echo "Arguments:" - echo " OUTPUT_DIR Directory where the XCFramework should be placed (default: swift/)" - echo "" - exit 0 - ;; - *) - # Assume it's the output directory if it doesn't start with -- - if [[ ! "$1" =~ ^-- ]]; then - OUTPUT_DIR="$1" - else - echo "Unknown option: $1" - echo "Use --help for usage information" - exit 1 - fi - shift - ;; + --help | -h) + echo "Usage: $0 [OUTPUT_DIR]" + echo "" + echo "Arguments:" + echo " OUTPUT_DIR Directory where the XCFramework should be placed (default: swift/)" + echo "" + exit 0 + ;; + *) + # Assume it's the output directory if it doesn't start with -- + if [[ ! "$1" =~ ^-- ]]; then + OUTPUT_DIR="$1" + else + echo "Unknown option: $1" + echo "Use --help for usage information" + exit 1 + fi + shift + ;; esac done @@ -75,35 +75,46 @@ echo "Rust packages built. Combining simulator targets into universal binary..." # Create universal binary for simulators lipo -create target/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.a \ - target/x86_64-apple-ios/release/lib${PACKAGE_NAME}.a \ - -output $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a + target/x86_64-apple-ios/release/lib${PACKAGE_NAME}.a \ + -output "$BASE_PATH"/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a -lipo -info $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a +lipo -info "$BASE_PATH"/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a echo "Generating Swift bindings..." # Generate Swift bindings using uniffi cargo run -p uniffi-bindgen generate \ - target/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.dylib \ - --library \ - --language swift \ - --no-format \ - --out-dir $BASE_PATH/ios_build/bindings + target/aarch64-apple-ios-sim/release/lib${PACKAGE_NAME}.dylib \ + --library \ + --language swift \ + --no-format \ + --out-dir "$BASE_PATH"/ios_build/bindings + +# uniffi-bindgen emits one set of files per linked uniffi crate. Move all +# generated Swift sources to the Sources directory and all FFI headers. +shopt -s nullglob +for swift_file in "$BASE_PATH"/ios_build/bindings/*.swift; do + mv "$swift_file" "$SWIFT_SOURCES_DIR/" +done -# Move generated Swift file to Sources directory -mv $BASE_PATH/ios_build/bindings/${PACKAGE_NAME}.swift ${SWIFT_SOURCES_DIR}/ +for header in "$BASE_PATH"/ios_build/bindings/*FFI.h; do + mv "$header" "$SWIFT_HEADERS_DIR/" +done -# Move headers -mv $BASE_PATH/ios_build/bindings/${PACKAGE_NAME}FFI.h $SWIFT_HEADERS_DIR/ -cat $BASE_PATH/ios_build/bindings/${PACKAGE_NAME}FFI.modulemap > $SWIFT_HEADERS_DIR/module.modulemap +: >"$SWIFT_HEADERS_DIR"/module.modulemap +for modmap in "$BASE_PATH"/ios_build/bindings/*FFI.modulemap; do + cat "$modmap" >>"$SWIFT_HEADERS_DIR"/module.modulemap + printf '\n' >>"$SWIFT_HEADERS_DIR"/module.modulemap +done +shopt -u nullglob echo "Creating XCFramework..." # Create XCFramework xcodebuild -create-xcframework \ - -library target/aarch64-apple-ios/release/lib${PACKAGE_NAME}.a -headers $BASE_PATH/ios_build/Headers \ - -library $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a -headers $BASE_PATH/ios_build/Headers \ - -output $FRAMEWORK_OUTPUT + -library target/aarch64-apple-ios/release/lib${PACKAGE_NAME}.a -headers $BASE_PATH/ios_build/Headers \ + -library $BASE_PATH/ios_build/target/universal-ios-sim/release/lib${PACKAGE_NAME}.a -headers $BASE_PATH/ios_build/Headers \ + -output $FRAMEWORK_OUTPUT # Clean up intermediate build files rm -rf $BASE_PATH/ios_build diff --git a/swift/test_swift.sh b/swift/test_swift.sh index 256317e9..d709f2a2 100755 --- a/swift/test_swift.sh +++ b/swift/test_swift.sh @@ -40,14 +40,17 @@ echo -e "${BLUE}📦 Step 2: Copying generated Swift files to test package${NC}" # Ensure the destination directory exists mkdir -p "$TESTS_PATH/$SOURCES_PATH_NAME" -# Copy the generated Swift file to the test package -if [ -f "$BASE_PATH/$SOURCES_PATH_NAME/bedrock.swift" ]; then - cp "$BASE_PATH/$SOURCES_PATH_NAME/bedrock.swift" "$TESTS_PATH/$SOURCES_PATH_NAME" - echo -e "${GREEN}✅ Swift bindings copied to test package${NC}" -else - echo -e "${RED}✗ Could not find generated Swift bindings at: $BASE_PATH/$SOURCES_PATH_NAME/bedrock.swift${NC}" +# Copy all generated Swift bindings to the test package. The build emits one +# Swift file per linked uniffi crate (e.g. bedrock.swift, siegel_uniffi.swift). +shopt -s nullglob +swift_files=("$BASE_PATH/$SOURCES_PATH_NAME"/*.swift) +shopt -u nullglob +if [ ${#swift_files[@]} -eq 0 ]; then + echo -e "${RED}✗ Could not find any generated Swift bindings in: $BASE_PATH/$SOURCES_PATH_NAME${NC}" exit 1 fi +cp "${swift_files[@]}" "$TESTS_PATH/$SOURCES_PATH_NAME" +echo -e "${GREEN}✅ Swift bindings copied to test package (${#swift_files[@]} file(s))${NC}" echo "" echo -e "${BLUE}🧪 Running Swift tests with verbose output...${NC}" diff --git a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift index 118031bc..cf63ab32 100644 --- a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift +++ b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift @@ -1,7 +1,50 @@ +import Darwin import XCTest @testable import Bedrock +/// Raw C `siegel_fill` symbol. Declared with `@_silgen_name` since the +/// function is `extern "C"` in the bedrock cdylib but is not part of the +/// uniffi-generated FFI headers. +@_silgen_name("siegel_fill") +private func siegel_fill(_ handle: UInt64, _ src: UnsafePointer, _ len: Int) -> Int32 + +/// Test [`SmartAccountKeyManager`] that delivers a hex-encoded +/// private key in a fresh [`SiegelSession`] on every call. Production +/// foreign code would fetch the secret from the platform key store +/// (e.g. Keychain) into a mutable `Data` / `[UInt8]`, fill the siegel, +/// then zeroize the source buffer. +final class TestKeyManager: SmartAccountKeyManager, @unchecked Sendable { + // Naturally in production, the key should never live in memory like this. + private let hexKey: String + + init(_ hexKey: String) { + self.hexKey = hexKey + } + + func getEoaPrivateKey() -> SiegelSession { + var raw = Array(hexKey.utf8) + if raw.isEmpty { + raw = [0] + } + defer { + raw.withUnsafeMutableBufferPointer { buf in + if let base = buf.baseAddress { + memset_s(base, buf.count, 0, buf.count) + } + } + } + guard let session = try? SiegelSession(len: UInt32(raw.count)) else { + fatalError("SiegelSession(len:) must succeed for non-empty len") + } + let fillResult = raw.withUnsafeBufferPointer { buf -> Int32 in + siegel_fill(session.handleId(), buf.baseAddress!, raw.count) + } + precondition(fillResult == 0, "siegel_fill failed with code \(fillResult)") + return session + } +} + final class BedrockSmartAccountTests: XCTestCase { // Well-known Anvil test private key and address @@ -9,23 +52,23 @@ final class BedrockSmartAccountTests: XCTestCase { let testWalletAddress = "0x4564420674EA68fcc61b463C0494807C759d47e6" let chainId: UInt32 = 10 // Optimism - func testSafeSmartAccountCreation() throws { - // Test creating a SafeSmartAccount instance - let account = try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: testWalletAddress + private func makeAccount( + privateKey: String? = nil, + walletAddress: String? = nil + ) throws -> SafeSmartAccount { + try SafeSmartAccount( + keyManager: TestKeyManager(privateKey ?? testPrivateKey), + walletAddress: walletAddress ?? testWalletAddress ) + } - // If we get here without throwing, the account was created successfully + func testSafeSmartAccountCreation() throws { + let account = try makeAccount() XCTAssertNotNil(account) } func testPersonalSign() throws { - // Create account - let account = try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: testWalletAddress - ) + let account = try makeAccount() // Test message signing - using same parameters as Rust test let message = "Hello, Safe Smart Account!" @@ -36,8 +79,7 @@ final class BedrockSmartAccountTests: XCTestCase { // Expected signature from Rust test // swiftlint:disable:next line_length - let expectedSignature = - "0xa9781c5233828575e8c7bababbef2b05b9f60a0c34581173655e6deaa40a3a8a0357d8877723588478c0113c630f68f6d118de0a0a97b6a5fa0284beeec721431c" + let expectedSignature = "0xa9781c5233828575e8c7bababbef2b05b9f60a0c34581173655e6deaa40a3a8a0357d8877723588478c0113c630f68f6d118de0a0a97b6a5fa0284beeec721431c" // Verify we got the exact expected signature XCTAssertEqual( @@ -51,11 +93,7 @@ final class BedrockSmartAccountTests: XCTestCase { } func testMultipleMessages() throws { - // Create account - let account = try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: testWalletAddress - ) + let account = try makeAccount() // Test signing multiple messages let messages = [ @@ -63,7 +101,7 @@ final class BedrockSmartAccountTests: XCTestCase { "Another test message", "Special characters: !@#$%^&*()", "Numbers: 1234567890", - "Empty string test: ", + "Empty string test: " ] for message in messages { @@ -81,10 +119,7 @@ final class BedrockSmartAccountTests: XCTestCase { func testInvalidPrivateKey() { // Test with invalid private key - should throw XCTAssertThrowsError( - try SafeSmartAccount( - privateKey: "invalid_key", - walletAddress: testWalletAddress - ) + try makeAccount(privateKey: "invalid_key") ) { error in // Verify we got an error XCTAssertNotNil(error) @@ -94,10 +129,7 @@ final class BedrockSmartAccountTests: XCTestCase { func testEmptyPrivateKey() { // Test with empty private key - should throw XCTAssertThrowsError( - try SafeSmartAccount( - privateKey: "", - walletAddress: testWalletAddress - ) + try makeAccount(privateKey: "") ) { error in XCTAssertNotNil(error) } @@ -106,21 +138,14 @@ final class BedrockSmartAccountTests: XCTestCase { func testInvalidWalletAddress() { // Test with invalid wallet address format XCTAssertThrowsError( - try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: "invalid_address" - ) + try makeAccount(walletAddress: "invalid_address") ) { error in XCTAssertNotNil(error) } } func testDifferentChainIds() throws { - // Create account - let account = try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: testWalletAddress - ) + let account = try makeAccount() // Test signing with different chain IDs let chainIds: [UInt32] = [1, 10, 137, 42161] // Ethereum, Optimism, Polygon, Arbitrum @@ -149,11 +174,7 @@ final class BedrockSmartAccountTests: XCTestCase { } func testLongMessage() throws { - // Create account - let account = try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: testWalletAddress - ) + let account = try makeAccount() // Test with a very long message let longMessage = String(repeating: "Lorem ipsum dolor sit amet. ", count: 100) @@ -168,11 +189,7 @@ final class BedrockSmartAccountTests: XCTestCase { } func testUnicodeMessage() throws { - // Create account - let account = try SafeSmartAccount( - privateKey: testPrivateKey, - walletAddress: testWalletAddress - ) + let account = try makeAccount() // Test with unicode characters let unicodeMessage = "Hello 世界 🌍 Здравствуй мир" @@ -194,4 +211,4 @@ final class BedrockSmartAccountTests: XCTestCase { XCTAssertEqual(walletAddress, "0xea51b7e5c07bb29237194aa14618057333435f3e") } -} \ No newline at end of file +} diff --git a/swift/tests/BedrockTests/BedrockSolMacroTests.swift b/swift/tests/BedrockTests/BedrockSolMacroTests.swift index a3ba88e7..1795ff63 100644 --- a/swift/tests/BedrockTests/BedrockSolMacroTests.swift +++ b/swift/tests/BedrockTests/BedrockSolMacroTests.swift @@ -39,7 +39,7 @@ final class BedrockSolMacroTests: XCTestCase { func testSignPermit2TransferIntegration() throws { // Test that the unparsed types work with the signing function let safeAccount = try SafeSmartAccount( - privateKey: "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583", + keyManager: TestKeyManager("4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583"), walletAddress: "0x4564420674EA68fcc61b463C0494807C759d47e6" )