#[allow(
clippy::disallowed_types,
reason = "only used for interior mutability in MockBrowserWallet; no async or cross-thread contention"
)]
use std::sync::Mutex;
use alloy_primitives::{Address, B256, keccak256};
#[cfg(feature = "wasm")]
use wasm_bindgen::prelude::*;
use crate::error::CowError;
#[cfg(target_arch = "wasm32")]
use crate::traits::CowSigner;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WalletSession {
pub address: Address,
pub chain_id: u64,
pub connected_at: u64,
}
impl WalletSession {
#[must_use]
pub const fn new(address: Address, chain_id: u64, connected_at: u64) -> Self {
Self { address, chain_id, connected_at }
}
#[must_use]
pub const fn is_expired(&self, now: u64, ttl_secs: u64) -> bool {
now >= self.connected_at.saturating_add(ttl_secs)
}
}
impl core::fmt::Display for WalletSession {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"WalletSession({:#x} on chain {} since {})",
self.address, self.chain_id, self.connected_at
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WalletEvent {
AccountsChanged(Vec<Address>),
ChainChanged(u64),
Connect {
chain_id: u64,
},
Disconnect {
code: u32,
message: String,
},
}
impl WalletEvent {
#[must_use]
pub const fn is_accounts_changed(&self) -> bool {
matches!(self, Self::AccountsChanged(_))
}
#[must_use]
pub const fn is_chain_changed(&self) -> bool {
matches!(self, Self::ChainChanged(_))
}
#[must_use]
pub const fn is_connect(&self) -> bool {
matches!(self, Self::Connect { .. })
}
#[must_use]
pub const fn is_disconnect(&self) -> bool {
matches!(self, Self::Disconnect { .. })
}
}
impl core::fmt::Display for WalletEvent {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::AccountsChanged(addrs) => {
write!(f, "AccountsChanged({} account(s))", addrs.len())
}
Self::ChainChanged(id) => write!(f, "ChainChanged({id})"),
Self::Connect { chain_id } => write!(f, "Connect(chain_id={chain_id})"),
Self::Disconnect { code, message } => {
write!(f, "Disconnect(code={code}, message={message})")
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SignRequestKind {
TypedData,
Message,
}
#[derive(Debug, Clone)]
pub struct SignRequest {
pub kind: SignRequestKind,
pub data: Vec<u8>,
pub timestamp: u64,
}
#[cfg(feature = "wasm")]
pub struct BrowserWallet {
address: Address,
signer_fn: js_sys::Function,
session: Option<WalletSession>,
chain_id: u64,
}
#[cfg(feature = "wasm")]
impl core::fmt::Debug for BrowserWallet {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("BrowserWallet")
.field("address", &self.address)
.field("signer_fn", &"<js_sys::Function>")
.field("chain_id", &self.chain_id)
.field("session", &self.session)
.finish()
}
}
#[cfg(feature = "wasm")]
#[allow(unsafe_code, reason = "js_sys types are single-threaded in WASM; Send/Sync are no-ops")]
unsafe impl Send for BrowserWallet {}
#[cfg(feature = "wasm")]
#[allow(unsafe_code, reason = "js_sys types are single-threaded in WASM; Send/Sync are no-ops")]
unsafe impl Sync for BrowserWallet {}
#[cfg(feature = "wasm")]
impl BrowserWallet {
#[must_use]
pub fn new(address: Address, signer_fn: js_sys::Function) -> Self {
Self { address, signer_fn, session: None, chain_id: 1 }
}
#[must_use]
pub const fn with_chain_id(mut self, chain_id: u64) -> Self {
self.chain_id = chain_id;
self
}
#[must_use]
pub const fn address(&self) -> Address {
self.address
}
#[must_use]
pub const fn signer_fn(&self) -> &js_sys::Function {
&self.signer_fn
}
#[must_use]
pub const fn session(&self) -> Option<&WalletSession> {
self.session.as_ref()
}
#[must_use]
pub const fn is_connected(&self) -> bool {
self.session.is_some()
}
#[must_use]
pub const fn chain_id(&self) -> u64 {
self.chain_id
}
#[allow(
clippy::missing_const_for_fn,
reason = "Option::as_ref and expect are not const-compatible"
)]
pub fn connect(&mut self, now: u64) -> &WalletSession {
let session = WalletSession::new(self.address, self.chain_id, now);
self.session = Some(session);
#[allow(
clippy::expect_used,
reason = "infallible: we just assigned Some on the previous line"
)]
self.session.as_ref().expect("session was just set")
}
pub const fn disconnect(&mut self) {
self.session = None;
}
pub const fn switch_chain(&mut self, chain_id: u64) {
self.chain_id = chain_id;
if let Some(ref mut session) = self.session {
session.chain_id = chain_id;
}
}
}
#[cfg(target_arch = "wasm32")]
#[async_trait::async_trait(?Send)]
impl CowSigner for BrowserWallet {
fn address(&self) -> Address {
self.address
}
async fn sign_typed_data(
&self,
domain_separator: B256,
struct_hash: B256,
) -> Result<Vec<u8>, CowError> {
let digest = compute_eip712_digest(domain_separator, struct_hash);
let digest_hex = format!("0x{}", alloy_primitives::hex::encode(digest.as_slice()));
let sig_hex = call_signer_fn(&self.signer_fn, &digest_hex).await?;
parse_hex_signature(&sig_hex)
}
async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, CowError> {
let message_hex = format!("0x{}", alloy_primitives::hex::encode(message));
let sig_hex = call_signer_fn(&self.signer_fn, &message_hex).await?;
parse_hex_signature(&sig_hex)
}
}
#[must_use]
pub fn compute_eip712_digest(domain_separator: B256, struct_hash: B256) -> B256 {
let mut msg = [0u8; 66];
msg[0] = 0x19;
msg[1] = 0x01;
msg[2..34].copy_from_slice(domain_separator.as_ref());
msg[34..66].copy_from_slice(struct_hash.as_ref());
keccak256(msg)
}
#[cfg(target_arch = "wasm32")]
async fn call_signer_fn(
signer_fn: &js_sys::Function,
hex_payload: &str,
) -> Result<String, CowError> {
let promise =
signer_fn.call1(&JsValue::NULL, &JsValue::from_str(hex_payload)).map_err(|e| {
CowError::Signing(format!(
"signer_fn call failed: {}",
e.as_string().unwrap_or_default()
))
})?;
let future = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise));
let result = future.await.map_err(|e| {
CowError::Signing(format!("signer rejected: {}", e.as_string().unwrap_or_default()))
})?;
result
.as_string()
.ok_or_else(|| CowError::Signing("signer_fn must return a hex string signature".to_owned()))
}
#[cfg(any(target_arch = "wasm32", test))]
pub(crate) fn parse_hex_signature(hex_str: &str) -> Result<Vec<u8>, CowError> {
let stripped = hex_str.strip_prefix("0x").unwrap_or_else(|| hex_str);
alloy_primitives::hex::decode(stripped)
.map_err(|e| CowError::Signing(format!("invalid hex signature: {e}")))
}
#[cfg(feature = "wasm")]
#[must_use]
pub fn detect_injected_wallet() -> bool {
let global = js_sys::global();
let window = js_sys::Reflect::get(&global, &JsValue::from_str("window"))
.unwrap_or_else(|_| JsValue::UNDEFINED);
if window.is_undefined() || window.is_null() {
return false;
}
let ethereum = js_sys::Reflect::get(&window, &JsValue::from_str("ethereum"))
.unwrap_or_else(|_| JsValue::UNDEFINED);
!ethereum.is_undefined() && !ethereum.is_null()
}
#[cfg(target_arch = "wasm32")]
pub async fn request_accounts(ethereum: &JsValue) -> Result<Vec<String>, CowError> {
let method = JsValue::from_str("eth_requestAccounts");
let params = js_sys::Array::new();
let request_obj = js_sys::Object::new();
js_sys::Reflect::set(&request_obj, &JsValue::from_str("method"), &method).map_err(|e| {
CowError::Signing(format!("failed to set method: {}", e.as_string().unwrap_or_default()))
})?;
js_sys::Reflect::set(&request_obj, &JsValue::from_str("params"), ¶ms).map_err(|e| {
CowError::Signing(format!("failed to set params: {}", e.as_string().unwrap_or_default()))
})?;
let request_fn =
js_sys::Reflect::get(ethereum, &JsValue::from_str("request")).map_err(|e| {
CowError::Signing(format!(
"ethereum.request not found: {}",
e.as_string().unwrap_or_default()
))
})?;
let request_fn: js_sys::Function = request_fn
.dyn_into()
.map_err(|_| CowError::Signing("ethereum.request is not a function".to_owned()))?;
let promise = request_fn.call1(ethereum, &request_obj).map_err(|e| {
CowError::Signing(format!(
"eth_requestAccounts call failed: {}",
e.as_string().unwrap_or_default()
))
})?;
let future = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise));
let result = future.await.map_err(|e| {
CowError::Signing(format!(
"eth_requestAccounts rejected: {}",
e.as_string().unwrap_or_default()
))
})?;
let array: js_sys::Array = result
.dyn_into()
.map_err(|_| CowError::Signing("eth_requestAccounts did not return an array".to_owned()))?;
let mut accounts = Vec::with_capacity(array.length() as usize);
for i in 0..array.length() {
let val = array.get(i);
let s = val
.as_string()
.ok_or_else(|| CowError::Signing(format!("account at index {i} is not a string")))?;
accounts.push(s);
}
Ok(accounts)
}
#[cfg(target_arch = "wasm32")]
pub async fn request_switch_chain(ethereum: &JsValue, chain_id: u64) -> Result<(), CowError> {
let method = JsValue::from_str("wallet_switchEthereumChain");
let chain_hex = format!("0x{chain_id:x}");
let chain_param = js_sys::Object::new();
js_sys::Reflect::set(
&chain_param,
&JsValue::from_str("chainId"),
&JsValue::from_str(&chain_hex),
)
.map_err(|e| {
CowError::Signing(format!("failed to set chainId: {}", e.as_string().unwrap_or_default()))
})?;
let params = js_sys::Array::new();
params.push(&chain_param);
let request_obj = js_sys::Object::new();
js_sys::Reflect::set(&request_obj, &JsValue::from_str("method"), &method).map_err(|e| {
CowError::Signing(format!("failed to set method: {}", e.as_string().unwrap_or_default()))
})?;
js_sys::Reflect::set(&request_obj, &JsValue::from_str("params"), ¶ms).map_err(|e| {
CowError::Signing(format!("failed to set params: {}", e.as_string().unwrap_or_default()))
})?;
let request_fn =
js_sys::Reflect::get(ethereum, &JsValue::from_str("request")).map_err(|e| {
CowError::Signing(format!(
"ethereum.request not found: {}",
e.as_string().unwrap_or_default()
))
})?;
let request_fn: js_sys::Function = request_fn
.dyn_into()
.map_err(|_| CowError::Signing("ethereum.request is not a function".to_owned()))?;
let promise = request_fn.call1(ethereum, &request_obj).map_err(|e| {
CowError::Signing(format!(
"wallet_switchEthereumChain call failed: {}",
e.as_string().unwrap_or_default()
))
})?;
let future = wasm_bindgen_futures::JsFuture::from(js_sys::Promise::from(promise));
future.await.map_err(|e| {
CowError::Signing(format!(
"wallet_switchEthereumChain rejected: {}",
e.as_string().unwrap_or_default()
))
})?;
Ok(())
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = "createBrowserWallet")]
pub fn new_from_js(
address: &str,
signer_fn: &js_sys::Function,
) -> Result<JsBrowserWallet, JsValue> {
let addr: Address = address
.parse()
.map_err(|e: <Address as core::str::FromStr>::Err| JsValue::from_str(&e.to_string()))?;
Ok(JsBrowserWallet { inner: BrowserWallet::new(addr, signer_fn.clone()) })
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = "detectBrowserWallet")]
#[must_use]
pub fn detect_js() -> bool {
detect_injected_wallet()
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_name = "BrowserWallet")]
pub struct JsBrowserWallet {
inner: BrowserWallet,
}
#[cfg(feature = "wasm")]
impl core::fmt::Debug for JsBrowserWallet {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("JsBrowserWallet").field("inner", &self.inner).finish()
}
}
#[cfg(feature = "wasm")]
#[wasm_bindgen(js_class = "BrowserWallet")]
impl JsBrowserWallet {
#[wasm_bindgen(getter)]
#[must_use]
pub fn address(&self) -> String {
format!("{:#x}", self.inner.address)
}
}
#[derive(Debug)]
#[allow(
clippy::disallowed_types,
reason = "std::sync::Mutex is adequate for this test-only mock; no async or contention"
)]
pub struct MockBrowserWallet {
address: Address,
chain_id: u64,
connected: bool,
sign_requests: Mutex<Vec<SignRequest>>,
should_fail: bool,
events: Vec<WalletEvent>,
}
impl MockBrowserWallet {
#[must_use]
#[allow(
clippy::disallowed_types,
reason = "std::sync::Mutex is adequate for this test-only mock"
)]
#[allow(clippy::missing_const_for_fn, reason = "Mutex::new is not const-stable")]
pub fn new(address: Address, chain_id: u64) -> Self {
Self {
address,
chain_id,
connected: false,
sign_requests: Mutex::new(Vec::new()),
should_fail: false,
events: Vec::new(),
}
}
pub fn connect(&mut self) {
self.connected = true;
self.events.push(WalletEvent::Connect { chain_id: self.chain_id });
}
pub fn disconnect(&mut self) {
self.connected = false;
self.events
.push(WalletEvent::Disconnect { code: 4900, message: "disconnected".to_owned() });
}
pub fn switch_chain(&mut self, chain_id: u64) {
self.chain_id = chain_id;
self.events.push(WalletEvent::ChainChanged(chain_id));
}
pub const fn set_should_fail(&mut self, fail: bool) {
self.should_fail = fail;
}
#[must_use]
#[allow(
clippy::expect_used,
reason = "Mutex is never poisoned in single-threaded mock context"
)]
pub fn sign_request_count(&self) -> usize {
self.sign_requests.lock().expect("sign_requests lock").len()
}
#[must_use]
#[allow(
clippy::expect_used,
reason = "Mutex is never poisoned in single-threaded mock context"
)]
pub fn last_sign_request(&self) -> Option<SignRequest> {
self.sign_requests.lock().expect("sign_requests lock").last().cloned()
}
#[must_use]
pub fn events(&self) -> &[WalletEvent] {
&self.events
}
pub fn clear_events(&mut self) {
self.events.clear();
}
#[must_use]
pub const fn is_connected(&self) -> bool {
self.connected
}
#[must_use]
pub const fn chain_id(&self) -> u64 {
self.chain_id
}
}
const MOCK_SIGNATURE: [u8; 65] = [0u8; 65];
#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl crate::traits::CowSigner for MockBrowserWallet {
fn address(&self) -> Address {
self.address
}
async fn sign_typed_data(
&self,
domain_separator: B256,
struct_hash: B256,
) -> Result<Vec<u8>, CowError> {
if self.should_fail {
return Err(CowError::Signing("mock wallet configured to fail".to_owned()));
}
let mut data = Vec::with_capacity(64);
data.extend_from_slice(domain_separator.as_ref());
data.extend_from_slice(struct_hash.as_ref());
#[allow(
clippy::expect_used,
reason = "Mutex is never poisoned in single-threaded mock context"
)]
self.sign_requests.lock().expect("sign_requests lock").push(SignRequest {
kind: SignRequestKind::TypedData,
data,
timestamp: 0,
});
Ok(MOCK_SIGNATURE.to_vec())
}
async fn sign_message(&self, message: &[u8]) -> Result<Vec<u8>, CowError> {
if self.should_fail {
return Err(CowError::Signing("mock wallet configured to fail".to_owned()));
}
#[allow(
clippy::expect_used,
reason = "Mutex is never poisoned in single-threaded mock context"
)]
self.sign_requests.lock().expect("sign_requests lock").push(SignRequest {
kind: SignRequestKind::Message,
data: message.to_vec(),
timestamp: 0,
});
Ok(MOCK_SIGNATURE.to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_address() -> Address {
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045".parse().expect("valid address")
}
fn test_address_2() -> Address {
"0x1111111111111111111111111111111111111111".parse().expect("valid address")
}
#[test]
fn parse_hex_signature_with_prefix() {
let hex = "0xabcdef01";
let bytes = parse_hex_signature(hex).expect("should parse");
assert_eq!(bytes, vec![0xab, 0xcd, 0xef, 0x01]);
}
#[test]
fn parse_hex_signature_without_prefix() {
let hex = "abcdef01";
let bytes = parse_hex_signature(hex).expect("should parse");
assert_eq!(bytes, vec![0xab, 0xcd, 0xef, 0x01]);
}
#[test]
fn parse_hex_signature_empty() {
let hex = "0x";
let bytes = parse_hex_signature(hex).expect("should parse empty");
assert!(bytes.is_empty());
}
#[test]
fn parse_hex_signature_invalid() {
let hex = "0xZZZZ";
let result = parse_hex_signature(hex);
assert!(result.is_err(), "should fail on invalid hex");
}
#[test]
fn parse_hex_signature_65_bytes() {
let hex = format!("0x{}", "ab".repeat(65));
let bytes = parse_hex_signature(&hex).expect("should parse 65-byte sig");
assert_eq!(bytes.len(), 65);
}
#[test]
fn parse_hex_signature_odd_length() {
let hex = "0xabc";
let result = parse_hex_signature(hex);
assert!(result.is_err(), "odd-length hex should fail");
}
#[test]
fn eip712_digest_computation() {
let domain_sep = B256::ZERO;
let struct_hash = B256::ZERO;
let digest = compute_eip712_digest(domain_sep, struct_hash);
assert_ne!(digest, B256::ZERO);
let expected: B256 = "0x0b15111afa5c2b936d8dd23e1ffc4a97dd7a9af57a8144231ff70b749ab128d0"
.parse()
.expect("valid hash");
assert_eq!(digest, expected);
}
#[test]
fn eip712_digest_changes_with_domain() {
let struct_hash = B256::ZERO;
let digest_a = compute_eip712_digest(B256::ZERO, struct_hash);
let domain_b: B256 = "0x0000000000000000000000000000000000000000000000000000000000000001"
.parse()
.expect("valid hash");
let digest_b = compute_eip712_digest(domain_b, struct_hash);
assert_ne!(digest_a, digest_b, "different domains must produce different digests");
}
#[test]
fn eip712_digest_changes_with_struct_hash() {
let domain_sep = B256::ZERO;
let digest_a = compute_eip712_digest(domain_sep, B256::ZERO);
let struct_hash_b: B256 =
"0x0000000000000000000000000000000000000000000000000000000000000001"
.parse()
.expect("valid hash");
let digest_b = compute_eip712_digest(domain_sep, struct_hash_b);
assert_ne!(digest_a, digest_b, "different struct hashes must produce different digests");
}
#[test]
fn test_address_roundtrip() {
let addr = test_address();
let hex = format!("{addr:#x}");
let parsed: Address = hex.parse().expect("roundtrip parse");
assert_eq!(addr, parsed);
}
#[test]
fn cow_signer_trait_is_object_safe() {
fn _assert_object_safe(_: &dyn crate::traits::CowSigner) {}
}
#[test]
fn compute_eip712_digest_is_deterministic() {
let domain: B256 = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.parse()
.expect("valid hash");
let hash: B256 = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
.parse()
.expect("valid hash");
let d1 = compute_eip712_digest(domain, hash);
let d2 = compute_eip712_digest(domain, hash);
assert_eq!(d1, d2, "same inputs must produce same digest");
}
#[test]
fn wallet_session_new() {
let addr = test_address();
let session = WalletSession::new(addr, 1, 1_000_000);
assert_eq!(session.address, addr);
assert_eq!(session.chain_id, 1);
assert_eq!(session.connected_at, 1_000_000);
}
#[test]
fn wallet_session_is_expired_false_within_ttl() {
let session = WalletSession::new(test_address(), 1, 1000);
assert!(!session.is_expired(4599, 3600));
}
#[test]
fn wallet_session_is_expired_true_at_boundary() {
let session = WalletSession::new(test_address(), 1, 1000);
assert!(session.is_expired(4600, 3600));
}
#[test]
fn wallet_session_is_expired_true_past_ttl() {
let session = WalletSession::new(test_address(), 1, 1000);
assert!(session.is_expired(9999, 3600));
}
#[test]
fn wallet_session_display() {
let session = WalletSession::new(test_address(), 42, 1_700_000_000);
let display = format!("{session}");
assert!(display.contains("chain 42"), "display should contain chain ID");
assert!(display.contains("1700000000"), "display should contain timestamp");
}
#[test]
fn wallet_event_accounts_changed() {
let event = WalletEvent::AccountsChanged(vec![test_address()]);
assert!(event.is_accounts_changed());
assert!(!event.is_chain_changed());
assert!(!event.is_connect());
assert!(!event.is_disconnect());
}
#[test]
fn wallet_event_chain_changed() {
let event = WalletEvent::ChainChanged(137);
assert!(!event.is_accounts_changed());
assert!(event.is_chain_changed());
assert!(!event.is_connect());
assert!(!event.is_disconnect());
}
#[test]
fn wallet_event_connect() {
let event = WalletEvent::Connect { chain_id: 1 };
assert!(!event.is_accounts_changed());
assert!(!event.is_chain_changed());
assert!(event.is_connect());
assert!(!event.is_disconnect());
}
#[test]
fn wallet_event_disconnect() {
let event = WalletEvent::Disconnect { code: 4900, message: "connection lost".to_owned() };
assert!(!event.is_accounts_changed());
assert!(!event.is_chain_changed());
assert!(!event.is_connect());
assert!(event.is_disconnect());
}
#[test]
fn wallet_event_display_accounts_changed() {
let event = WalletEvent::AccountsChanged(vec![test_address(), test_address_2()]);
let display = format!("{event}");
assert!(display.contains("2 account(s)"), "display should show account count");
}
#[test]
fn wallet_event_display_chain_changed() {
let display = format!("{}", WalletEvent::ChainChanged(42161));
assert!(display.contains("42161"));
}
#[test]
fn wallet_event_display_connect() {
let display = format!("{}", WalletEvent::Connect { chain_id: 10 });
assert!(display.contains("10"));
}
#[test]
fn wallet_event_display_disconnect() {
let display =
format!("{}", WalletEvent::Disconnect { code: 4900, message: "bye".to_owned() });
assert!(display.contains("4900"));
assert!(display.contains("bye"));
}
#[test]
fn mock_wallet_new_defaults() {
let mock = MockBrowserWallet::new(test_address(), 1);
assert!(!mock.is_connected());
assert_eq!(mock.chain_id(), 1);
assert_eq!(mock.sign_request_count(), 0);
assert!(mock.events().is_empty());
}
#[test]
fn mock_wallet_connect_disconnect() {
let mut mock = MockBrowserWallet::new(test_address(), 1);
mock.connect();
assert!(mock.is_connected());
assert_eq!(mock.events().len(), 1);
assert!(mock.events()[0].is_connect());
mock.disconnect();
assert!(!mock.is_connected());
assert_eq!(mock.events().len(), 2);
assert!(mock.events()[1].is_disconnect());
}
#[test]
fn mock_wallet_switch_chain() {
let mut mock = MockBrowserWallet::new(test_address(), 1);
mock.switch_chain(137);
assert_eq!(mock.chain_id(), 137);
assert_eq!(mock.events().len(), 1);
assert!(mock.events()[0].is_chain_changed());
assert_eq!(mock.events()[0], WalletEvent::ChainChanged(137));
}
#[test]
fn mock_wallet_clear_events() {
let mut mock = MockBrowserWallet::new(test_address(), 1);
mock.connect();
mock.disconnect();
assert_eq!(mock.events().len(), 2);
mock.clear_events();
assert!(mock.events().is_empty());
}
#[tokio::test]
async fn mock_wallet_sign_typed_data_success() {
let mock = MockBrowserWallet::new(test_address(), 1);
let result = crate::traits::CowSigner::sign_typed_data(&mock, B256::ZERO, B256::ZERO).await;
assert!(result.is_ok());
let sig = result.expect("signing should succeed");
assert_eq!(sig.len(), 65);
assert_eq!(mock.sign_request_count(), 1);
let req = mock.last_sign_request().expect("should have a request");
assert_eq!(req.kind, SignRequestKind::TypedData);
assert_eq!(req.data.len(), 64);
}
#[tokio::test]
async fn mock_wallet_sign_typed_data_failure() {
let mut mock = MockBrowserWallet::new(test_address(), 1);
mock.set_should_fail(true);
let result = crate::traits::CowSigner::sign_typed_data(&mock, B256::ZERO, B256::ZERO).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("mock wallet configured to fail"));
}
#[tokio::test]
async fn mock_wallet_sign_message_success() {
let mock = MockBrowserWallet::new(test_address(), 1);
let message = b"hello world";
let result = crate::traits::CowSigner::sign_message(&mock, message).await;
assert!(result.is_ok());
let sig = result.expect("signing should succeed");
assert_eq!(sig.len(), 65);
assert_eq!(mock.sign_request_count(), 1);
let req = mock.last_sign_request().expect("should have a request");
assert_eq!(req.kind, SignRequestKind::Message);
assert_eq!(req.data, message);
}
#[tokio::test]
async fn mock_wallet_sign_message_failure() {
let mut mock = MockBrowserWallet::new(test_address(), 1);
mock.set_should_fail(true);
let result = crate::traits::CowSigner::sign_message(&mock, b"test").await;
assert!(result.is_err());
}
#[tokio::test]
async fn mock_wallet_multiple_sign_requests() {
let mock = MockBrowserWallet::new(test_address(), 1);
crate::traits::CowSigner::sign_typed_data(&mock, B256::ZERO, B256::ZERO).await.unwrap();
crate::traits::CowSigner::sign_message(&mock, b"msg1").await.unwrap();
crate::traits::CowSigner::sign_message(&mock, b"msg2").await.unwrap();
assert_eq!(mock.sign_request_count(), 3);
}
#[tokio::test]
async fn mock_wallet_cow_signer_address() {
let addr = test_address();
let mock = MockBrowserWallet::new(addr, 1);
assert_eq!(crate::traits::CowSigner::address(&mock), addr);
}
#[test]
fn mock_wallet_is_send_and_sync() {
fn _assert_send<T: Send>() {}
fn _assert_sync<T: Sync>() {}
_assert_send::<MockBrowserWallet>();
_assert_sync::<MockBrowserWallet>();
}
#[test]
fn mock_wallet_implements_cow_signer() {
fn _assert_cow_signer<T: crate::traits::CowSigner>() {}
_assert_cow_signer::<MockBrowserWallet>();
}
#[test]
fn sign_request_kind_equality() {
assert_eq!(SignRequestKind::TypedData, SignRequestKind::TypedData);
assert_eq!(SignRequestKind::Message, SignRequestKind::Message);
assert_ne!(SignRequestKind::TypedData, SignRequestKind::Message);
}
#[test]
fn mock_wallet_event_sequence() {
let mut mock = MockBrowserWallet::new(test_address(), 1);
mock.connect();
mock.switch_chain(42);
mock.disconnect();
assert_eq!(mock.events().len(), 3);
assert!(mock.events()[0].is_connect());
assert!(mock.events()[1].is_chain_changed());
assert!(mock.events()[2].is_disconnect());
}
#[test]
fn wallet_session_equality() {
let s1 = WalletSession::new(test_address(), 1, 1000);
let s2 = WalletSession::new(test_address(), 1, 1000);
let s3 = WalletSession::new(test_address(), 2, 1000);
assert_eq!(s1, s2);
assert_ne!(s1, s3);
}
}