From 09a7d055dea196f177b23050b48505682c16e545 Mon Sep 17 00:00:00 2001 From: pd Date: Fri, 15 May 2026 13:40:31 -0700 Subject: [PATCH 1/5] feat: siegel key manager --- Cargo.lock | 23 +++++++++++++++++ bedrock/Cargo.toml | 1 + bedrock/src/lib.rs | 1 + bedrock/src/smart_account/mod.rs | 38 ++++++++++++++++------------- bedrock/src/smart_account/signer.rs | 31 ++++++++++++++++++++--- 5 files changed, 73 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bd52e68e..b28a8b38 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ "serde_json", "serial_test", "sha2", + "siegel-uniffi", "strum", "subtle", "tar", @@ -5202,6 +5203,28 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "siegel" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6ad2272eb97e86ac485964f8d26287d73af4ae9e1a3ddbaf7586644aa41c0a6" +dependencies = [ + "libc", + "thiserror 2.0.18", + "zeroize", +] + +[[package]] +name = "siegel-uniffi" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b30a1624db67abe426b85ff03348f361af58be258bad030b664340c7105fd41" +dependencies = [ + "siegel", + "thiserror 2.0.18", + "uniffi", +] + [[package]] name = "signature" version = "2.2.0" diff --git a/bedrock/Cargo.toml b/bedrock/Cargo.toml index 4e8fc0b2..4b2ebb62 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.1.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 feeb60e0..93e4a8d0 100644 --- a/bedrock/src/lib.rs +++ b/bedrock/src/lib.rs @@ -54,3 +54,4 @@ pub mod siwe; pub mod test_utils; uniffi::setup_scaffolding!("bedrock"); +siegel_uniffi::uniffi_reexport_scaffolding!(); diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index f317d7bc..170760aa 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -1,10 +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 siegel_uniffi::SiegelSession; pub use signer::{Eip191Signer, EoaSigner, SafeSmartAccountSigner}; pub use transaction_4337::Is4337Encodable; @@ -103,10 +104,9 @@ impl From for SafeSmartAccountError { /// 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, + key_manager: Arc, /// The address of the Safe Smart Account (i.e. the deployed smart contract) pub wallet_address: Address, } @@ -127,20 +127,9 @@ impl SafeSmartAccount { /// - Will return an error if the key is not a valid point in the k256 curve. #[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()) })?; @@ -150,8 +139,18 @@ impl SafeSmartAccount { wallet_address ); + // Verify once that the key manager is correct and the key is a valid secp256k1 scalar + let siegel = key_manager.get_eoa_private_key(); + siegel.read_once(|private_key| { + 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()))?; + }); + Ok(Self { - signer, + key_manager, wallet_address, }) } @@ -400,6 +399,11 @@ pub struct SafeTransaction { pub nonce: String, } +#[uniffi::export(with_foreign)] +pub trait SmartAccountKeyManager: Send + Sync { + fn get_eoa_private_key(&self) -> Arc; +} + #[cfg(test)] impl SafeSmartAccount { /// Creates a new `SafeSmartAccount` instance with a random EOA signing key. diff --git a/bedrock/src/smart_account/signer.rs b/bedrock/src/smart_account/signer.rs index d4677e9e..fc523dc4 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -101,8 +101,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) + self.sign_hash_sync(&message_hash) .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) } @@ -114,13 +113,37 @@ 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) + self.sign_hash_sync(&message_hash) .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) } } impl SafeSmartAccount { + /// Signs a fully encoded digest using the wallet's private key in a + /// scope closure that ensures zeroization after 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(); + let mut signature: Option; + siegel.read_once(|private_key| { + 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()))?; + signature = Some(signer.sign_hash_sync(final_digest)?); + drop(signer); + }); + signature.ok_or(SafeSmartAccountError::Generic { + error_message: "unexpectedly unable to generate signature".to_string(), + }) + } + /// 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). From 039057e7187b8dc139da365bf6bc4079f1f68401 Mon Sep 17 00:00:00 2001 From: pd Date: Fri, 15 May 2026 14:17:42 -0700 Subject: [PATCH 2/5] full --- bedrock/src/siwe/test.rs | 5 +- bedrock/src/smart_account/mod.rs | 140 +++++++++++++----- bedrock/src/smart_account/signer.rs | 40 +++-- bedrock/src/smart_account/transaction_4337.rs | 2 +- bedrock/src/test_utils.rs | 44 +++++- bedrock/src/transactions/mod.rs | 10 +- .../tests/test_permit2_approval_processor.rs | 2 +- .../test_smart_account_bundler_sponsored.rs | 18 ++- ...t_account_erc4337_transaction_execution.rs | 7 +- bedrock/tests/test_smart_account_morpho.rs | 5 +- .../test_smart_account_permit2_transfer.rs | 10 +- .../tests/test_smart_account_personal_sign.rs | 21 ++- .../test_smart_account_sign_typed_data.rs | 7 +- bedrock/tests/test_smart_account_transfer.rs | 5 +- bedrock/tests/test_smart_account_usd_vault.rs | 5 +- ...t_account_wa_get_user_operation_receipt.rs | 6 +- bedrock/tests/test_smart_account_wld_vault.rs | 5 +- ..._account_world_gift_manager_gift_cancel.rs | 6 +- ..._account_world_gift_manager_gift_redeem.rs | 12 +- 19 files changed, 254 insertions(+), 96 deletions(-) diff --git a/bedrock/src/siwe/test.rs b/bedrock/src/siwe/test.rs index fd817ea6..2e95a170 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, TEST_WALLET).unwrap() } fn make_valid_message(datetime: &str) -> String { @@ -470,7 +470,8 @@ 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, 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 170760aa..7b873497 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -1,11 +1,7 @@ use std::{str::FromStr, sync::Arc}; -use alloy::{ - dyn_abi::TypedData, - primitives::Address, - signers::{k256::ecdsa::SigningKey, local::LocalSigner}, -}; -use siegel_uniffi::SiegelSession; +use alloy::{dyn_abi::TypedData, primitives::Address, signers::local::LocalSigner}; +use siegel_uniffi::{SessionError, SiegelSession}; pub use signer::{Eip191Signer, EoaSigner, SafeSmartAccountSigner}; pub use transaction_4337::Is4337Encodable; @@ -80,6 +76,9 @@ 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. + #[error("siegel session error: {0}")] + SiegelSession(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 { @@ -99,6 +98,21 @@ 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 { + Self::SiegelSession(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. @@ -106,25 +120,37 @@ impl From for SafeSmartAccountError { /// Reference: #[derive(uniffi::Object)] pub struct SafeSmartAccount { + /// 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 siegel session cannot be read. #[uniffi::constructor] pub fn new( key_manager: Arc, @@ -134,23 +160,28 @@ impl SafeSmartAccount { 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 = + LocalSigner::from_slice(&hex::decode(private_key).map_err( + |e| SafeSmartAccountError::KeyDecoding(e.to_string()), + )?) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; + Ok(signer.address()) + }, + )??; + debug!( "Successfully initialized SafeSmartAccount for wallet: {}", wallet_address ); - // Verify once that the key manager is correct and the key is a valid secp256k1 scalar - let siegel = key_manager.get_eoa_private_key(); - siegel.read_once(|private_key| { - 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()))?; - }); - Ok(Self { key_manager, + eoa_address, wallet_address, }) } @@ -205,14 +236,21 @@ impl SafeSmartAccount { /// - Will throw an error if the signature process unexpectedly fails. /// /// # Examples - /// ```rust - /// use bedrock::smart_account::{SafeSmartAccount}; + /// ```ignore + /// use std::sync::Arc; + /// use bedrock::smart_account::{SafeSmartAccount, SmartAccountKeyManager}; + /// use bedrock::test_utils::InMemoryKeyManager; /// use bedrock::transactions::foreign::UnparsedUserOperation; /// use bedrock::primitives::Network; /// - /// let safe = SafeSmartAccount::new( + /// let key_manager: Arc = Arc::new( /// // this is Anvil's default private key, it is a test secret - /// "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80".to_string(), + /// InMemoryKeyManager::new( + /// "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + /// ), + /// ); + /// let safe = SafeSmartAccount::new( + /// key_manager, /// "0x4564420674EA68fcc61b463C0494807C759d47e6", /// ) /// .unwrap(); @@ -342,9 +380,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 } } @@ -399,11 +436,39 @@ 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 Swift/Kotlin side and pull the secret from the +/// platform's secure storage (e.g. iOS Keychain), fill the allocated protected +/// memory via `siegel_fill` and zeoized after use. Secret is pulled from secure +/// storage explicitly for the signataure operation and zeroized. #[uniffi::export(with_foreign)] pub trait SmartAccountKeyManager: Send + Sync { + /// Returns a freshly allocated and filled Siegel session containing the + /// hex-encoded EOA private key. The session is consumed by a single + /// `read_once`. fn get_eoa_private_key(&self) -> Arc; } +#[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: impl Into, + 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. @@ -415,12 +480,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") } } @@ -445,7 +511,7 @@ 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", ); @@ -459,7 +525,7 @@ mod tests { #[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( + let result = SafeSmartAccount::from_private_key_hex( invalid_hex.to_string(), "0x0000000000000000000000000000000000000042", ); @@ -481,7 +547,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, ); @@ -495,7 +561,7 @@ mod tests { #[test] fn test_sign_transaction() { - let safe = SafeSmartAccount::new( + let safe = SafeSmartAccount::from_private_key_hex( "4142710b9b4caaeb000b8e5de271bbebac7f509aab2f5e61d1ed1958bfe6d583" .to_string(), "0x4564420674EA68fcc61b463C0494807C759d47e6", @@ -519,7 +585,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", @@ -550,7 +616,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 fc523dc4..916dd57b 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -102,7 +102,6 @@ impl SafeSmartAccountSigner for SafeSmartAccount { ) -> Result { let message_hash = self.get_message_hash_for_safe(message, chain_id, None); self.sign_hash_sync(&message_hash) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) } fn sign_digest( @@ -114,34 +113,33 @@ impl SafeSmartAccountSigner for SafeSmartAccount { let message_hash = self.eip_712_hash(digest, chain_id, domain_separator_address); self.sign_hash_sync(&message_hash) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) } } impl SafeSmartAccount { - /// Signs a fully encoded digest using the wallet's private key in a - /// scope closure that ensures zeroization after signature. + /// 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 + /// - `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(); - let mut signature: Option; - siegel.read_once(|private_key| { + siegel.read_once(|private_key| -> Result { 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()))?; - signature = Some(signer.sign_hash_sync(final_digest)?); - drop(signer); - }); - signature.ok_or(SafeSmartAccountError::Generic { - error_message: "unexpectedly unable to generate signature".to_string(), - }) + signer + .sign_hash_sync(final_digest) + .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) + })? } /// Computes the digest for a specific message to be signed by the Safe Smart Account. @@ -298,7 +296,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", ) @@ -313,7 +311,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", ) @@ -328,7 +326,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", ) @@ -345,7 +343,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", ) @@ -361,7 +359,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", ) @@ -379,7 +377,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", ) @@ -405,7 +403,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", ) @@ -433,7 +431,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..a83d2840 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,57 @@ 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. + pub fn new>(private_key_hex: S) -> 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().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: only reads `len` bytes from `src`. + let rc = unsafe { + siegel_fill( + session.handle(), + self.hex_bytes.as_ptr(), + self.hex_bytes.len(), + ) + }; + assert_eq!(rc, FILL_OK, "siegel_fill failed: {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..4ba66ec2 100644 --- a/bedrock/src/transactions/mod.rs +++ b/bedrock/src/transactions/mod.rs @@ -64,14 +64,18 @@ impl SafeSmartAccount { /// /// # Example /// - /// ```rust,no_run - /// use bedrock::smart_account::SafeSmartAccount; + /// ```ignore + /// use std::sync::Arc; + /// use bedrock::smart_account::{SafeSmartAccount, SmartAccountKeyManager}; + /// use bedrock::test_utils::InMemoryKeyManager; /// use bedrock::transactions::TransactionError; /// use bedrock::primitives::Network; /// /// # async fn example() -> Result<(), TransactionError> { /// // Assume we have a configured SafeSmartAccount - /// # let safe_account = SafeSmartAccount::new("test_key".to_string(), "0x1234567890123456789012345678901234567890").unwrap(); + /// # let key_manager: Arc = + /// # Arc::new(InMemoryKeyManager::new("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")); + /// # 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 From 3297eff9d3713fabf83956381abc932c3ddc3e0b Mon Sep 17 00:00:00 2001 From: pd Date: Fri, 15 May 2026 14:37:05 -0700 Subject: [PATCH 3/5] minor improvements --- bedrock/src/siwe/test.rs | 5 +++-- bedrock/src/test_utils.rs | 17 +++++++++++++---- bedrock/src/transactions/mod.rs | 9 ++++----- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/bedrock/src/siwe/test.rs b/bedrock/src/siwe/test.rs index 2e95a170..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::from_private_key_hex(TEST_KEY, TEST_WALLET).unwrap() + SafeSmartAccount::from_private_key_hex(TEST_KEY.to_string(), TEST_WALLET).unwrap() } fn make_valid_message(datetime: &str) -> String { @@ -471,7 +471,8 @@ fn sign_produces_verifiable_signature() { let signer = PrivateKeySigner::from_str(TEST_KEY).unwrap(); let eoa_address = signer.address(); let account = - SafeSmartAccount::from_private_key_hex(TEST_KEY, TEST_WALLET).unwrap(); + SafeSmartAccount::from_private_key_hex(TEST_KEY.to_string(), TEST_WALLET) + .unwrap(); let msg = SiweMessage { scheme: Some(Scheme::HTTPS), diff --git a/bedrock/src/test_utils.rs b/bedrock/src/test_utils.rs index a83d2840..af5ce30b 100644 --- a/bedrock/src/test_utils.rs +++ b/bedrock/src/test_utils.rs @@ -34,11 +34,12 @@ pub struct InMemoryKeyManager { impl InMemoryKeyManager { /// Wraps a hex-encoded private key. - pub fn new>(private_key_hex: S) -> Self { + #[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().into_bytes(), + hex_bytes: private_key_hex.into_bytes(), } } } @@ -48,7 +49,13 @@ impl SmartAccountKeyManager for InMemoryKeyManager { 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: only reads `len` bytes from `src`. + // 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(), @@ -56,7 +63,9 @@ impl SmartAccountKeyManager for InMemoryKeyManager { self.hex_bytes.len(), ) }; - assert_eq!(rc, FILL_OK, "siegel_fill failed: {rc}"); + // 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 } } diff --git a/bedrock/src/transactions/mod.rs b/bedrock/src/transactions/mod.rs index 4ba66ec2..f2f76ac0 100644 --- a/bedrock/src/transactions/mod.rs +++ b/bedrock/src/transactions/mod.rs @@ -64,17 +64,16 @@ impl SafeSmartAccount { /// /// # Example /// - /// ```ignore + /// ```no_run /// use std::sync::Arc; /// use bedrock::smart_account::{SafeSmartAccount, SmartAccountKeyManager}; - /// use bedrock::test_utils::InMemoryKeyManager; /// 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 key_manager: Arc = - /// # Arc::new(InMemoryKeyManager::new("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80")); /// # let safe_account = SafeSmartAccount::new(key_manager, "0x1234567890123456789012345678901234567890").unwrap(); /// /// // Transfer USDC on World Chain From 7ba38efb9672dd69098052e5d490135ab52af1fe Mon Sep 17 00:00:00 2001 From: pd Date: Fri, 15 May 2026 14:42:10 -0700 Subject: [PATCH 4/5] zeroize it all! --- bedrock/src/smart_account/mod.rs | 140 +++++++++++++++++----------- bedrock/src/smart_account/signer.rs | 21 +++-- 2 files changed, 103 insertions(+), 58 deletions(-) diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index 7b873497..f6c40903 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -4,6 +4,7 @@ use alloy::{dyn_abi::TypedData, primitives::Address, signers::local::LocalSigner 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; @@ -77,8 +78,16 @@ pub enum SafeSmartAccountError { #[error("the contract {0} is restricted from TypedData signing.")] RestrictedContract(String), /// Error originating from the siegel secure-memory session backing the EOA key. - #[error("siegel session error: {0}")] - SiegelSession(String), + /// + /// `kind` is a stable machine-readable discriminant (e.g. `"consumed"`, + /// `"invalid_state"`); `message` is the human-readable description. + #[error("siegel session error ({kind}): {message}")] + SiegelSession { + /// Stable machine-readable discriminant for the underlying [`SessionError`] variant. + kind: String, + /// Human-readable description of the session 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 { @@ -109,7 +118,20 @@ impl std::fmt::Debug for SafeSmartAccount { impl From for SafeSmartAccountError { fn from(e: SessionError) -> Self { - Self::SiegelSession(e.to_string()) + 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", + }; + Self::SiegelSession { + kind: kind.to_string(), + message: e.to_string(), + } } } @@ -149,7 +171,7 @@ impl SafeSmartAccount { /// # 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( @@ -165,12 +187,17 @@ impl SafeSmartAccount { let siegel = key_manager.get_eoa_private_key(); let eoa_address = siegel.read_once( |private_key| -> Result { - let signer = - LocalSigner::from_slice(&hex::decode(private_key).map_err( - |e| SafeSmartAccountError::KeyDecoding(e.to_string()), - )?) + let raw_key: Zeroizing> = + Zeroizing::new(hex::decode(private_key).map_err(|e| { + SafeSmartAccountError::KeyDecoding(e.to_string()) + })?); + let signer = LocalSigner::from_slice(&raw_key) .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; - Ok(signer.address()) + let address = signer.address(); + // Redundant (`raw_key` and `signer` get immediately zeroized) but added for an abundance of clarity + drop(raw_key); + drop(signer); + Ok(address) }, )??; @@ -236,47 +263,45 @@ impl SafeSmartAccount { /// - Will throw an error if the signature process unexpectedly fails. /// /// # Examples - /// ```ignore + /// ```no_run /// use std::sync::Arc; - /// use bedrock::smart_account::{SafeSmartAccount, SmartAccountKeyManager}; - /// use bedrock::test_utils::InMemoryKeyManager; + /// use bedrock::smart_account::{ + /// SafeSmartAccount, SafeSmartAccountError, SmartAccountKeyManager, + /// }; /// use bedrock::transactions::foreign::UnparsedUserOperation; /// use bedrock::primitives::Network; /// - /// let key_manager: Arc = Arc::new( - /// // this is Anvil's default private key, it is a test secret - /// InMemoryKeyManager::new( - /// "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", - /// ), - /// ); - /// let safe = SafeSmartAccount::new( - /// key_manager, - /// "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, @@ -439,15 +464,26 @@ pub struct SafeTransaction { /// Foreign-side key store that delivers the EOA private key wrapped in a /// fresh [`SiegelSession`] each time it is needed. /// -/// Implementations live on Swift/Kotlin side and pull the secret from the -/// platform's secure storage (e.g. iOS Keychain), fill the allocated protected -/// memory via `siegel_fill` and zeoized after use. Secret is pulled from secure -/// storage explicitly for the signataure operation and zeroized. +/// 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 Siegel session containing the - /// hex-encoded EOA private key. The session is consumed by a single - /// `read_once`. + /// 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; } @@ -460,7 +496,7 @@ impl SafeSmartAccount { /// # Errors /// - Same conditions as [`SafeSmartAccount::new`]. pub fn from_private_key_hex( - private_key_hex: impl Into, + private_key_hex: String, wallet_address: &str, ) -> Result { let manager: Arc = diff --git a/bedrock/src/smart_account/signer.rs b/bedrock/src/smart_account/signer.rs index 916dd57b..35786412 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -7,6 +7,7 @@ use alloy::{ }; use k256::ecdsa::SigningKey; use ruint::aliases::U256; +use zeroize::Zeroizing; use crate::primitives::{address::BedrockAddress, HexEncodedData}; @@ -131,14 +132,22 @@ impl SafeSmartAccount { ) -> Result { let siegel = self.key_manager.get_eoa_private_key(); siegel.read_once(|private_key| -> Result { - let signer = LocalSigner::from_slice( - &hex::decode(private_key) + // 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(private_key) .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?, - ) - .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; - signer + ); + let signer = LocalSigner::from_slice(&raw_key) + .map_err(|e| SafeSmartAccountError::KeyDecoding(e.to_string()))?; + let signature = signer .sign_hash_sync(final_digest) - .map_err(|e| SafeSmartAccountError::Signing(e.to_string())) + .map_err(|e| SafeSmartAccountError::Signing(e.to_string())); + // Redundant, but added for an abundance of clarity. All copies are zeroized, only the signature + // escapes the closure. + drop(signer); + drop(raw_key); + signature })? } From 2a982e8983126f1a88b97913267b870205f5973a Mon Sep 17 00:00:00 2001 From: pd Date: Fri, 15 May 2026 14:46:28 -0700 Subject: [PATCH 5/5] feedback --- bedrock/src/smart_account/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index f6c40903..b90ab5a9 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -81,12 +81,12 @@ pub enum SafeSmartAccountError { /// /// `kind` is a stable machine-readable discriminant (e.g. `"consumed"`, /// `"invalid_state"`); `message` is the human-readable description. - #[error("siegel session error ({kind}): {message}")] + #[error("siegel session error ({kind}): {error_message}")] SiegelSession { /// Stable machine-readable discriminant for the underlying [`SessionError`] variant. kind: String, - /// Human-readable description of the session error. - message: 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}")] @@ -130,7 +130,7 @@ impl From for SafeSmartAccountError { }; Self::SiegelSession { kind: kind.to_string(), - message: e.to_string(), + error_message: e.to_string(), } } }