From 09a7d055dea196f177b23050b48505682c16e545 Mon Sep 17 00:00:00 2001 From: pd Date: Fri, 15 May 2026 13:40:31 -0700 Subject: [PATCH 01/14] 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 02/14] 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 03/14] 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 04/14] 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 05/14] 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(), } } } From 06248f4a3a235936e43dc19c7b31208a31810b6a Mon Sep 17 00:00:00 2001 From: pd Date: Mon, 1 Jun 2026 14:28:40 +0200 Subject: [PATCH 06/14] address feedback --- bedrock/Cargo.toml | 2 +- bedrock/src/smart_account/mod.rs | 5 +++++ bedrock/src/smart_account/signer.rs | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/bedrock/Cargo.toml b/bedrock/Cargo.toml index 4b2ebb62..b533e4f1 100644 --- a/bedrock/Cargo.toml +++ b/bedrock/Cargo.toml @@ -86,7 +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" +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/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index b90ab5a9..4b6e8a3d 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -187,6 +187,11 @@ impl SafeSmartAccount { let siegel = key_manager.get_eoa_private_key(); let eoa_address = siegel.read_once( |private_key| -> Result { + if private_key.len() != 64 { + return Err(SafeSmartAccountError::KeyDecoding( + "encoded key was not the right length (64 hex)".to_string(), + )); + } let raw_key: Zeroizing> = Zeroizing::new(hex::decode(private_key).map_err(|e| { SafeSmartAccountError::KeyDecoding(e.to_string()) diff --git a/bedrock/src/smart_account/signer.rs b/bedrock/src/smart_account/signer.rs index 35786412..b5fc5199 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -132,6 +132,12 @@ impl SafeSmartAccount { ) -> Result { let siegel = self.key_manager.get_eoa_private_key(); siegel.read_once(|private_key| -> Result { + if private_key.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( From 0bc3c2e4d544e97d7839e6919b93ba4abfcb11cb Mon Sep 17 00:00:00 2001 From: pd Date: Mon, 1 Jun 2026 17:15:53 +0200 Subject: [PATCH 07/14] fix kotlin tests --- kotlin/bedrock-tests/build.gradle.kts | 3 +- .../bedrock/BedrockSmartAccountTests.kt | 25 ++++++----- .../kotlin/bedrock/BedrockSolMacroTests.kt | 2 +- .../src/test/kotlin/bedrock/TestKeyManager.kt | 43 +++++++++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt 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..12fe5b30 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt @@ -1,30 +1,33 @@ package bedrock -import uniffi.bedrock.SafeSmartAccount -import uniffi.bedrock.SafeSmartAccountException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue +import uniffi.bedrock.SafeSmartAccount +import uniffi.bedrock.SafeSmartAccountException class BedrockSmartAccountTests { private val testPrivateKey = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" 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 +49,7 @@ class BedrockSmartAccountTests { @Test fun testMultipleMessages() { - val account = SafeSmartAccount(testPrivateKey, testWalletAddress) + val account = account() val messages = listOf( "Message 1", @@ -66,27 +69,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 +104,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 +113,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") 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..2c473d6e --- /dev/null +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt @@ -0,0 +1,43 @@ +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 + 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). + * + * For the empty-key test case we pad with a single zero byte so the + * `SiegelSession::new` length check passes and the failure surfaces from + * Rust as a `SafeSmartAccountException.KeyDecoding`. + */ +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()) + mem.write(0, bytes, 0, bytes.size) + val rc = SiegelNative.siegel_fill(session.handle().toLong(), mem, bytes.size.toLong()) + check(rc == 0) { "siegel_fill failed with code $rc" } + return session + } +} From 5cf550c68f4ab78967de05a85cc4cf26a3bb275a Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 09:35:26 +0200 Subject: [PATCH 08/14] kotlin zeroize --- .../bedrock/BedrockSmartAccountTests.kt | 21 +++++++++---- .../src/test/kotlin/bedrock/TestKeyManager.kt | 30 ++++++++++++++----- 2 files changed, 37 insertions(+), 14 deletions(-) diff --git a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt index 12fe5b30..358eaa59 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/BedrockSmartAccountTests.kt @@ -1,20 +1,22 @@ package bedrock +import uniffi.bedrock.SafeSmartAccount +import uniffi.bedrock.SafeSmartAccountException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertNotNull import kotlin.test.assertTrue -import uniffi.bedrock.SafeSmartAccount -import uniffi.bedrock.SafeSmartAccountException class BedrockSmartAccountTests { private val testPrivateKey = "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" private val testWalletAddress = "0x4564420674EA68fcc61b463C0494807C759d47e6" private val chainId: UInt = 10u // Optimism - private fun account(privateKey: String = testPrivateKey, walletAddress: String = testWalletAddress) = - SafeSmartAccount(TestKeyManager(privateKey), walletAddress) + 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. @@ -122,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/TestKeyManager.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt index 2c473d6e..d899a533 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt @@ -17,7 +17,12 @@ internal object SiegelNative { } @JvmStatic - external fun siegel_fill(handle: Long, src: Pointer, len: Long): Int + @Suppress("ktlint:standard:function-naming") + external fun siegel_fill( + handle: Long, + src: Pointer, + len: Long, + ): Int } /** @@ -25,19 +30,28 @@ internal object SiegelNative { * key in a fresh [SiegelSession] on every call. Production foreign code * would fetch the secret from the platform key store (e.g. Keychain). * - * For the empty-key test case we pad with a single zero byte so the - * `SiegelSession::new` length check passes and the failure surfaces from - * Rust as a `SafeSmartAccountException.KeyDecoding`. + * + * 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 { +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()) - mem.write(0, bytes, 0, bytes.size) - val rc = SiegelNative.siegel_fill(session.handle().toLong(), mem, bytes.size.toLong()) - check(rc == 0) { "siegel_fill failed with code $rc" } + try { + mem.write(0, bytes, 0, bytes.size) + val rc = SiegelNative.siegel_fill(session.handle().toLong(), mem, bytes.size.toLong()) + check(rc == 0) { "siegel_fill failed with code $rc" } + } finally { + mem.clear() + mem.close() + bytes.fill(0) + } return session } } From 6fe4bacf9400cd6c9c7e79a0781a8d57e0bbd566 Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 09:44:23 +0200 Subject: [PATCH 09/14] swift tests --- .../BedrockSmartAccountTests.swift | 104 ++++++++++-------- .../BedrockTests/BedrockSolMacroTests.swift | 2 +- 2 files changed, 61 insertions(+), 45 deletions(-) diff --git a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift index 118031bc..b5aebca7 100644 --- a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift +++ b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift @@ -1,7 +1,48 @@ +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) + } + } + } + let session = try! SiegelSession(len: UInt32(raw.count)) + let rc = raw.withUnsafeBufferPointer { buf -> Int32 in + siegel_fill(session.handle(), buf.baseAddress!, raw.count) + } + precondition(rc == 0, "siegel_fill failed with code \(rc)") + return session + } +} + final class BedrockSmartAccountTests: XCTestCase { // Well-known Anvil test private key and address @@ -9,23 +50,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!" @@ -51,11 +92,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 = [ @@ -81,10 +118,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 +128,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 +137,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 +173,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 +188,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 +210,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" ) From 7206c0580a6ccd4bc8e1066e88a20de926a25b21 Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 09:50:20 +0200 Subject: [PATCH 10/14] bump to 0.2.0 --- Cargo.lock | 9 +++++---- bedrock/Cargo.toml | 2 +- bedrock/src/smart_account/mod.rs | 6 ++++-- bedrock/src/test_utils.rs | 2 +- .../src/test/kotlin/bedrock/TestKeyManager.kt | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b28a8b38..bbad6120 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5205,9 +5205,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "siegel" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6ad2272eb97e86ac485964f8d26287d73af4ae9e1a3ddbaf7586644aa41c0a6" +checksum = "76e7be3bd3792e86519ef639757407f1abd86d21aa65bb3313d4327172f0f9b0" dependencies = [ "libc", "thiserror 2.0.18", @@ -5216,10 +5216,11 @@ dependencies = [ [[package]] name = "siegel-uniffi" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b30a1624db67abe426b85ff03348f361af58be258bad030b664340c7105fd41" +checksum = "02b1af96d641e2c8c7cc8d8c2c16d4b8ddd73f9068a2100721f46c23533012e1" dependencies = [ + "getrandom 0.4.1", "siegel", "thiserror 2.0.18", "uniffi", diff --git a/bedrock/Cargo.toml b/bedrock/Cargo.toml index b533e4f1..773db972 100644 --- a/bedrock/Cargo.toml +++ b/bedrock/Cargo.toml @@ -86,7 +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" +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/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index 4b6e8a3d..2eaac40b 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -127,6 +127,8 @@ impl From for SafeSmartAccountError { 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(), @@ -559,7 +561,7 @@ mod tests { 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)") ); } @@ -574,7 +576,7 @@ mod tests { assert_eq!( result.unwrap_err().to_string(), format!( - "failed to decode hex-encoded secret into k256 signer: signature error" + "failed to decode hex-encoded secret into k256 signer: encoded key was not the right length (64 hex)" ) ); } diff --git a/bedrock/src/test_utils.rs b/bedrock/src/test_utils.rs index af5ce30b..5609f60a 100644 --- a/bedrock/src/test_utils.rs +++ b/bedrock/src/test_utils.rs @@ -58,7 +58,7 @@ impl SmartAccountKeyManager for InMemoryKeyManager { // matching what `siegel_fill` expects. let rc = unsafe { siegel_fill( - session.handle(), + session.handle_id(), self.hex_bytes.as_ptr(), self.hex_bytes.len(), ) diff --git a/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt index d899a533..d02eb0c5 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt @@ -45,7 +45,7 @@ internal class TestKeyManager( val mem = Memory(bytes.size.toLong()) try { mem.write(0, bytes, 0, bytes.size) - val rc = SiegelNative.siegel_fill(session.handle().toLong(), mem, bytes.size.toLong()) + val rc = SiegelNative.siegel_fill(session.handle_id().toLong(), mem, bytes.size.toLong()) check(rc == 0) { "siegel_fill failed with code $rc" } } finally { mem.clear() From 3dee655723830cd6903377188f904d87f082a627 Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 09:51:36 +0200 Subject: [PATCH 11/14] copy all swift sources --- swift/test_swift.sh | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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}" From e9baf4cbc1adeb6d11ea0c2ea26ae2257c658dac Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 10:12:54 +0200 Subject: [PATCH 12/14] all --- .../src/test/kotlin/bedrock/TestKeyManager.kt | 2 +- swift/build_swift.sh | 83 +++++++++++-------- .../BedrockSmartAccountTests.swift | 2 +- 3 files changed, 49 insertions(+), 38 deletions(-) diff --git a/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt index d02eb0c5..4d7bf408 100644 --- a/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt +++ b/kotlin/bedrock-tests/src/test/kotlin/bedrock/TestKeyManager.kt @@ -45,7 +45,7 @@ internal class TestKeyManager( val mem = Memory(bytes.size.toLong()) try { mem.write(0, bytes, 0, bytes.size) - val rc = SiegelNative.siegel_fill(session.handle_id().toLong(), mem, bytes.size.toLong()) + val rc = SiegelNative.siegel_fill(session.handleId().toLong(), mem, bytes.size.toLong()) check(rc == 0) { "siegel_fill failed with code $rc" } } finally { mem.clear() 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/tests/BedrockTests/BedrockSmartAccountTests.swift b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift index b5aebca7..0ac2c84c 100644 --- a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift +++ b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift @@ -36,7 +36,7 @@ final class TestKeyManager: SmartAccountKeyManager, @unchecked Sendable { } let session = try! SiegelSession(len: UInt32(raw.count)) let rc = raw.withUnsafeBufferPointer { buf -> Int32 in - siegel_fill(session.handle(), buf.baseAddress!, raw.count) + siegel_fill(session.handleId(), buf.baseAddress!, raw.count) } precondition(rc == 0, "siegel_fill failed with code \(rc)") return session From f22b83f50eef5155ae664179fe19153c23d0ecc3 Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 10:35:55 +0200 Subject: [PATCH 13/14] improvements --- bedrock/src/smart_account/mod.rs | 39 ++++++++++++------- bedrock/src/smart_account/signer.rs | 26 ++++--------- .../BedrockSmartAccountTests.swift | 13 ++++--- 3 files changed, 40 insertions(+), 38 deletions(-) diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index 2eaac40b..753ba8bf 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -1,6 +1,7 @@ use std::{str::FromStr, sync::Arc}; 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; @@ -189,20 +190,9 @@ impl SafeSmartAccount { let siegel = key_manager.get_eoa_private_key(); let eoa_address = siegel.read_once( |private_key| -> Result { - if private_key.len() != 64 { - return Err(SafeSmartAccountError::KeyDecoding( - "encoded key was not the right length (64 hex)".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()))?; + let signer = signer_from_siegel_bytes(private_key)?; let address = signer.address(); - // Redundant (`raw_key` and `signer` get immediately zeroized) but added for an abundance of clarity - drop(raw_key); + // Redundant (`signer` get immediately zeroized) but added for an abundance of clarity drop(signer); Ok(address) }, @@ -494,6 +484,29 @@ pub trait SmartAccountKeyManager: Send + Sync { 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 diff --git a/bedrock/src/smart_account/signer.rs b/bedrock/src/smart_account/signer.rs index b5fc5199..210e1902 100644 --- a/bedrock/src/smart_account/signer.rs +++ b/bedrock/src/smart_account/signer.rs @@ -7,9 +7,11 @@ use alloy::{ }; use k256::ecdsa::SigningKey; use ruint::aliases::U256; -use zeroize::Zeroizing; -use crate::primitives::{address::BedrockAddress, HexEncodedData}; +use crate::{ + primitives::{address::BedrockAddress, HexEncodedData}, + smart_account::signer_from_siegel_bytes, +}; use super::{SafeSmartAccount, SafeSmartAccountError}; @@ -132,27 +134,13 @@ impl SafeSmartAccount { ) -> Result { let siegel = self.key_manager.get_eoa_private_key(); siegel.read_once(|private_key| -> Result { - if private_key.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(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()))?; + 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. All copies are zeroized, only the signature - // escapes the closure. + + // Redundant, but added for an abundance of clarity. Only the signature escapes the closure. drop(signer); - drop(raw_key); signature })? } diff --git a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift index 0ac2c84c..cf63ab32 100644 --- a/swift/tests/BedrockTests/BedrockSmartAccountTests.swift +++ b/swift/tests/BedrockTests/BedrockSmartAccountTests.swift @@ -34,11 +34,13 @@ final class TestKeyManager: SmartAccountKeyManager, @unchecked Sendable { } } } - let session = try! SiegelSession(len: UInt32(raw.count)) - let rc = raw.withUnsafeBufferPointer { buf -> Int32 in + 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(rc == 0, "siegel_fill failed with code \(rc)") + precondition(fillResult == 0, "siegel_fill failed with code \(fillResult)") return session } } @@ -77,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( @@ -100,7 +101,7 @@ final class BedrockSmartAccountTests: XCTestCase { "Another test message", "Special characters: !@#$%^&*()", "Numbers: 1234567890", - "Empty string test: ", + "Empty string test: " ] for message in messages { From 0272c07d1594f1508622da1fe1c9237081e5b21f Mon Sep 17 00:00:00 2001 From: pd Date: Tue, 2 Jun 2026 10:46:20 +0200 Subject: [PATCH 14/14] use actually an invalid curve point --- bedrock/src/smart_account/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bedrock/src/smart_account/mod.rs b/bedrock/src/smart_account/mod.rs index 753ba8bf..c1adcb1c 100644 --- a/bedrock/src/smart_account/mod.rs +++ b/bedrock/src/smart_account/mod.rs @@ -580,16 +580,18 @@ mod tests { #[test] fn test_cannot_initialize_with_invalid_curve_point() { - let invalid_hex = "2a"; // `42` is not a valid point on the curve + // This is the `secp256k1` modulus, not a valid field element + let invalid_curve_point = + "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"; let result = SafeSmartAccount::from_private_key_hex( - invalid_hex.to_string(), + invalid_curve_point.to_string(), "0x0000000000000000000000000000000000000042", ); assert!(result.is_err()); assert_eq!( result.unwrap_err().to_string(), format!( - "failed to decode hex-encoded secret into k256 signer: encoded key was not the right length (64 hex)" + "failed to decode hex-encoded secret into k256 signer: signature error" ) ); }