diff --git a/Cargo.lock b/Cargo.lock
index 5ce4dd7e6d149..6587058525848 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -392,9 +392,9 @@ dependencies = [
[[package]]
name = "alloy-provider"
-version = "1.0.12"
+version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea624ddcdad357c33652b86aa7df9bd21afd2080973389d3facf1a221c573948"
+checksum = "a58276c10466554bcd9dd93cd5ea349d2c6d28e29d59ad299e200b3eedbea205"
dependencies = [
"alloy-chains",
"alloy-consensus",
@@ -481,9 +481,9 @@ dependencies = [
[[package]]
name = "alloy-rpc-client"
-version = "1.0.12"
+version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e43d00b4de38432304c4e4b01ae6a3601490fd9824c852329d158763ec18663c"
+checksum = "2ede96b42460d865ff3c26ce604aa927c829a7b621675d0a7bdfc8cdc9d5f7fb"
dependencies = [
"alloy-json-rpc",
"alloy-primitives",
@@ -588,7 +588,7 @@ dependencies = [
"alloy-rlp",
"alloy-serde",
"alloy-sol-types",
- "itertools 0.14.0",
+ "itertools 0.13.0",
"serde",
"serde_json",
"thiserror 2.0.12",
@@ -838,9 +838,9 @@ dependencies = [
[[package]]
name = "alloy-transport-http"
-version = "1.0.12"
+version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b234272ee449e32c9f1afbbe4ee08ea7c4b52f14479518f95c844ab66163c545"
+checksum = "40b18ce0732e42186d10479b7d87f96eb3991209c472f534fb775223e1e51f4f"
dependencies = [
"alloy-json-rpc",
"alloy-transport",
@@ -2441,6 +2441,12 @@ dependencies = [
"shlex",
]
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
[[package]]
name = "cexpr"
version = "0.6.0"
@@ -2791,6 +2797,16 @@ dependencies = [
"windows-sys 0.59.0",
]
+[[package]]
+name = "combine"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
[[package]]
name = "comfy-table"
version = "7.1.4"
@@ -4067,6 +4083,36 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "foundry-browser-wallet"
+version = "1.2.3"
+dependencies = [
+ "alloy-consensus",
+ "alloy-dyn-abi",
+ "alloy-network",
+ "alloy-primitives",
+ "alloy-provider",
+ "alloy-rpc-types",
+ "alloy-signer",
+ "alloy-signer-local",
+ "alloy-sol-types",
+ "async-trait",
+ "axum",
+ "foundry-common",
+ "hex",
+ "parking_lot",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.12",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing",
+ "uuid 1.17.0",
+ "webbrowser",
+]
+
[[package]]
name = "foundry-cheatcodes"
version = "1.2.3"
@@ -4269,7 +4315,7 @@ dependencies = [
"fs_extra",
"futures-util",
"home",
- "itertools 0.14.0",
+ "itertools 0.13.0",
"path-slash",
"rand 0.8.5",
"rayon",
@@ -4666,8 +4712,10 @@ dependencies = [
"derive_builder",
"eth-keystore",
"eyre",
+ "foundry-browser-wallet",
"foundry-config",
"gcloud-sdk",
+ "reqwest",
"rpassword",
"serde",
"thiserror 2.0.12",
@@ -5585,6 +5633,28 @@ dependencies = [
"jiff-tzdb",
]
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
[[package]]
name = "jobserver"
version = "0.1.33"
@@ -6165,6 +6235,12 @@ dependencies = [
"syn 2.0.104",
]
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
@@ -6456,6 +6532,31 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "objc2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88c6597e14493ab2e44ce58f2fdecf095a51f12ca57bec060a11c57332520551"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c"
+dependencies = [
+ "bitflags 2.9.1",
+ "objc2",
+]
+
[[package]]
name = "object"
version = "0.36.7"
@@ -7168,7 +7269,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d"
dependencies = [
"anyhow",
- "itertools 0.14.0",
+ "itertools 0.13.0",
"proc-macro2",
"quote",
"syn 2.0.104",
@@ -8700,7 +8801,7 @@ dependencies = [
"derive_builder",
"derive_more 2.0.1",
"dunce",
- "itertools 0.10.5",
+ "itertools 0.13.0",
"itoa",
"lasso",
"match_cfg",
@@ -8737,7 +8838,7 @@ dependencies = [
"alloy-primitives",
"bitflags 2.9.1",
"bumpalo",
- "itertools 0.10.5",
+ "itertools 0.13.0",
"memchr",
"num-bigint",
"num-rational",
@@ -9048,7 +9149,7 @@ dependencies = [
"serde_json",
"sha2 0.10.9",
"tempfile",
- "thiserror 2.0.12",
+ "thiserror 1.0.69",
"url",
"zip",
]
@@ -10276,6 +10377,22 @@ dependencies = [
"string_cache_codegen",
]
+[[package]]
+name = "webbrowser"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf4f3c0ba838e82b4e5ccc4157003fb8c324ee24c058470ffb82820becbde98"
+dependencies = [
+ "core-foundation 0.10.1",
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2",
+ "objc2-foundation",
+ "url",
+ "web-sys",
+]
+
[[package]]
name = "webpki-roots"
version = "0.26.11"
@@ -10468,6 +10585,15 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -10495,6 +10621,21 @@ dependencies = [
"windows-targets 0.53.2",
]
+[[package]]
+name = "windows-targets"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -10536,6 +10677,12 @@ dependencies = [
"windows-link",
]
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -10548,6 +10695,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -10560,6 +10713,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+[[package]]
+name = "windows_i686_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -10584,6 +10743,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+[[package]]
+name = "windows_i686_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -10596,6 +10761,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -10608,6 +10779,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -10620,6 +10797,12 @@ version = "0.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
diff --git a/Cargo.toml b/Cargo.toml
index e2fbc4a9f9bd4..f2400a4be5e04 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,8 @@ members = [
"crates/macros/",
"crates/test-utils/",
"crates/lint/",
+ "crates/wallets/",
+ "crates/browser-wallet/",
]
resolver = "2"
@@ -200,6 +202,7 @@ foundry-evm-traces = { path = "crates/evm/traces" }
foundry-macros = { path = "crates/macros" }
foundry-test-utils = { path = "crates/test-utils" }
foundry-wallets = { path = "crates/wallets" }
+foundry-browser-wallet = { path = "crates/browser-wallet" }
foundry-linking = { path = "crates/linking" }
# solc & compilation utilities
diff --git a/crates/browser-wallet/Cargo.toml b/crates/browser-wallet/Cargo.toml
new file mode 100644
index 0000000000000..bb9619fd7d75a
--- /dev/null
+++ b/crates/browser-wallet/Cargo.toml
@@ -0,0 +1,41 @@
+[package]
+name = "foundry-browser-wallet"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+homepage.workspace = true
+repository.workspace = true
+exclude.workspace = true
+
+[lints]
+workspace = true
+
+[dependencies]
+alloy-primitives.workspace = true
+alloy-signer = { workspace = true, features = ["eip712"] }
+alloy-rpc-types.workspace = true
+alloy-consensus.workspace = true
+alloy-network.workspace = true
+alloy-sol-types.workspace = true
+alloy-dyn-abi.workspace = true
+foundry-common.workspace = true
+
+axum.workspace = true
+tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
+tower.workspace = true
+tower-http = { workspace = true, features = ["cors", "set-header"] }
+uuid = { workspace = true, features = ["v4", "serde"] }
+hex = "0.4"
+parking_lot.workspace = true
+serde = { workspace = true, features = ["derive"] }
+serde_json.workspace = true
+thiserror.workspace = true
+async-trait.workspace = true
+tracing.workspace = true
+webbrowser = "1.0"
+
+[dev-dependencies]
+tokio = { workspace = true, features = ["test-util"] }
+reqwest = { workspace = true, features = ["json"] }
+alloy-signer-local.workspace = true
+alloy-provider.workspace = true
\ No newline at end of file
diff --git a/crates/browser-wallet/README.md b/crates/browser-wallet/README.md
new file mode 100644
index 0000000000000..495a0c144cdfe
--- /dev/null
+++ b/crates/browser-wallet/README.md
@@ -0,0 +1,220 @@
+# Browser Wallet
+
+Browser wallet integration for Foundry tools, enabling interaction with MetaMask and other browser-based wallets.
+
+## Overview
+
+This crate provides a bridge between Foundry CLI tools and browser wallets using a local HTTP server. It implements support for:
+- Transaction sending via `cast send --browser`
+- Contract deployment via `forge create --browser`
+- Message signing (personal_sign and eth_signTypedData_v4) via `cast wallet sign --browser`
+
+## Architecture
+
+The browser wallet integration follows this flow:
+
+1. CLI starts a local HTTP server on a random port
+2. Opens the user's browser to `http://localhost:PORT`
+3. Web interface connects to the browser wallet (e.g., MetaMask)
+4. CLI queues requests (transactions/signatures) for browser processing
+5. Browser polls for pending requests and prompts user for approval
+6. Results are returned to CLI via the HTTP API
+
+## HTTP API Reference
+
+All API endpoints follow JSON-RPC 2.0 conventions and are served from `http://localhost:PORT`.
+
+### GET `/api/heartbeat`
+Health check endpoint to verify server is running.
+
+**Response:**
+```json
+{
+ "success": true,
+ "data": {
+ "status": "ok",
+ "connected": true,
+ "address": "0x1234..."
+ }
+}
+```
+
+### GET `/api/transaction/pending`
+Retrieve the next pending transaction for user approval.
+
+**Response:**
+```json
+{
+ "success": true,
+ "data": {
+ "id": "uuid-v4",
+ "from": "0x1234...",
+ "to": "0x5678...",
+ "value": "0x1000000000000000",
+ "data": "0x...",
+ "chainId": "0x1"
+ }
+}
+```
+
+### POST `/api/transaction/response`
+Submit transaction approval/rejection result.
+
+**Request:**
+```json
+{
+ "id": "uuid-v4",
+ "hash": "0xabcd...",
+ "error": null
+}
+```
+
+**Response:**
+```json
+{
+ "success": true
+}
+```
+
+### GET `/api/sign/pending`
+Retrieve pending message signing request.
+
+**Response:**
+```json
+{
+ "success": true,
+ "data": {
+ "id": "uuid-v4",
+ "message": "Hello World",
+ "address": "0x1234...",
+ "type": "personal_sign"
+ }
+}
+```
+
+### POST `/api/sign/response`
+Submit message signing result.
+
+**Request:**
+```json
+{
+ "id": "uuid-v4",
+ "signature": "0xabcd...",
+ "error": null
+}
+```
+
+**Response:**
+```json
+{
+ "success": true
+}
+```
+
+### GET `/api/network`
+Get current network configuration.
+
+**Response:**
+```json
+{
+ "success": true,
+ "data": {
+ "chainId": 1,
+ "name": "mainnet"
+ }
+}
+```
+
+### POST `/api/account`
+Update connected wallet account status.
+
+**Request:**
+```json
+{
+ "address": "0x1234...",
+ "chainId": 1
+}
+```
+
+**Response:**
+```json
+{
+ "success": true
+}
+```
+
+## Message Types
+
+### Transaction Request (`BrowserTransaction`)
+```rust
+{
+ id: String, // Unique transaction ID
+ from: Address, // Sender address
+ to: Option
, // Recipient (None for contract creation)
+ value: U256, // ETH value to send
+ data: Bytes, // Transaction data
+ chainId: ChainId, // Network chain ID
+}
+```
+
+### Sign Request (`SignRequest`)
+```rust
+{
+ id: String, // Unique request ID
+ message: String, // Message to sign
+ address: Address, // Address to sign with
+ type: SignType, // "personal_sign" or "sign_typed_data"
+}
+```
+
+### Sign Type (`SignType`)
+- `personal_sign`: Standard message signing
+- `sign_typed_data`: EIP-712 typed data signing
+
+## JavaScript API
+
+The web interface uses the standard EIP-1193 provider interface:
+
+```javascript
+// Connect wallet
+await window.ethereum.request({ method: 'eth_requestAccounts' });
+
+// Send transaction
+const hash = await window.ethereum.request({
+ method: 'eth_sendTransaction',
+ params: [transaction]
+});
+
+// Sign message
+const signature = await window.ethereum.request({
+ method: 'personal_sign',
+ params: [message, address]
+});
+
+// Sign typed data
+const signature = await window.ethereum.request({
+ method: 'eth_signTypedData_v4',
+ params: [address, typedData]
+});
+```
+
+## Security
+
+- Server only accepts connections from localhost
+- Content Security Policy headers prevent XSS attacks
+- No sensitive data is stored; all operations are transient
+- Automatic timeout after 5 minutes of inactivity
+
+## Standards Compliance
+
+- [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193): Ethereum Provider JavaScript API
+- [EIP-712](https://eips.ethereum.org/EIPS/eip-712): Typed structured data hashing and signing
+- [JSON-RPC 2.0](https://www.jsonrpc.org/specification): Communication protocol
+
+## Contributing
+
+When adding new functionality:
+1. Update message types in `lib.rs`
+2. Add corresponding HTTP endpoints in `server.rs`
+3. Update JavaScript handlers in `assets/web/js/`
+4. Add integration tests in `tests/integration/`
\ No newline at end of file
diff --git a/crates/browser-wallet/src/assets.rs b/crates/browser-wallet/src/assets.rs
new file mode 100644
index 0000000000000..05a80c994541b
--- /dev/null
+++ b/crates/browser-wallet/src/assets.rs
@@ -0,0 +1,18 @@
+/// Embedded web assets for the browser wallet interface
+pub mod web {
+ /// HTML index page
+ pub const INDEX_HTML: &str = include_str!("assets/web/index.html");
+
+ /// JavaScript files
+ pub mod js {
+ pub const MAIN_JS: &str = include_str!("assets/web/js/main.js");
+ pub const WALLET_JS: &str = include_str!("assets/web/js/wallet.js");
+ pub const POLLING_JS: &str = include_str!("assets/web/js/polling.js");
+ pub const UTILS_JS: &str = include_str!("assets/web/js/utils.js");
+ }
+
+ /// CSS files
+ pub mod css {
+ pub const STYLES_CSS: &str = include_str!("assets/web/css/styles.css");
+ }
+}
diff --git a/crates/browser-wallet/src/assets/web/css/styles.css b/crates/browser-wallet/src/assets/web/css/styles.css
new file mode 100644
index 0000000000000..0185df6311826
--- /dev/null
+++ b/crates/browser-wallet/src/assets/web/css/styles.css
@@ -0,0 +1,279 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ background: #0a0a0a;
+ color: #e4e4e7;
+ min-height: 100vh;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.container {
+ width: 100%;
+ max-width: 480px;
+ padding: 2rem;
+}
+
+.card {
+ background: #18181b;
+ border: 1px solid #27272a;
+ border-radius: 12px;
+ padding: 2rem;
+ box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
+ overflow: hidden;
+}
+
+h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+h3 {
+ font-size: 1.125rem;
+ margin: 1.5rem 0 1rem 0;
+}
+
+.subtitle {
+ color: #71717a;
+ margin-bottom: 2rem;
+ font-size: 0.875rem;
+}
+
+/* Loading state animation */
+@keyframes pulse {
+ 0%, 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.5;
+ }
+}
+
+.status-value.initializing {
+ animation: pulse 1.5s ease-in-out infinite;
+ color: #60a5fa;
+}
+
+.status {
+ background: #27272a;
+ border-radius: 8px;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+.status.connected {
+ border: 1px solid #16a34a;
+ background: #16a34a10;
+}
+
+.status-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0.25rem 0;
+}
+
+.status-label {
+ color: #a1a1aa;
+ font-size: 0.875rem;
+}
+
+.status-value {
+ font-family: 'Courier New', monospace;
+ font-size: 0.875rem;
+}
+
+.status-value.connected {
+ color: #16a34a;
+}
+
+.address {
+ color: #3b82f6;
+ word-break: break-all;
+}
+
+.chain-badge {
+ background: #27272a;
+ padding: 0.25rem 0.75rem;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ color: #a1a1aa;
+}
+
+#connect-container {
+ display: flex;
+ justify-content: center;
+ margin: 1.5rem 0;
+}
+
+.btn {
+ padding: 0.75rem 1.5rem;
+ border: none;
+ border-radius: 8px;
+ font-size: 0.875rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s;
+}
+
+.btn:disabled {
+ background: #27272a;
+ color: #71717a;
+ cursor: not-allowed;
+}
+
+.btn-primary {
+ background: #3b82f6;
+ color: white;
+}
+
+.btn-primary:hover:not(:disabled) {
+ background: #2563eb;
+}
+
+.btn-secondary {
+ background: #27272a;
+ color: #e4e4e7;
+}
+
+.btn-secondary:hover:not(:disabled) {
+ background: #3f3f46;
+}
+
+.btn-reject {
+ background: #dc2626;
+}
+
+.btn-reject:hover:not(:disabled) {
+ background: #b91c1c;
+}
+
+.button-group {
+ display: flex;
+ gap: 0.75rem;
+ margin-top: 1.5rem;
+}
+
+.button-group button {
+ flex: 1;
+}
+
+#transaction-container {
+ width: 100%;
+ overflow: hidden;
+}
+
+.tx-details {
+ background: #27272a;
+ border-radius: 8px;
+ padding: 1rem;
+ margin: 1rem 0;
+ overflow: hidden;
+}
+
+.tx-row {
+ display: flex;
+ justify-content: space-between;
+ padding: 0.5rem 0;
+ border-bottom: 1px solid #3f3f46;
+}
+
+.tx-row:last-child {
+ border-bottom: none;
+}
+
+.tx-label {
+ color: #a1a1aa;
+ font-size: 0.875rem;
+}
+
+.tx-value {
+ font-family: 'Courier New', monospace;
+ font-size: 0.875rem;
+ word-break: break-all;
+ text-align: right;
+ max-width: 60%;
+}
+
+.success-message {
+ background: #16a34a10;
+ border: 1px solid #16a34a;
+ color: #16a34a;
+ padding: 1rem;
+ border-radius: 8px;
+ margin: 1rem 0;
+ word-break: break-all;
+ overflow-wrap: break-word;
+}
+
+.error-message {
+ background: #dc262610;
+ border: 1px solid #dc2626;
+ color: #dc2626;
+ padding: 1rem;
+ border-radius: 8px;
+ margin: 1rem 0;
+ word-break: break-all;
+ overflow-wrap: break-word;
+}
+
+.warning-message {
+ background: #27272a;
+ border-radius: 8px;
+ padding: 0.75rem;
+ margin-top: 1rem;
+ font-size: 0.875rem;
+ color: #71717a;
+ text-align: center;
+}
+
+.detecting {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: #71717a;
+ font-size: 0.875rem;
+}
+
+.spinner {
+ display: inline-block;
+ width: 16px;
+ height: 16px;
+ border: 2px solid #3b82f6;
+ border-radius: 50%;
+ border-top-color: transparent;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+.footer {
+ text-align: center;
+ color: #71717a;
+ font-size: 0.75rem;
+ margin-top: 2rem;
+}
+
+.security-warning {
+ background: #f59e0b1a;
+ border: 1px solid #f59e0b;
+ color: #f59e0b;
+ padding: 1rem;
+ border-radius: 8px;
+ text-align: center;
+ font-size: 0.875rem;
+ margin-bottom: 1rem;
+ font-weight: 500;
+}
\ No newline at end of file
diff --git a/crates/browser-wallet/src/assets/web/index.html b/crates/browser-wallet/src/assets/web/index.html
new file mode 100644
index 0000000000000..e54541439aebe
--- /dev/null
+++ b/crates/browser-wallet/src/assets/web/index.html
@@ -0,0 +1,72 @@
+
+
+
+
+
+ Foundry Browser Wallet
+
+
+
+
+
+
+
⚒️ Foundry Browser Wallet
+
Connect your wallet to send transactions
+
+
+
+
+ Status:
+ Initializing...
+
+
+ Account:
+ None
+
+
+ Network:
+ Unknown
+
+
+
+
+
+
+
+
+
+
+
📤 Transaction Request
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/crates/browser-wallet/src/assets/web/js/main.js b/crates/browser-wallet/src/assets/web/js/main.js
new file mode 100644
index 0000000000000..f0a1d495a1c6b
--- /dev/null
+++ b/crates/browser-wallet/src/assets/web/js/main.js
@@ -0,0 +1,336 @@
+// Global initialization flag
+let isInitialized = false;
+let initializationPromise = null;
+
+// Initialize application
+async function initializeApp() {
+ if (isInitialized) return;
+ if (initializationPromise) return initializationPromise;
+
+ initializationPromise = performInitialization();
+ await initializationPromise;
+ isInitialized = true;
+}
+
+async function performInitialization() {
+ console.log('Starting wallet initialization...');
+
+ // Set initial state
+ setConnectionState(ConnectionState.INITIALIZING);
+ updateStatus('Initializing...');
+
+ // Start heartbeat immediately
+ startHeartbeat();
+
+ // Check network details
+ checkNetworkDetails().catch(console.error);
+
+ // Detect provider with retry logic
+ const providerAvailable = await detectProvider();
+
+ if (!providerAvailable) {
+ setConnectionState(ConnectionState.PROVIDER_NOT_FOUND);
+ updateStatus('No wallet detected');
+ handleNoWalletDetected();
+ return;
+ }
+
+ // Provider is available
+ setConnectionState(ConnectionState.READY);
+ updateStatus('Ready');
+
+ // Set up event listeners
+ setupWalletEventListeners();
+
+ // Check for auto-reconnection
+ await attemptAutoReconnection();
+}
+
+async function attemptAutoReconnection() {
+ const lastConnection = connectionStorage.getLastConnection();
+
+ if (!lastConnection || !lastConnection.address) {
+ console.log('No previous connection found');
+ return;
+ }
+
+ console.log('Attempting to reconnect to:', lastConnection.address);
+
+ try {
+ // Check if account is still available
+ const accounts = await window.ethereum.request({ method: 'eth_accounts' });
+
+ if (accounts.includes(lastConnection.address)) {
+ // Account is available, restore connection
+ console.log('Restoring previous connection');
+
+ connectedAccount = lastConnection.address;
+ updateAccount(connectedAccount);
+ setConnectionState(ConnectionState.CONNECTED);
+ updateStatus('Connected');
+
+ // Hide connect button
+ const connectContainer = document.getElementById('connect-container');
+ if (connectContainer) {
+ connectContainer.style.display = 'none';
+ }
+
+ // Get current chain ID
+ const chainId = await window.ethereum.request({ method: 'eth_chainId' });
+ const chainIdDecimal = parseInt(chainId, 16);
+
+ // Save updated connection info
+ connectionStorage.saveConnection(connectedAccount, chainIdDecimal);
+
+ // Report to backend
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({
+ address: connectedAccount,
+ chain_id: chainIdDecimal
+ })
+ });
+
+ // Start polling
+ startTransactionPolling();
+ startSigningPolling();
+
+ } else {
+ // Account not available, clear stored connection
+ console.log('Previous account not available, clearing connection');
+ connectionStorage.clearConnection();
+ }
+ } catch (error) {
+ console.error('Auto-reconnection failed:', error);
+ connectionStorage.clearConnection();
+ }
+}
+
+function handleNoWalletDetected() {
+ const connectButton = document.getElementById('connect-button');
+ if (connectButton) {
+ connectButton.textContent = 'Install Wallet';
+ connectButton.disabled = true;
+ }
+
+ // Continue checking for wallet injection
+ const checkInterval = setInterval(async () => {
+ if (window.ethereum) {
+ clearInterval(checkInterval);
+ console.log('Wallet detected after page load');
+
+ setConnectionState(ConnectionState.READY);
+ updateStatus('Ready');
+
+ const connectButton = document.getElementById('connect-button');
+ if (connectButton) {
+ connectButton.textContent = 'Connect Wallet';
+ connectButton.disabled = false;
+ }
+
+ setupWalletEventListeners();
+
+ // Check for auto-reconnection
+ await attemptAutoReconnection();
+ }
+ }, 1000);
+}
+
+// Set up wallet event listeners
+function setupWalletEventListeners() {
+ if (!window.ethereum) return;
+
+ // Remove any existing listeners to avoid duplicates
+ window.ethereum.removeAllListeners('accountsChanged');
+ window.ethereum.removeAllListeners('chainChanged');
+
+ window.ethereum.on('accountsChanged', async (accounts) => {
+ if (accounts.length === 0) {
+ // Disconnected
+ connectedAccount = null;
+ updateAccount(null);
+ setConnectionState(ConnectionState.DISCONNECTED);
+ updateStatus('Disconnected');
+ connectionStorage.clearConnection();
+
+ // Show connect button
+ const connectContainer = document.getElementById('connect-container');
+ if (connectContainer) {
+ connectContainer.style.display = 'block';
+ }
+
+ // Report to backend
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({ address: null })
+ });
+
+ // Stop polling
+ if (pollingIntervals.transaction) {
+ clearInterval(pollingIntervals.transaction);
+ pollingIntervals.transaction = null;
+ }
+ if (pollingIntervals.signing) {
+ clearInterval(pollingIntervals.signing);
+ pollingIntervals.signing = null;
+ }
+ } else {
+ // Account changed
+ connectedAccount = accounts[0];
+ updateAccount(connectedAccount);
+ setConnectionState(ConnectionState.CONNECTED);
+ updateStatus('Connected');
+
+ // Hide connect button
+ const connectContainer = document.getElementById('connect-container');
+ if (connectContainer) {
+ connectContainer.style.display = 'none';
+ }
+
+ // Get chain ID
+ const chainId = await window.ethereum.request({ method: 'eth_chainId' });
+ const chainIdDecimal = parseInt(chainId, 16);
+
+ // Save connection
+ connectionStorage.saveConnection(connectedAccount, chainIdDecimal);
+
+ // Report to backend
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({
+ address: connectedAccount,
+ chain_id: chainIdDecimal
+ })
+ });
+
+ // Start polling if not already
+ if (!pollingIntervals.transaction) {
+ startTransactionPolling();
+ }
+ if (!pollingIntervals.signing) {
+ startSigningPolling();
+ }
+ }
+ });
+
+ // Listen for chain changes - secure handling without reload
+ window.ethereum.on('chainChanged', async (chainId) => {
+ const chainIdDecimal = parseInt(chainId, 16);
+ console.log('Chain changed to:', chainIdDecimal);
+
+ // Cancel all pending operations
+ if (currentTransaction) {
+ console.warn('Chain changed while transaction pending - cancelling');
+ // Report transaction failure
+ await apiCall('api/transaction/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentTransaction.id,
+ status: 'error',
+ error: 'Network changed during transaction'
+ })
+ });
+ currentTransaction = null;
+ isProcessingTransaction = false;
+ }
+
+ if (currentSigningRequest) {
+ console.warn('Chain changed while signing pending - cancelling');
+ // Report signing failure
+ await apiCall('api/sign/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentSigningRequest.id,
+ status: 'error',
+ error: 'Network changed during signing'
+ })
+ });
+ currentSigningRequest = null;
+ isProcessingSigning = false;
+ }
+
+ // Update saved connection if connected
+ if (connectedAccount) {
+ connectionStorage.saveConnection(connectedAccount, chainIdDecimal);
+ }
+
+ // Update backend with new chain ID
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({
+ address: connectedAccount,
+ chain_id: chainIdDecimal
+ })
+ });
+
+ // Update UI
+ updateStatus(`Chain changed to ${chainIdDecimal}`);
+
+ // Show warning
+ const warningDiv = document.createElement('div');
+ warningDiv.className = 'security-warning';
+ warningDiv.style.position = 'fixed';
+ warningDiv.style.top = '20px';
+ warningDiv.style.left = '50%';
+ warningDiv.style.transform = 'translateX(-50%)';
+ warningDiv.style.zIndex = '1000';
+ warningDiv.innerHTML = '\u26a0\ufe0f Network changed. Please verify you are on the correct network.';
+ document.body.appendChild(warningDiv);
+
+ // Remove warning after 5 seconds
+ setTimeout(() => {
+ warningDiv.remove();
+ }, 5000);
+
+ // Re-check network details
+ checkNetworkDetails().catch(console.error);
+ });
+}
+
+// Initialize on page load
+window.addEventListener('DOMContentLoaded', async () => {
+ await initializeApp();
+});
+
+// Cleanup on page unload
+window.addEventListener('beforeunload', () => {
+ stopAllPolling();
+});
+
+// Listen for connection state changes to update UI
+onConnectionStateChange((newState, oldState) => {
+ // Update connect button based on state
+ const connectButton = document.getElementById('connect-button');
+ if (!connectButton) return;
+
+ switch (newState) {
+ case ConnectionState.INITIALIZING:
+ connectButton.disabled = true;
+ connectButton.textContent = 'Initializing...';
+ break;
+ case ConnectionState.PROVIDER_NOT_FOUND:
+ connectButton.disabled = true;
+ connectButton.textContent = 'Install Wallet';
+ break;
+ case ConnectionState.READY:
+ connectButton.disabled = false;
+ connectButton.textContent = 'Connect Wallet';
+ break;
+ case ConnectionState.CONNECTING:
+ connectButton.disabled = true;
+ connectButton.textContent = 'Connecting...';
+ break;
+ case ConnectionState.CONNECTED:
+ connectButton.disabled = false;
+ connectButton.textContent = 'Connected';
+ break;
+ case ConnectionState.DISCONNECTED:
+ connectButton.disabled = false;
+ connectButton.textContent = 'Connect Wallet';
+ break;
+ case ConnectionState.ERROR:
+ connectButton.disabled = false;
+ connectButton.textContent = 'Retry Connection';
+ break;
+ }
+});
\ No newline at end of file
diff --git a/crates/browser-wallet/src/assets/web/js/polling.js b/crates/browser-wallet/src/assets/web/js/polling.js
new file mode 100644
index 0000000000000..6912176f89c14
--- /dev/null
+++ b/crates/browser-wallet/src/assets/web/js/polling.js
@@ -0,0 +1,173 @@
+let pollingIntervals = {
+ heartbeat: null,
+ transaction: null,
+ signing: null,
+ network: null
+};
+
+// Request queue to prevent race conditions
+const requestQueue = [];
+let processingQueue = false;
+
+// Add request to queue
+function queueRequest(request) {
+ requestQueue.push(request);
+ if (!processingQueue) {
+ processQueue();
+ }
+}
+
+// Process queued requests
+async function processQueue() {
+ if (processingQueue || requestQueue.length === 0) return;
+
+ processingQueue = true;
+
+ while (requestQueue.length > 0) {
+ const request = requestQueue.shift();
+ try {
+ await request();
+ } catch (error) {
+ console.error('Queue processing error:', error);
+ }
+ }
+
+ processingQueue = false;
+}
+
+// Start heartbeat polling
+function startHeartbeat() {
+ pollingIntervals.heartbeat = setInterval(async () => {
+ try {
+ const response = await apiCall('api/heartbeat');
+ if (!response || response.status !== 'alive') {
+ console.error('Heartbeat failed:', response);
+ showError('Lost connection to backend');
+ stopAllPolling();
+ }
+ } catch (error) {
+ console.error('Heartbeat error:', error);
+ showError('Lost connection to backend');
+ stopAllPolling();
+ }
+ }, 5000); // Every 5 seconds
+}
+
+// Poll for pending transactions with dynamic interval
+function startTransactionPolling() {
+ let pollDelay = 1000; // Start at 1 second
+ const MAX_POLL_DELAY = 5000; // Max 5 seconds
+
+ const poll = async () => {
+ // Only poll if connected
+ if (connectionState !== ConnectionState.CONNECTED) {
+ console.log('Not polling transactions - not connected');
+ pollingIntervals.transaction = null;
+ return;
+ }
+
+ if (currentTransaction || isProcessingTransaction) {
+ // Fast polling when processing
+ pollDelay = 1000;
+ } else {
+ try {
+ const tx = await apiCall('api/transaction/pending');
+ if (tx && tx.id) {
+ pollDelay = 1000; // Reset to fast polling
+ queueRequest(() => processTransaction(tx));
+ } else {
+ // Exponential backoff when no work
+ pollDelay = Math.min(pollDelay * 1.5, MAX_POLL_DELAY);
+ }
+ } catch (error) {
+ console.error('Transaction polling error:', error);
+ pollDelay = MAX_POLL_DELAY;
+ }
+ }
+
+ pollingIntervals.transaction = setTimeout(poll, pollDelay);
+ };
+
+ poll();
+}
+
+// Poll for pending signing requests with dynamic interval
+function startSigningPolling() {
+ let pollDelay = 1000; // Start at 1 second
+ const MAX_POLL_DELAY = 5000; // Max 5 seconds
+
+ const poll = async () => {
+ // Only poll if connected
+ if (connectionState !== ConnectionState.CONNECTED) {
+ console.log('Not polling signing requests - not connected');
+ pollingIntervals.signing = null;
+ return;
+ }
+
+ if (currentSigningRequest || isProcessingSigning) {
+ // Fast polling when processing
+ pollDelay = 1000;
+ } else {
+ try {
+ const req = await apiCall('api/sign/pending');
+ if (req && req.id) {
+ pollDelay = 1000; // Reset to fast polling
+ queueRequest(() => processSigningRequest(req));
+ } else {
+ // Exponential backoff when no work
+ pollDelay = Math.min(pollDelay * 1.5, MAX_POLL_DELAY);
+ }
+ } catch (error) {
+ console.error('Signing polling error:', error);
+ pollDelay = MAX_POLL_DELAY;
+ }
+ }
+
+ pollingIntervals.signing = setTimeout(poll, pollDelay);
+ };
+
+ poll();
+}
+
+// Check network details
+async function checkNetworkDetails() {
+ const details = await apiCall('api/network');
+ if (details) {
+ updateNetwork(details.network_name);
+
+ // Verify we're on the right network
+ if (window.ethereum && connectedAccount) {
+ try {
+ const chainId = await window.ethereum.request({ method: 'eth_chainId' });
+ const currentChainId = parseInt(chainId, 16);
+
+ if (currentChainId !== details.chain_id) {
+ showError(`Wrong network! Please switch to ${details.network_name} (Chain ID: ${details.chain_id})`);
+ }
+ } catch (error) {
+ console.error('Failed to check chain ID:', error);
+ }
+ }
+ }
+}
+
+// Stop all polling
+function stopAllPolling() {
+ Object.entries(pollingIntervals).forEach(([key, interval]) => {
+ if (interval) {
+ if (key === 'transaction' || key === 'signing') {
+ clearTimeout(interval);
+ } else {
+ clearInterval(interval);
+ }
+ }
+ });
+
+ // Clear all intervals
+ pollingIntervals = {
+ heartbeat: null,
+ transaction: null,
+ signing: null,
+ network: null
+ };
+}
\ No newline at end of file
diff --git a/crates/browser-wallet/src/assets/web/js/utils.js b/crates/browser-wallet/src/assets/web/js/utils.js
new file mode 100644
index 0000000000000..e0261b9f15ae9
--- /dev/null
+++ b/crates/browser-wallet/src/assets/web/js/utils.js
@@ -0,0 +1,239 @@
+// Storage keys
+const STORAGE_KEYS = {
+ LAST_CONNECTED_ACCOUNT: 'foundry.wallet.lastAccount',
+ LAST_CHAIN_ID: 'foundry.wallet.lastChainId',
+ CONNECTION_VERSION: 'foundry.wallet.version',
+ WALLET_CONNECTED: 'foundry.wallet.connected'
+};
+
+// Current version for storage migration
+const STORAGE_VERSION = '1.0.0';
+
+// Connection state persistence
+const connectionStorage = {
+ saveConnection(address, chainId) {
+ try {
+ localStorage.setItem(STORAGE_KEYS.LAST_CONNECTED_ACCOUNT, address);
+ localStorage.setItem(STORAGE_KEYS.LAST_CHAIN_ID, chainId.toString());
+ localStorage.setItem(STORAGE_KEYS.CONNECTION_VERSION, STORAGE_VERSION);
+ localStorage.setItem(STORAGE_KEYS.WALLET_CONNECTED, 'true');
+ } catch (e) {
+ console.warn('Failed to save connection state:', e);
+ }
+ },
+
+ getLastConnection() {
+ try {
+ const version = localStorage.getItem(STORAGE_KEYS.CONNECTION_VERSION);
+ // Clear old versions
+ if (version && version !== STORAGE_VERSION) {
+ this.clearConnection();
+ return null;
+ }
+
+ const wasConnected = localStorage.getItem(STORAGE_KEYS.WALLET_CONNECTED) === 'true';
+ if (!wasConnected) return null;
+
+ return {
+ address: localStorage.getItem(STORAGE_KEYS.LAST_CONNECTED_ACCOUNT),
+ chainId: parseInt(localStorage.getItem(STORAGE_KEYS.LAST_CHAIN_ID) || '0')
+ };
+ } catch (e) {
+ console.warn('Failed to get connection state:', e);
+ return null;
+ }
+ },
+
+ clearConnection() {
+ try {
+ localStorage.removeItem(STORAGE_KEYS.LAST_CONNECTED_ACCOUNT);
+ localStorage.removeItem(STORAGE_KEYS.LAST_CHAIN_ID);
+ localStorage.removeItem(STORAGE_KEYS.WALLET_CONNECTED);
+ } catch (e) {
+ console.warn('Failed to clear connection state:', e);
+ }
+ }
+};
+
+// API wrapper for backend communication with timeout
+async function apiCall(endpoint, options = {}, timeout = 30000) {
+ // Create abort controller for timeout
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
+
+ try {
+ const response = await fetch(`/${endpoint}`, {
+ ...options,
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options.headers
+ },
+ signal: controller.signal,
+ credentials: 'same-origin' // Important for CSRF protection
+ });
+
+ clearTimeout(timeoutId);
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+
+ return await response.json();
+ } catch (error) {
+ clearTimeout(timeoutId);
+
+ if (error.name === 'AbortError') {
+ console.error(`API call timed out after ${timeout}ms: ${endpoint}`);
+ showError('Request timed out. Please try again.');
+ } else {
+ console.error(`API call failed: ${endpoint}`, error);
+ }
+
+ return null;
+ }
+}
+
+// UI update helpers
+function updateStatus(status) {
+ const element = document.getElementById('connection-status');
+ if (element) {
+ element.textContent = status;
+ let className = 'status-value';
+ if (status === 'Connected') {
+ className += ' connected';
+ } else if (status === 'Initializing...') {
+ className += ' initializing';
+ }
+ element.className = className;
+ }
+}
+
+function updateAccount(address) {
+ const element = document.getElementById('account-address');
+ if (element) {
+ element.textContent = address || 'None';
+ }
+}
+
+function updateNetwork(networkName) {
+ const element = document.getElementById('network-name');
+ if (element) {
+ element.textContent = networkName || 'Unknown';
+ }
+}
+
+function showError(message) {
+ const errorContainer = document.getElementById('error-container');
+ const errorMessage = document.getElementById('error-message');
+
+ if (errorContainer && errorMessage) {
+ errorMessage.textContent = message;
+ errorContainer.style.display = 'block';
+
+ setTimeout(() => {
+ errorContainer.style.display = 'none';
+ }, 5000);
+ }
+}
+
+function hideError() {
+ const errorContainer = document.getElementById('error-container');
+ if (errorContainer) {
+ errorContainer.style.display = 'none';
+ }
+}
+
+// Format wei to ETH for display
+function formatWei(weiString) {
+ try {
+ const wei = BigInt(weiString);
+ const eth = Number(wei) / 1e18;
+ return `${weiString} wei (${eth.toFixed(6)} ETH)`;
+ } catch {
+ return `${weiString} wei`;
+ }
+}
+
+// Shorten address for display
+function shortenAddress(address) {
+ if (!address || address.length < 42) return address;
+ return `${address.slice(0, 6)}...${address.slice(-4)}`;
+}
+
+// Provider detection with exponential backoff
+async function detectProvider(maxRetries = 10, initialDelay = 100) {
+ let retries = 0;
+ let delay = initialDelay;
+
+ return new Promise((resolve) => {
+ const checkProvider = () => {
+ if (window.ethereum) {
+ console.log('Provider detected after', retries, 'attempts');
+ resolve(true);
+ return;
+ }
+
+ retries++;
+ if (retries >= maxRetries) {
+ console.warn('Provider not detected after', maxRetries, 'attempts');
+ resolve(false);
+ return;
+ }
+
+ // Exponential backoff with max delay of 5 seconds
+ delay = Math.min(delay * 1.5, 5000);
+ setTimeout(checkProvider, delay);
+ };
+
+ checkProvider();
+ });
+}
+
+// Connection state machine
+const ConnectionState = {
+ INITIALIZING: 'initializing',
+ PROVIDER_NOT_FOUND: 'provider_not_found',
+ READY: 'ready',
+ CONNECTING: 'connecting',
+ CONNECTED: 'connected',
+ DISCONNECTED: 'disconnected',
+ ERROR: 'error'
+};
+
+// Global connection state
+let connectionState = ConnectionState.INITIALIZING;
+let connectionStateListeners = [];
+
+function setConnectionState(newState) {
+ const oldState = connectionState;
+ connectionState = newState;
+ console.log('Connection state:', oldState, '->', newState);
+
+ // Notify listeners
+ connectionStateListeners.forEach(listener => {
+ listener(newState, oldState);
+ });
+}
+
+function onConnectionStateChange(listener) {
+ connectionStateListeners.push(listener);
+ // Return unsubscribe function
+ return () => {
+ connectionStateListeners = connectionStateListeners.filter(l => l !== listener);
+ };
+}
+
+// Check if we should attempt auto-reconnection
+async function shouldAutoReconnect() {
+ const lastConnection = connectionStorage.getLastConnection();
+ if (!lastConnection || !lastConnection.address) return false;
+
+ // Check if the account is still available
+ try {
+ const accounts = await window.ethereum.request({ method: 'eth_accounts' });
+ return accounts.includes(lastConnection.address);
+ } catch (error) {
+ console.error('Failed to check accounts for auto-reconnect:', error);
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/crates/browser-wallet/src/assets/web/js/wallet.js b/crates/browser-wallet/src/assets/web/js/wallet.js
new file mode 100644
index 0000000000000..381a90b33d415
--- /dev/null
+++ b/crates/browser-wallet/src/assets/web/js/wallet.js
@@ -0,0 +1,614 @@
+let currentTransaction = null;
+let currentSigningRequest = null;
+let connectedAccount = null;
+let isProcessingTransaction = false;
+let isProcessingSigning = false;
+
+// Track processed transactions to prevent replay attacks
+const processedTransactions = new Set();
+const processedSigningRequests = new Set();
+
+// Check if MetaMask is available
+function isMetaMaskAvailable() {
+ return typeof window.ethereum !== 'undefined';
+}
+
+// Validate wallet connection integrity
+async function validateConnection() {
+ if (!window.ethereum) return false;
+ if (connectionState !== ConnectionState.CONNECTED) return false;
+
+ try {
+ // Check if wallet is still connected
+ const accounts = await window.ethereum.request({
+ method: 'eth_accounts'
+ });
+
+ // Verify account matches what we expect
+ if (accounts.length === 0 || accounts[0] !== connectedAccount) {
+ // Account mismatch - potential security issue
+ await handleSecurityError('Wallet connection changed unexpectedly');
+ return false;
+ }
+
+ // Verify chain ID hasn't changed unexpectedly
+ const chainId = await window.ethereum.request({ method: 'eth_chainId' });
+ const currentChainId = parseInt(chainId, 16);
+
+ // Update saved connection info
+ connectionStorage.saveConnection(connectedAccount, currentChainId);
+
+ // Report current state to backend
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({
+ address: connectedAccount,
+ chain_id: currentChainId
+ })
+ });
+
+ return true;
+ } catch (error) {
+ console.error('Connection validation failed:', error);
+ setConnectionState(ConnectionState.ERROR);
+ return false;
+ }
+}
+
+// Handle security errors
+async function handleSecurityError(message) {
+ console.error('Security error:', message);
+
+ // Clear connection
+ connectedAccount = null;
+ updateAccount(null);
+ setConnectionState(ConnectionState.ERROR);
+ updateStatus('Disconnected - Security Error');
+ connectionStorage.clearConnection();
+
+ // Stop all operations
+ stopAllPolling();
+
+ // Show error
+ showError(message);
+
+ // Report to backend
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({ address: null })
+ });
+}
+
+// Connect to MetaMask
+async function connectWallet() {
+ if (!isMetaMaskAvailable()) {
+ showError('MetaMask is not installed! Please install MetaMask to continue.');
+ return;
+ }
+
+ // Prevent concurrent connection attempts
+ if (connectionState === ConnectionState.CONNECTING) {
+ console.log('Connection already in progress');
+ return;
+ }
+
+ const connectButton = document.getElementById('connect-button');
+
+ try {
+ setConnectionState(ConnectionState.CONNECTING);
+
+ if (connectButton) {
+ connectButton.disabled = true;
+ connectButton.textContent = 'Connecting...';
+ }
+
+ // Update status to show we're connecting
+ updateStatus('Connecting...');
+
+ // First check if we already have permission (wallet might already be connected)
+ const existingAccounts = await window.ethereum.request({
+ method: 'eth_accounts'
+ });
+
+ let accounts;
+ if (existingAccounts && existingAccounts.length > 0) {
+ // Already have permission, use existing accounts
+ accounts = existingAccounts;
+ } else {
+ // Need to request permission
+ accounts = await window.ethereum.request({
+ method: 'eth_requestAccounts'
+ });
+ }
+
+ if (accounts.length > 0) {
+ connectedAccount = accounts[0];
+ updateAccount(connectedAccount);
+ setConnectionState(ConnectionState.CONNECTED);
+ updateStatus('Connected');
+
+ // Hide connect button
+ const connectContainer = document.getElementById('connect-container');
+ if (connectContainer) {
+ connectContainer.style.display = 'none';
+ }
+
+ // Get chain ID
+ const chainId = await window.ethereum.request({ method: 'eth_chainId' });
+ const chainIdDecimal = parseInt(chainId, 16);
+
+ // Save connection to localStorage
+ connectionStorage.saveConnection(connectedAccount, chainIdDecimal);
+
+ // Report to backend
+ await apiCall('api/account', {
+ method: 'POST',
+ body: JSON.stringify({
+ address: connectedAccount,
+ chain_id: chainIdDecimal
+ })
+ });
+
+ // Start polling for transactions and signing requests
+ startTransactionPolling();
+ startSigningPolling();
+ } else {
+ // No accounts returned, reset button and status
+ setConnectionState(ConnectionState.READY);
+ updateStatus('Ready');
+ if (connectButton) {
+ connectButton.disabled = false;
+ connectButton.textContent = 'Connect Wallet';
+ }
+ }
+ } catch (error) {
+ console.error('Failed to connect:', error);
+
+ // Reset status
+ setConnectionState(ConnectionState.READY);
+ updateStatus('Ready');
+
+ // Don't show error for user rejection
+ if (error.code !== 4001) {
+ showError(`Failed to connect: ${error.message}`);
+ }
+
+ // Always reset button state on error
+ if (connectButton) {
+ connectButton.disabled = false;
+ connectButton.textContent = 'Connect Wallet';
+ }
+ }
+}
+
+// Process pending transaction
+async function processTransaction(tx) {
+ if (isProcessingTransaction) return;
+
+ // Prevent replay attacks
+ if (processedTransactions.has(tx.id)) {
+ console.error('Transaction already processed:', tx.id);
+ return;
+ }
+
+ // Validate connection before processing
+ if (!await validateConnection()) {
+ console.error('Connection validation failed');
+ return;
+ }
+
+ console.log('Received transaction:', tx);
+
+ // The transaction comes directly with the fields, not nested
+ const txData = tx;
+
+ // Parse chain ID - it's coming as hex string "0x7a69"
+ let chainId = parseInt(txData.chainId, 16);
+
+ // Normalize the transaction data for consistent access
+ // Keep hex values as-is since they're already in the correct format
+ currentTransaction = {
+ id: tx.id,
+ from: txData.from,
+ to: txData.to,
+ value: txData.value || '0x0',
+ data: txData.data || txData.input || '0x',
+ gas: txData.gas,
+ gas_price: txData.gasPrice,
+ max_fee_per_gas: txData.maxFeePerGas,
+ max_priority_fee_per_gas: txData.maxPriorityFeePerGas,
+ nonce: txData.nonce,
+ chain_id: chainId
+ };
+
+ // Ensure data field doesn't get double-prefixed
+ if (currentTransaction.data === '0x0x') {
+ currentTransaction.data = '0x';
+ }
+
+ console.log('Normalized transaction:', currentTransaction);
+
+ isProcessingTransaction = true;
+ processedTransactions.add(tx.id);
+
+ // Set timeout to clean up old transaction IDs after 5 minutes
+ setTimeout(() => {
+ processedTransactions.delete(tx.id);
+ }, 300000);
+
+ // Display transaction details with security warnings
+ const details = document.getElementById('transaction-details');
+ if (details) {
+ // Security warning for contract interactions
+ let securityWarning = '';
+ if (currentTransaction.data && currentTransaction.data !== '0x') {
+ securityWarning = '⚠️ This transaction interacts with a smart contract
';
+ }
+ if (!currentTransaction.to) {
+ securityWarning = '⚠️ This transaction deploys a new contract
';
+ }
+
+ let html = securityWarning;
+ html += 'From' + currentTransaction.from + '
';
+ html += 'To' + (currentTransaction.to || 'Contract Creation') + '
';
+ html += 'Value' + formatWei(currentTransaction.value || '0') + ' ETH
';
+
+ if (currentTransaction.gas) {
+ html += 'Gas Limit' + currentTransaction.gas + '
';
+ }
+
+ if (currentTransaction.data && currentTransaction.data !== '0x') {
+ const displayData = currentTransaction.data.length > 66 ? currentTransaction.data.substring(0, 66) + '...' : currentTransaction.data;
+ html += 'Data' + displayData + '
';
+ }
+
+ details.innerHTML = html;
+ }
+
+ // Show transaction container
+ const txContainer = document.getElementById('transaction-container');
+ if (txContainer) {
+ txContainer.style.display = 'block';
+ }
+
+ // Reset status
+ const statusEl = document.getElementById('transaction-status');
+ if (statusEl) {
+ statusEl.innerHTML = '';
+ }
+}
+
+// Update transaction status
+function updateTransactionStatus(message, isError = false) {
+ const statusEl = document.getElementById('transaction-status');
+ if (statusEl) {
+ statusEl.innerHTML = `
+
+ ${!isError ? '
' : ''}${message}
+
+ `;
+ }
+}
+
+// Approve transaction
+async function approveTransaction() {
+ if (!currentTransaction) return;
+
+ const actionsEl = document.getElementById('transaction-actions');
+ if (actionsEl) {
+ actionsEl.style.display = 'none';
+ }
+
+ try {
+ // Check if we need to switch chains
+ const chainId = await window.ethereum.request({ method: 'eth_chainId' });
+ const currentChainId = parseInt(chainId, 16);
+
+ if (currentChainId !== currentTransaction.chain_id) {
+ updateTransactionStatus('Switching to correct network...');
+
+ console.log('Current chain ID:', currentChainId, 'Transaction chain ID:', currentTransaction.chain_id);
+
+ try {
+ // Ensure chain_id is a number before converting to hex
+ const targetChainId = typeof currentTransaction.chain_id === 'string'
+ ? parseInt(currentTransaction.chain_id)
+ : currentTransaction.chain_id;
+ const hexChainId = '0x' + targetChainId.toString(16);
+
+ console.log('Switching to chain:', hexChainId);
+
+ await window.ethereum.request({
+ method: 'wallet_switchEthereumChain',
+ params: [{ chainId: hexChainId }],
+ });
+ } catch (switchError) {
+ console.error('Switch network error:', switchError);
+ // This error code indicates that the chain has not been added to MetaMask
+ if (switchError.code === 4902) {
+ throw new Error('Please add this network to MetaMask first');
+ } else {
+ throw new Error('Failed to switch network. Please switch manually and try again.');
+ }
+ }
+ }
+
+ updateTransactionStatus('Please sign the transaction in MetaMask...');
+
+ // Build transaction parameters
+ // Values are already in hex format from the server
+ const txParams = {
+ from: currentTransaction.from,
+ to: currentTransaction.to || undefined,
+ value: currentTransaction.value || '0x0',
+ data: currentTransaction.data || '0x',
+ };
+
+ // Ensure we don't have empty strings that would bypass the || operator
+ if (txParams.value === '') {
+ txParams.value = '0x0';
+ }
+ if (txParams.data === '') {
+ txParams.data = '0x';
+ }
+
+ // Prevent any accidental double hex prefixing
+ const ensureHexPrefix = (value) => {
+ if (!value) return '0x0';
+ // If it already starts with 0x, return as-is
+ if (value.startsWith('0x')) return value;
+ // Otherwise add 0x prefix
+ return '0x' + value;
+ };
+
+ // Clean up any potential double prefixes
+ if (txParams.value && txParams.value.startsWith('0x0x')) {
+ console.warn('Cleaning double hex prefix from value:', txParams.value);
+ txParams.value = '0x' + txParams.value.substring(4);
+ }
+ if (txParams.data && txParams.data.startsWith('0x0x')) {
+ console.warn('Cleaning double hex prefix from data:', txParams.data);
+ txParams.data = '0x' + txParams.data.substring(4);
+ }
+
+ console.log('Final transaction params before sending:', txParams);
+
+ if (currentTransaction.gas) {
+ txParams.gas = currentTransaction.gas;
+ }
+
+ if (currentTransaction.gas_price) {
+ txParams.gasPrice = currentTransaction.gas_price;
+ }
+
+ if (currentTransaction.max_fee_per_gas) {
+ txParams.maxFeePerGas = currentTransaction.max_fee_per_gas;
+ }
+
+ if (currentTransaction.max_priority_fee_per_gas) {
+ txParams.maxPriorityFeePerGas = currentTransaction.max_priority_fee_per_gas;
+ }
+
+ if (currentTransaction.nonce !== null && currentTransaction.nonce !== undefined) {
+ txParams.nonce = currentTransaction.nonce;
+ }
+
+ // Send transaction
+ const txHash = await window.ethereum.request({
+ method: 'eth_sendTransaction',
+ params: [txParams]
+ });
+
+ updateTransactionStatus('Transaction sent! Notifying Foundry...');
+
+ // Report success
+ await apiCall('api/transaction/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentTransaction.id,
+ status: 'success',
+ hash: txHash
+ })
+ });
+
+ // Show success message
+ const txContainer = document.getElementById('transaction-container');
+ if (txContainer) {
+ txContainer.innerHTML = `
+
+ ✅ Transaction sent
+
+ ${txHash}
+
+ `;
+ }
+
+ // Reset state for next transaction
+ currentTransaction = null;
+ isProcessingTransaction = false;
+
+ } catch (error) {
+ console.error('Transaction failed:', error);
+
+ // Report error
+ await apiCall('api/transaction/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentTransaction.id,
+ status: 'error',
+ error: error.message || 'Transaction failed'
+ })
+ });
+
+ // Show error as final state
+ const txContainer = document.getElementById('transaction-container');
+ if (txContainer) {
+ let errorMessage = error.message || 'Transaction failed';
+
+ // Handle common error cases with clearer messages
+ if (error.code === 4001) {
+ errorMessage = 'Transaction rejected by user';
+ } else if (error.code === -32002) {
+ errorMessage = 'Request already pending. Please check your wallet.';
+ } else if (error.code === -32603) {
+ errorMessage = 'Internal wallet error. Please try again.';
+ }
+
+ txContainer.innerHTML = `
+
+ ❌ ${errorMessage}
+
+ `;
+ }
+
+ // Reset state
+ currentTransaction = null;
+ isProcessingTransaction = false;
+ }
+}
+
+// Reject transaction
+async function rejectTransaction() {
+ if (!currentTransaction) return;
+
+ const actionsEl = document.getElementById('transaction-actions');
+ if (actionsEl) {
+ actionsEl.style.display = 'none';
+ }
+
+ updateTransactionStatus('Rejecting transaction...');
+
+ await apiCall('api/transaction/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentTransaction.id,
+ status: 'error',
+ error: 'User rejected transaction'
+ })
+ });
+
+ // Show rejection message as final state
+ const txContainer = document.getElementById('transaction-container');
+ if (txContainer) {
+ txContainer.innerHTML = `
+
+ ❌ Transaction rejected
+
+ `;
+ }
+
+ // Reset state
+ currentTransaction = null;
+ isProcessingTransaction = false;
+
+ // Don't hide the container - leave it as end state
+}
+
+// Process signing request
+async function processSigningRequest(req) {
+ if (isProcessingSigning) return;
+
+ // Prevent replay attacks
+ if (processedSigningRequests.has(req.id)) {
+ console.error('Signing request already processed:', req.id);
+ return;
+ }
+
+ // Validate connection before processing
+ if (!await validateConnection()) {
+ console.error('Connection validation failed');
+ return;
+ }
+
+ currentSigningRequest = req;
+ isProcessingSigning = true;
+ processedSigningRequests.add(req.id);
+
+ // Set timeout to clean up old request IDs after 5 minutes
+ setTimeout(() => {
+ processedSigningRequests.delete(req.id);
+ }, 300000);
+
+ try {
+ let signature;
+
+ // Handle both old format (string) and new format (enum serialized as snake_case)
+ const signType = req.sign_type || req.type;
+ if (signType === 'personal_sign' || signType === 'PersonalSign') {
+ // Personal sign - eth_sign equivalent
+ signature = await window.ethereum.request({
+ method: 'personal_sign',
+ params: [req.message || req.data, connectedAccount]
+ });
+ } else if (signType === 'sign_typed_data' || signType === 'SignTypedData') {
+ // EIP-712 typed data signing
+ const typedData = JSON.parse(req.message || req.data);
+ signature = await window.ethereum.request({
+ method: 'eth_signTypedData_v4',
+ params: [connectedAccount, req.message || req.data]
+ });
+ } else {
+ throw new Error(`Unknown signing type: ${signType}`);
+ }
+
+ // Report success
+ await apiCall('api/sign/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentSigningRequest.id,
+ status: 'success',
+ signature: signature
+ })
+ });
+
+ // Show success in UI
+ showSigningSuccess(signType);
+
+ } catch (error) {
+ console.error('Signing failed:', error);
+
+ // Report error
+ await apiCall('api/sign/response', {
+ method: 'POST',
+ body: JSON.stringify({
+ id: currentSigningRequest.id,
+ status: 'error',
+ error: error.message || 'Signing failed'
+ })
+ });
+
+ // Show error in UI
+ showSigningError(error.message || 'Signing failed');
+ }
+
+ // Reset state
+ currentSigningRequest = null;
+ isProcessingSigning = false;
+}
+
+// Show signing success
+function showSigningSuccess(type) {
+ const txContainer = document.getElementById('transaction-container');
+ if (txContainer) {
+ const messageType = (type === 'personal_sign' || type === 'PersonalSign') ? 'Message' : 'Typed data';
+ txContainer.innerHTML = `
+
+ ✅ ${messageType} signed successfully
+
+ `;
+ txContainer.style.display = 'block';
+ }
+}
+
+// Show signing error
+function showSigningError(error) {
+ const txContainer = document.getElementById('transaction-container');
+ if (txContainer) {
+ txContainer.innerHTML = `
+
+ ❌ ${error}
+
+ `;
+ txContainer.style.display = 'block';
+ }
+}
\ No newline at end of file
diff --git a/crates/browser-wallet/src/error.rs b/crates/browser-wallet/src/error.rs
new file mode 100644
index 0000000000000..f2103df581b93
--- /dev/null
+++ b/crates/browser-wallet/src/error.rs
@@ -0,0 +1,31 @@
+use alloy_signer::Error as SignerError;
+
+#[derive(Debug, thiserror::Error)]
+pub enum BrowserWalletError {
+ #[error("{operation} request timed out")]
+ Timeout { operation: &'static str },
+
+ #[error("{operation} rejected: {reason}")]
+ Rejected { operation: &'static str, reason: String },
+
+ #[error("Wallet not connected")]
+ NotConnected,
+
+ #[error("Server error: {0}")]
+ ServerError(String),
+
+ #[error("Chain mismatch: expected {expected}, got {actual}")]
+ ChainMismatch { expected: u64, actual: u64 },
+}
+
+impl From for SignerError {
+ fn from(err: BrowserWalletError) -> Self {
+ Self::other(err)
+ }
+}
+
+impl From for BrowserWalletError {
+ fn from(err: SignerError) -> Self {
+ Self::ServerError(err.to_string())
+ }
+}
diff --git a/crates/browser-wallet/src/lib.rs b/crates/browser-wallet/src/lib.rs
new file mode 100644
index 0000000000000..a7850f4168106
--- /dev/null
+++ b/crates/browser-wallet/src/lib.rs
@@ -0,0 +1,127 @@
+//! # Browser Wallet Support for Foundry
+//!
+//! This crate implements browser wallet integration following:
+//! - [EIP-1193](https://eips.ethereum.org/EIPS/eip-1193): Ethereum Provider JavaScript API
+//! - [EIP-712](https://eips.ethereum.org/EIPS/eip-712): Typed structured data hashing and signing
+//! - JSON-RPC 2.0 for communication protocol
+//!
+//! ## Architecture
+//!
+//! The implementation uses a local HTTP server to bridge between CLI and browser:
+//! 1. CLI starts local server and opens browser
+//! 2. Browser connects to MetaMask/ injected wallets via window.ethereum
+//! 3. Transactions are queued and processed asynchronously
+//! 4. Results are returned to CLI via polling
+//!
+//! ## Standards and References
+//!
+//! This implementation adheres to the following standards:
+//! - **EIP-1193**: Defines the JavaScript Ethereum Provider API that browser wallets expose
+//! - **EIP-712**: Specifies typed structured data hashing and signing
+//! - **JSON-RPC 2.0**: Communication protocol between the CLI and browser
+
+mod assets;
+mod error;
+mod server;
+mod signer;
+mod state;
+
+pub use error::BrowserWalletError;
+pub use server::BrowserWalletServer;
+pub use signer::BrowserSigner;
+
+use alloy_dyn_abi::TypedData;
+use alloy_primitives::{Address, Bytes, ChainId, B256};
+use alloy_rpc_types::TransactionRequest;
+use serde::{Deserialize, Serialize};
+
+/// Wallet connection information
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct WalletConnection {
+ pub address: Address,
+ pub chain_id: ChainId,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub wallet_name: Option,
+}
+
+/// Browser-specific transaction wrapper
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BrowserTransaction {
+ /// Unique ID for tracking in the browser
+ pub id: String,
+ /// Standard Alloy transaction request
+ #[serde(flatten)]
+ pub request: TransactionRequest,
+}
+
+/// Transaction response from the browser
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TransactionResponse {
+ pub id: String,
+ pub hash: Option,
+ pub error: Option,
+}
+
+/// Type of signature request
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum SignType {
+ PersonalSign,
+ SignTypedData,
+}
+
+/// Message signing request
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SignRequest {
+ pub id: String,
+ pub message: String,
+ pub address: Address,
+ #[serde(rename = "type")]
+ pub sign_type: SignType,
+}
+
+/// Message signing response
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct SignResponse {
+ pub id: String,
+ pub signature: Option,
+ pub error: Option,
+}
+
+/// Typed data signing request following EIP-712
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct TypedDataRequest {
+ pub id: String,
+ pub address: Address,
+ pub typed_data: TypedData,
+}
+
+/// Standard EIP-1193 provider interface
+/// Reference:
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "method", content = "params")]
+pub enum EthereumRequest {
+ #[serde(rename = "eth_requestAccounts")]
+ RequestAccounts,
+
+ #[serde(rename = "eth_sendTransaction")]
+ SendTransaction([TransactionRequest; 1]),
+
+ #[serde(rename = "personal_sign")]
+ PersonalSign(String, Address),
+
+ #[serde(rename = "eth_signTypedData_v4")]
+ SignTypedData(Address, TypedData),
+}
+
+/// Response wrapper for browser communication
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BrowserResponse {
+ pub success: bool,
+ pub data: Option,
+ pub error: Option,
+}
+
+#[cfg(test)]
+#[path = "tests.rs"]
+mod tests;
diff --git a/crates/browser-wallet/src/server.rs b/crates/browser-wallet/src/server.rs
new file mode 100644
index 0000000000000..7b552355b3a99
--- /dev/null
+++ b/crates/browser-wallet/src/server.rs
@@ -0,0 +1,467 @@
+use crate::{
+ state::BrowserWalletState, BrowserTransaction, BrowserWalletError, SignRequest, SignResponse,
+ TransactionResponse, TypedDataRequest, WalletConnection,
+};
+use alloy_dyn_abi::TypedData;
+use alloy_primitives::{Address, Bytes, B256};
+use axum::{
+ extract::State,
+ http::HeaderMap,
+ response::{Html, Json},
+ routing::{get, post},
+ Router,
+};
+use std::{net::SocketAddr, sync::Arc};
+use tokio::sync::{oneshot, Mutex};
+use tower::ServiceBuilder;
+use tower_http::{cors::CorsLayer, set_header::SetResponseHeaderLayer};
+
+/// Browser wallet HTTP server
+#[derive(Debug, Clone)]
+pub struct BrowserWalletServer {
+ port: u16,
+ state: Arc,
+ shutdown_tx: Option>>>>,
+}
+
+impl BrowserWalletServer {
+ /// Create a new browser wallet server
+ pub fn new(port: u16) -> Self {
+ Self { port, state: Arc::new(BrowserWalletState::new()), shutdown_tx: None }
+ }
+
+ /// Start the server and open browser
+ pub async fn start(&mut self) -> Result<(), BrowserWalletError> {
+ let router = self.create_router();
+
+ let addr = SocketAddr::from(([127, 0, 0, 1], self.port));
+ let listener = tokio::net::TcpListener::bind(addr)
+ .await
+ .map_err(|e| BrowserWalletError::ServerError(e.to_string()))?;
+
+ self.port = listener.local_addr().unwrap().port();
+
+ let (shutdown_tx, shutdown_rx) = oneshot::channel();
+ self.shutdown_tx = Some(Arc::new(Mutex::new(Some(shutdown_tx))));
+
+ tokio::spawn(async move {
+ let server = axum::serve(listener, router);
+ let _ = server
+ .with_graceful_shutdown(async {
+ let _ = shutdown_rx.await;
+ })
+ .await;
+ });
+
+ // Open browser (skip in test mode)
+ if std::env::var("BROWSER_WALLET_TEST_MODE").is_err() {
+ self.open_browser()?;
+ }
+
+ Ok(())
+ }
+
+ /// Get the server port
+ pub fn port(&self) -> u16 {
+ self.port
+ }
+
+ /// Stop the server
+ pub async fn stop(&mut self) -> Result<(), BrowserWalletError> {
+ if let Some(shutdown_arc) = self.shutdown_tx.take() {
+ if let Some(tx) = shutdown_arc.lock().await.take() {
+ let _ = tx.send(());
+ }
+ }
+ Ok(())
+ }
+
+ /// Check if a wallet is connected
+ pub fn is_connected(&self) -> bool {
+ self.state.get_connected_address().is_some()
+ }
+
+ /// Get current wallet connection
+ pub fn get_connection(&self) -> Option {
+ self.state.get_connected_address().map(|address| {
+ let chain_id = self.state.get_connected_chain_id().unwrap_or(31337); // Default to Anvil
+ WalletConnection {
+ address: address.parse().unwrap_or_default(),
+ chain_id,
+ wallet_name: None,
+ }
+ })
+ }
+
+ /// Request a transaction
+ pub async fn request_transaction(
+ &self,
+ request: BrowserTransaction,
+ ) -> Result {
+ // Check if wallet is connected
+ if self.state.get_connected_address().is_none() {
+ return Err(BrowserWalletError::NotConnected);
+ }
+
+ let tx_id = request.id.clone();
+
+ // Add to request queue
+ self.state.add_transaction_request(request);
+
+ // Wait for response (with timeout)
+ let timeout = if std::env::var("BROWSER_WALLET_TIMEOUT").is_ok() {
+ std::time::Duration::from_secs(
+ std::env::var("BROWSER_WALLET_TIMEOUT").unwrap_or_default().parse().unwrap_or(300),
+ )
+ } else {
+ std::time::Duration::from_secs(300) // 5 minutes default
+ };
+ let start = std::time::Instant::now();
+
+ loop {
+ // Check for response
+ if let Some(response) = self.state.get_transaction_response(&tx_id) {
+ if let Some(hash) = response.hash {
+ return Ok(hash);
+ } else if let Some(error) = response.error {
+ return Err(BrowserWalletError::Rejected {
+ operation: "Transaction",
+ reason: error,
+ });
+ } else {
+ return Err(BrowserWalletError::ServerError(
+ "Transaction response missing both hash and error".to_string(),
+ ));
+ }
+ }
+
+ // Check timeout
+ if start.elapsed() > timeout {
+ // Remove from queue
+ self.state.remove_transaction_request(&tx_id);
+ return Err(BrowserWalletError::Timeout { operation: "Transaction" });
+ }
+
+ // Sleep briefly
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ }
+ }
+
+ /// Request a message signature
+ pub async fn request_signing(&self, request: SignRequest) -> Result {
+ // Check if wallet is connected
+ if self.state.get_connected_address().is_none() {
+ return Err(BrowserWalletError::NotConnected);
+ }
+
+ let request_id = request.id.clone();
+
+ // Add to request queue
+ self.state.add_signing_request(request);
+
+ // Wait for response (with timeout)
+ let timeout = if std::env::var("BROWSER_WALLET_TIMEOUT").is_ok() {
+ std::time::Duration::from_secs(
+ std::env::var("BROWSER_WALLET_TIMEOUT").unwrap_or_default().parse().unwrap_or(300),
+ )
+ } else {
+ std::time::Duration::from_secs(300) // 5 minutes default
+ };
+ let start = std::time::Instant::now();
+
+ loop {
+ // Check for response
+ if let Some(response) = self.state.get_signing_response(&request_id) {
+ if let Some(signature) = response.signature {
+ return Ok(signature);
+ } else if let Some(error) = response.error {
+ return Err(BrowserWalletError::Rejected {
+ operation: "Signing",
+ reason: error,
+ });
+ } else {
+ return Err(BrowserWalletError::ServerError(
+ "Signing response missing both signature and error".to_string(),
+ ));
+ }
+ }
+
+ // Check timeout
+ if start.elapsed() > timeout {
+ // Remove from queue
+ self.state.remove_signing_request(&request_id);
+ return Err(BrowserWalletError::Timeout { operation: "Signing" });
+ }
+
+ // Sleep briefly
+ tokio::time::sleep(std::time::Duration::from_millis(100)).await;
+ }
+ }
+
+ /// Request typed data signature
+ pub async fn request_typed_data_signing(
+ &self,
+ address: Address,
+ typed_data: TypedData,
+ ) -> Result {
+ let request =
+ TypedDataRequest { id: uuid::Uuid::new_v4().to_string(), address, typed_data };
+
+ // For now, convert to regular sign request with JSON data
+ let sign_request = SignRequest {
+ id: request.id,
+ address: request.address,
+ message: serde_json::to_string(&request.typed_data)
+ .map_err(|e| BrowserWalletError::ServerError(e.to_string()))?,
+ sign_type: crate::SignType::SignTypedData,
+ };
+
+ self.request_signing(sign_request).await
+ }
+
+ /// Get the state for sharing with the signer
+ pub fn state(&self) -> Arc {
+ Arc::clone(&self.state)
+ }
+
+ /// Create the Axum router
+ fn create_router(&self) -> Router {
+ Router::new()
+ // Serve the main page
+ .route("/", get(serve_index))
+ // API endpoints
+ .route("/api/heartbeat", get(heartbeat))
+ .route("/api/transaction/pending", get(get_pending_transaction))
+ .route("/api/transaction/response", post(report_transaction_result))
+ .route("/api/sign/pending", get(get_pending_signing))
+ .route("/api/sign/response", post(report_signing_result))
+ .route("/api/network", get(get_network_details))
+ .route("/api/account", post(update_account_status))
+ // Serve static files
+ .route("/js/main.js", get(serve_main_js))
+ .route("/js/wallet.js", get(serve_wallet_js))
+ .route("/js/polling.js", get(serve_polling_js))
+ .route("/js/utils.js", get(serve_utils_js))
+ .route("/css/styles.css", get(serve_styles_css))
+ .layer(
+ ServiceBuilder::new()
+ // Security headers
+ .layer(SetResponseHeaderLayer::overriding(
+ axum::http::header::CONTENT_SECURITY_POLICY,
+ axum::http::HeaderValue::from_static(
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';"
+ ),
+ ))
+ .layer(SetResponseHeaderLayer::overriding(
+ axum::http::header::X_FRAME_OPTIONS,
+ axum::http::HeaderValue::from_static("DENY"),
+ ))
+ .layer(SetResponseHeaderLayer::overriding(
+ axum::http::header::X_CONTENT_TYPE_OPTIONS,
+ axum::http::HeaderValue::from_static("nosniff"),
+ ))
+ .layer(SetResponseHeaderLayer::overriding(
+ axum::http::header::REFERRER_POLICY,
+ axum::http::HeaderValue::from_static("no-referrer"),
+ ))
+ // Restrictive CORS - only allow same-origin
+ .layer(
+ CorsLayer::new()
+ .allow_origin(["http://localhost:*".parse().unwrap()])
+ .allow_methods([axum::http::Method::GET, axum::http::Method::POST])
+ .allow_headers([axum::http::header::CONTENT_TYPE])
+ .allow_credentials(true),
+ ),
+ )
+ .with_state(Arc::clone(&self.state))
+ }
+
+ /// Open the browser
+ fn open_browser(&self) -> Result<(), BrowserWalletError> {
+ // Skip browser opening in test mode
+ if std::env::var("BROWSER_WALLET_TEST_MODE").is_ok() {
+ return Ok(());
+ }
+
+ let url = format!("http://localhost:{}", self.port);
+
+ webbrowser::open(&url)
+ .map_err(|e| BrowserWalletError::ServerError(format!("Failed to open browser: {e}")))
+ }
+}
+
+// Handler functions
+
+async fn serve_index() -> impl axum::response::IntoResponse {
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ axum::http::header::CONTENT_TYPE,
+ axum::http::HeaderValue::from_static("text/html; charset=utf-8"),
+ );
+ (headers, Html(crate::assets::web::INDEX_HTML))
+}
+
+async fn serve_main_js() -> impl axum::response::IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE, "application/javascript")],
+ crate::assets::web::js::MAIN_JS,
+ )
+}
+
+async fn serve_wallet_js() -> impl axum::response::IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE, "application/javascript")],
+ crate::assets::web::js::WALLET_JS,
+ )
+}
+
+async fn serve_polling_js() -> impl axum::response::IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE, "application/javascript")],
+ crate::assets::web::js::POLLING_JS,
+ )
+}
+
+async fn serve_utils_js() -> impl axum::response::IntoResponse {
+ (
+ [(axum::http::header::CONTENT_TYPE, "application/javascript")],
+ crate::assets::web::js::UTILS_JS,
+ )
+}
+
+async fn serve_styles_css() -> impl axum::response::IntoResponse {
+ ([(axum::http::header::CONTENT_TYPE, "text/css")], crate::assets::web::css::STYLES_CSS)
+}
+
+async fn heartbeat(State(state): State>) -> Json {
+ state.update_heartbeat();
+ Json(serde_json::json!({"status": "alive"}))
+}
+
+async fn get_pending_transaction(
+ State(state): State>,
+) -> Json