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 +
+
+ + +
+ +
+ + + + + + + + +
+
+
+ + + + + + + \ 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> { + Json(state.get_pending_transaction()) +} + +async fn report_transaction_result( + State(state): State>, + Json(js_response): Json, +) -> Json { + // Parse the JavaScript response format + if let Some(id) = js_response.get("id").and_then(|v| v.as_str()) { + let status = js_response.get("status").and_then(|v| v.as_str()).unwrap_or(""); + + let response = if status == "success" { + if let Some(hash_str) = js_response.get("hash").and_then(|v| v.as_str()) { + // Try to parse the hash + if let Ok(hash) = hash_str.parse::() { + TransactionResponse { id: id.to_string(), hash: Some(hash), error: None } + } else { + TransactionResponse { + id: id.to_string(), + hash: None, + error: Some(format!("Invalid transaction hash: {hash_str}")), + } + } + } else { + TransactionResponse { + id: id.to_string(), + hash: None, + error: Some("No transaction hash provided".to_string()), + } + } + } else { + TransactionResponse { + id: id.to_string(), + hash: None, + error: js_response.get("error").and_then(|v| v.as_str()).map(String::from), + } + }; + + state.add_transaction_response(response); + } + + Json(serde_json::json!({"status": "ok"})) +} + +async fn get_pending_signing( + State(state): State>, +) -> Json> { + Json(state.get_pending_signing()) +} + +async fn report_signing_result( + State(state): State>, + Json(js_response): Json, +) -> Json { + // Parse the JavaScript response format + if let Some(id) = js_response.get("id").and_then(|v| v.as_str()) { + let status = js_response.get("status").and_then(|v| v.as_str()).unwrap_or(""); + + let response = if status == "success" { + if let Some(sig_str) = js_response.get("signature").and_then(|v| v.as_str()) { + // Parse the signature as hex bytes + if let Ok(sig_bytes) = hex::decode(sig_str.trim_start_matches("0x")) { + SignResponse { + id: id.to_string(), + signature: Some(sig_bytes.into()), + error: None, + } + } else { + SignResponse { + id: id.to_string(), + signature: None, + error: Some(format!("Invalid signature format: {sig_str}")), + } + } + } else { + SignResponse { + id: id.to_string(), + signature: None, + error: Some("No signature provided".to_string()), + } + } + } else { + SignResponse { + id: id.to_string(), + signature: None, + error: js_response.get("error").and_then(|v| v.as_str()).map(String::from), + } + }; + + state.add_signing_response(response); + } + + Json(serde_json::json!({"status": "ok"})) +} + +async fn get_network_details( + State(state): State>, +) -> Json { + // Return static Anvil network details for now + Json(serde_json::json!({ + "chain_id": state.get_connected_chain_id().unwrap_or(31337), + "network_name": "Anvil", + "rpc_url": "http://localhost:8545" + })) +} + +async fn update_account_status( + State(state): State>, + Json(body): Json, +) -> Json { + if let Some(address) = body.get("address").and_then(|a| a.as_str()) { + state.set_connected_address(Some(address.to_string())); + + // Update chain ID if provided + if let Some(chain_id) = body.get("chain_id").and_then(|c| c.as_u64()) { + state.set_connected_chain_id(Some(chain_id)); + } + } else if body.get("address").is_some() { + // Handle null address (disconnection) + state.set_connected_address(None); + } + + Json(serde_json::json!({"status": "ok"})) +} diff --git a/crates/browser-wallet/src/signer.rs b/crates/browser-wallet/src/signer.rs new file mode 100644 index 0000000000000..a0e90f1beafbd --- /dev/null +++ b/crates/browser-wallet/src/signer.rs @@ -0,0 +1,203 @@ +use crate::{BrowserTransaction, BrowserWalletServer, SignRequest}; +use alloy_consensus::SignableTransaction; +use alloy_dyn_abi::TypedData; +use alloy_network::TxSigner; +use alloy_primitives::{Address, ChainId, Signature, B256}; +use alloy_rpc_types::TransactionRequest; +use alloy_signer::{Result, Signer, SignerSync}; +use alloy_sol_types::{Eip712Domain, SolStruct}; +use async_trait::async_trait; +use foundry_common::sh_println; +use std::sync::Arc; +use tokio::sync::Mutex; + +/// Browser wallet signer that delegates signing to a connected browser wallet. +/// +/// This signer opens a local HTTP server and displays a web interface where users +/// can connect their browser wallet (MetaMask, WalletConnect browser extension, etc.) +/// to sign transactions. +/// +/// # Standards +/// - Follows EIP-1193 for Ethereum Provider JavaScript API +/// - Supports EIP-712 for typed data signing +#[derive(Clone, Debug)] +pub struct BrowserSigner { + server: Arc>, + address: Address, + chain_id: ChainId, +} + +impl BrowserSigner { + /// Create a new browser signer. + /// + /// This will start an HTTP server on the specified port and open a browser window + /// for wallet connection. The function will wait for the user to connect their wallet + /// before returning. + /// + /// # Arguments + /// * `port` - The port to run the HTTP server on (use 0 for automatic assignment) + pub async fn new(port: u16) -> Result { + let mut server = BrowserWalletServer::new(port); + + // Start the server + server.start().await.map_err(alloy_signer::Error::other)?; + + // Wait for wallet connection + let _ = sh_println!("\n🌐 Opening browser for wallet connection..."); + let _ = sh_println!("Waiting for wallet connection...\n"); + + // Poll for connection (timeout after 5 minutes) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(300); + + loop { + if let Some(connection) = server.get_connection() { + let _ = sh_println!("✅ Wallet connected: {}", connection.address); + let _ = sh_println!(" Chain ID: {}\n", connection.chain_id); + + return Ok(Self { + server: Arc::new(Mutex::new(server)), + address: connection.address, + chain_id: connection.chain_id, + }); + } + + if start.elapsed() > timeout { + return Err(alloy_signer::Error::other("Wallet connection timeout")); + } + + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + + /// Send a transaction through the browser wallet. + /// + /// This method is used by cast send when browser wallet is detected. + pub async fn send_transaction_via_browser( + &self, + tx_request: TransactionRequest, + ) -> Result { + let request = + BrowserTransaction { id: uuid::Uuid::new_v4().to_string(), request: tx_request }; + + let server = self.server.lock().await; + let tx_hash = + server.request_transaction(request).await.map_err(alloy_signer::Error::other)?; + + // Give the UI a moment to update before potentially shutting down + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + Ok(tx_hash) + } +} + +// Implement SignerSync trait as required by Alloy patterns +impl SignerSync for BrowserSigner { + fn sign_hash_sync(&self, _hash: &B256) -> Result { + Err(alloy_signer::Error::other( + "Browser wallets cannot sign raw hashes. Use sign_message or send_transaction instead.", + )) + } + + fn sign_message_sync(&self, _message: &[u8]) -> Result { + Err(alloy_signer::Error::other( + "Browser signer requires async operations. Use sign_message instead.", + )) + } + + fn chain_id_sync(&self) -> Option { + Some(self.chain_id) + } +} + +#[async_trait] +impl Signer for BrowserSigner { + async fn sign_hash(&self, _hash: &B256) -> Result { + // Browser wallets handle transaction signing differently + // They sign and send in one step via eth_sendTransaction + Err(alloy_signer::Error::other( + "Browser wallets sign and send transactions in one step. Use eth_sendTransaction instead." + )) + } + + async fn sign_message(&self, message: &[u8]) -> Result { + let request = SignRequest { + id: uuid::Uuid::new_v4().to_string(), + address: self.address, + message: format!("0x{}", hex::encode(message)), + sign_type: crate::SignType::PersonalSign, + }; + + let server = self.server.lock().await; + let signature = + server.request_signing(request).await.map_err(alloy_signer::Error::other)?; + + // Parse the signature + Signature::try_from(signature.as_ref()) + .map_err(|e| alloy_signer::Error::other(format!("Invalid signature: {e}"))) + } + + fn address(&self) -> Address { + self.address + } + + fn chain_id(&self) -> Option { + Some(self.chain_id) + } + + fn set_chain_id(&mut self, chain_id: Option) { + if let Some(id) = chain_id { + self.chain_id = id; + } + } + + async fn sign_typed_data( + &self, + _payload: &T, + _domain: &Eip712Domain, + ) -> Result + where + Self: Sized, + { + // Not directly supported - use sign_dynamic_typed_data instead + Err(alloy_signer::Error::other("Use sign_dynamic_typed_data for browser wallets")) + } + + async fn sign_dynamic_typed_data(&self, payload: &TypedData) -> Result { + let server = self.server.lock().await; + let signature = server + .request_typed_data_signing(self.address, payload.clone()) + .await + .map_err(alloy_signer::Error::other)?; + + // Parse the signature + Signature::try_from(signature.as_ref()) + .map_err(|e| alloy_signer::Error::other(format!("Invalid signature: {e}"))) + } +} + +#[async_trait] +impl TxSigner for BrowserSigner { + fn address(&self) -> Address { + self.address + } + + async fn sign_transaction( + &self, + _tx: &mut dyn SignableTransaction, + ) -> Result { + // Not used - browser wallets sign and send in one step + Err(alloy_signer::Error::other("Use send_transaction_via_browser for browser wallets")) + } +} + +impl Drop for BrowserSigner { + fn drop(&mut self) { + // Stop the server when the signer is dropped + let server = self.server.clone(); + tokio::spawn(async move { + let mut server = server.lock().await; + let _ = server.stop().await; + }); + } +} diff --git a/crates/browser-wallet/src/state.rs b/crates/browser-wallet/src/state.rs new file mode 100644 index 0000000000000..5ad6245b79814 --- /dev/null +++ b/crates/browser-wallet/src/state.rs @@ -0,0 +1,202 @@ +use crate::{BrowserTransaction, SignRequest, SignResponse, TransactionResponse}; +use parking_lot::Mutex; +use std::{ + collections::{HashMap, VecDeque}, + sync::Arc, + time::Instant, +}; + +/// Generic request/response queue for browser wallet operations +#[derive(Debug)] +pub struct RequestQueue { + /// Pending requests from CLI to browser + requests: VecDeque, + /// Responses from browser indexed by request ID + responses: HashMap, +} + +impl Default for RequestQueue { + fn default() -> Self { + Self::new() + } +} + +impl RequestQueue { + pub fn new() -> Self { + Self { requests: VecDeque::new(), responses: HashMap::new() } + } + + pub fn add_request(&mut self, request: Req) { + self.requests.push_back(request); + } + + pub fn get_pending(&self) -> Option<&Req> { + self.requests.front() + } + + pub fn remove_request(&mut self, id: &str) -> Option + where + Req: HasId, + { + if let Some(pos) = self.requests.iter().position(|r| r.id() == id) { + self.requests.remove(pos) + } else { + None + } + } + + pub fn add_response(&mut self, id: String, response: Res) { + self.responses.insert(id, response); + } + + pub fn get_response(&mut self, id: &str) -> Option { + self.responses.remove(id) + } +} + +/// Trait for types that have an ID +pub trait HasId { + fn id(&self) -> &str; +} + +impl HasId for BrowserTransaction { + fn id(&self) -> &str { + &self.id + } +} + +impl HasId for SignRequest { + fn id(&self) -> &str { + &self.id + } +} + +/// Connection information +#[derive(Debug, Clone, Default)] +pub struct ConnectionInfo { + pub address: Option, + pub chain_id: Option, +} + +/// Simplified browser wallet state +#[derive(Debug, Clone)] +pub struct BrowserWalletState { + /// Transaction request/response queue + pub transactions: Arc>>, + /// Signing request/response queue + pub signing: Arc>>, + /// Current connection info + pub connection: Arc>, + /// Last heartbeat timestamp + pub last_heartbeat: Arc>, +} + +impl Default for BrowserWalletState { + fn default() -> Self { + Self::new() + } +} + +impl BrowserWalletState { + pub fn new() -> Self { + Self { + transactions: Arc::new(Mutex::new(RequestQueue::new())), + signing: Arc::new(Mutex::new(RequestQueue::new())), + connection: Arc::new(Mutex::new(ConnectionInfo::default())), + last_heartbeat: Arc::new(Mutex::new(Instant::now())), + } + } + + /// Add a transaction request + pub fn add_transaction_request(&self, request: BrowserTransaction) { + self.transactions.lock().add_request(request); + } + + /// Get pending transaction + pub fn get_pending_transaction(&self) -> Option { + self.transactions.lock().get_pending().cloned() + } + + /// Remove transaction request + pub fn remove_transaction_request(&self, id: &str) { + self.transactions.lock().remove_request(id); + } + + /// Add transaction response + pub fn add_transaction_response(&self, response: TransactionResponse) { + let id = response.id.clone(); + // Add to responses first (before removing from queue to avoid race) + self.transactions.lock().add_response(id.clone(), response); + // Then remove from request queue + self.remove_transaction_request(&id); + } + + /// Get transaction response + pub fn get_transaction_response(&self, id: &str) -> Option { + self.transactions.lock().get_response(id) + } + + /// Add a signing request + pub fn add_signing_request(&self, request: SignRequest) { + self.signing.lock().add_request(request); + } + + /// Get pending signing request + pub fn get_pending_signing(&self) -> Option { + self.signing.lock().get_pending().cloned() + } + + /// Remove signing request + pub fn remove_signing_request(&self, id: &str) { + self.signing.lock().remove_request(id); + } + + /// Add signing response + pub fn add_signing_response(&self, response: SignResponse) { + let id = response.id.clone(); + // Add to responses first (before removing from queue to avoid race) + self.signing.lock().add_response(id.clone(), response); + // Then remove from request queue + self.remove_signing_request(&id); + } + + /// Get signing response + pub fn get_signing_response(&self, id: &str) -> Option { + self.signing.lock().get_response(id) + } + + /// Update heartbeat + pub fn update_heartbeat(&self) { + *self.last_heartbeat.lock() = Instant::now(); + } + + /// Check if wallet is connected + pub fn is_connected(&self) -> bool { + self.connection.lock().address.is_some() + } + + /// Set connected address + pub fn set_connected_address(&self, address: Option) { + let mut conn = self.connection.lock(); + conn.address = address; + // Clear chain ID if disconnecting + if conn.address.is_none() { + conn.chain_id = None; + } + } + + /// Set connected chain ID + pub fn set_connected_chain_id(&self, chain_id: Option) { + self.connection.lock().chain_id = chain_id; + } + + /// Get connected address + pub fn get_connected_address(&self) -> Option { + self.connection.lock().address.clone() + } + + /// Get connected chain ID + pub fn get_connected_chain_id(&self) -> Option { + self.connection.lock().chain_id + } +} diff --git a/crates/browser-wallet/src/tests.rs b/crates/browser-wallet/src/tests.rs new file mode 100644 index 0000000000000..868d7505ddd46 --- /dev/null +++ b/crates/browser-wallet/src/tests.rs @@ -0,0 +1,120 @@ +#[cfg(test)] +use crate::{state::BrowserWalletState, BrowserTransaction, TransactionResponse, WalletConnection}; +#[cfg(test)] +use alloy_primitives::{address, B256, U256}; +#[cfg(test)] +use alloy_rpc_types::TransactionRequest; + +#[test] +fn test_browser_transaction_serialization() { + let tx = BrowserTransaction { + id: "test-123".to_string(), + request: TransactionRequest { + from: Some(address!("0000000000000000000000000000000000000001")), + to: Some(address!("0000000000000000000000000000000000000002").into()), + value: Some(U256::from(1000)), + ..Default::default() + }, + }; + + let json = serde_json::to_string_pretty(&tx).unwrap(); + + let deserialized: BrowserTransaction = serde_json::from_str(&json).unwrap(); + + assert_eq!(tx.id, deserialized.id); + assert_eq!(tx.request.from, deserialized.request.from); + assert_eq!(tx.request.to, deserialized.request.to); + assert_eq!(tx.request.value, deserialized.request.value); +} + +#[test] +fn test_wallet_connection() { + let connection = WalletConnection { + address: address!("0000000000000000000000000000000000000001"), + chain_id: 1, + wallet_name: Some("MetaMask".to_string()), + }; + + let json = serde_json::to_string(&connection).unwrap(); + let deserialized: WalletConnection = serde_json::from_str(&json).unwrap(); + + assert_eq!(connection.address, deserialized.address); + assert_eq!(connection.chain_id, deserialized.chain_id); + assert_eq!(connection.wallet_name, deserialized.wallet_name); +} + +#[test] +fn test_transaction_hex_serialization() { + use alloy_primitives::U256; + + let tx = TransactionRequest { + from: Some(address!("0000000000000000000000000000000000000001")), + to: Some(address!("0000000000000000000000000000000000000002").into()), + value: Some(U256::from(1_000_000_000_000_000_000u64)), // 1 ETH + chain_id: Some(31337), + ..Default::default() + }; + + let browser_tx = BrowserTransaction { id: "test-1".to_string(), request: tx }; + + let json = serde_json::to_string_pretty(&browser_tx).unwrap(); + + // Check that hex values are properly formatted + assert!(json.contains("\"value\": \"0x")); + assert!(json.contains("\"chainId\": \"0x")); + + // Ensure no double hex encoding + assert!(!json.contains("\"0x0x")); + assert!(!json.contains("0x0x0x")); +} + +#[test] +fn test_transaction_with_empty_data() { + use alloy_primitives::U256; + + let tx = TransactionRequest { + from: Some(address!("0000000000000000000000000000000000000001")), + to: Some(address!("0000000000000000000000000000000000000002").into()), + value: Some(U256::ZERO), + chain_id: Some(31337), + ..Default::default() + }; + // tx.data is None by default + + let browser_tx = BrowserTransaction { id: "test-2".to_string(), request: tx }; + + let json = serde_json::to_string_pretty(&browser_tx).unwrap(); + + // Check that empty values are handled correctly + assert!(json.contains("\"value\": \"0x0\"")); + // data should be absent if None + assert!(!json.contains("\"data\"") || json.contains("\"data\": null")); +} + +#[tokio::test] +async fn test_state_management() { + let state = BrowserWalletState::new(); + + // Test wallet connection + assert!(state.get_connected_address().is_none()); + + state.set_connected_address(Some("0x0000000000000000000000000000000000000001".to_string())); + assert_eq!( + state.get_connected_address(), + Some("0x0000000000000000000000000000000000000001".to_string()) + ); + + // Test transaction queue + let tx = BrowserTransaction { id: "tx-1".to_string(), request: TransactionRequest::default() }; + + state.add_transaction_request(tx); + assert_eq!(state.get_pending_transaction().unwrap().id, "tx-1"); + + // Test response handling + let response = + TransactionResponse { id: "tx-1".to_string(), hash: Some(B256::default()), error: None }; + + state.add_transaction_response(response); + assert!(state.get_pending_transaction().is_none()); + assert!(state.get_transaction_response("tx-1").is_some()); +} diff --git a/crates/browser-wallet/tests/integration/connection.rs b/crates/browser-wallet/tests/integration/connection.rs new file mode 100644 index 0000000000000..db29c57440cba --- /dev/null +++ b/crates/browser-wallet/tests/integration/connection.rs @@ -0,0 +1,167 @@ +use crate::integration::utils::TestWallet; + +#[tokio::test] +async fn test_server_startup() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + + // Verify server is healthy + assert!(wallet.health_check().await?); + + // Verify network details are available + let network = wallet.get_network_details().await?; + assert_eq!(network["chain_id"], 31337); + assert_eq!(network["network_name"], "Anvil"); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_wallet_connection() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Initially no wallet should be connected + assert!(wallet.server.get_connection().is_none()); + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Verify connection + let connection = wallet.server.get_connection(); + assert!(connection.is_some()); + let conn = connection.unwrap(); + assert_eq!(conn.address.to_string().to_lowercase(), test_address.to_lowercase()); + assert_eq!(conn.chain_id, 31337); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_wallet_disconnection() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + assert!(wallet.server.is_connected()); + + // Disconnect wallet + wallet.disconnect().await?; + + // Verify disconnection + assert!(!wallet.server.is_connected()); + assert!(wallet.server.get_connection().is_none()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_chain_id_update() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect to chain 31337 + wallet.connect(test_address, 31337).await?; + + let connection = wallet.server.get_connection().unwrap(); + assert_eq!(connection.chain_id, 31337); + + // Update to mainnet + wallet.connect(test_address, 1).await?; + + let connection = wallet.server.get_connection().unwrap(); + assert_eq!(connection.chain_id, 1); + assert_eq!(connection.address.to_string().to_lowercase(), test_address.to_lowercase()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_multiple_connections() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let address1 = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + let address2 = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; + + // Connect first address + wallet.connect(address1, 31337).await?; + let connection = wallet.server.get_connection().unwrap(); + assert_eq!(connection.address.to_string().to_lowercase(), address1.to_lowercase()); + + // Connect second address (should replace first) + wallet.connect(address2, 31337).await?; + let connection = wallet.server.get_connection().unwrap(); + assert_eq!(connection.address.to_string().to_lowercase(), address2.to_lowercase()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_concurrent_connections() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let addresses = [ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + ]; + + // Attempt concurrent connections + let mut handles = vec![]; + for (i, addr) in addresses.iter().enumerate() { + let wallet_url = wallet.base_url.clone(); + let client = wallet.client.clone(); + let address = addr.to_string(); + let chain_id = 31337 + i as u64; + + let handle = tokio::spawn(async move { + client + .post(format!("{wallet_url}/api/account")) + .json(&serde_json::json!({ + "address": address, + "chain_id": chain_id + })) + .send() + .await + }); + handles.push(handle); + } + + // Wait for all connections + for handle in handles { + let response = handle.await??; + assert!(response.status().is_success()); + } + + // Only the last connection should be active + let connection = wallet.server.get_connection(); + assert!(connection.is_some()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_invalid_address_format() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + + // Try to connect with invalid address + let response = wallet + .client + .post(format!("{}/api/account", wallet.base_url)) + .json(&serde_json::json!({ + "address": "invalid_address", + "chain_id": 31337 + })) + .send() + .await?; + + // Should still succeed at API level (validation happens client-side) + assert!(response.status().is_success()); + + wallet.shutdown().await?; + Ok(()) +} diff --git a/crates/browser-wallet/tests/integration/mod.rs b/crates/browser-wallet/tests/integration/mod.rs new file mode 100644 index 0000000000000..a474864aeae2f --- /dev/null +++ b/crates/browser-wallet/tests/integration/mod.rs @@ -0,0 +1,7 @@ +pub mod utils; + +pub mod connection; +pub mod persistence; +pub mod security; +pub mod signing; +pub mod transaction; diff --git a/crates/browser-wallet/tests/integration/persistence.rs b/crates/browser-wallet/tests/integration/persistence.rs new file mode 100644 index 0000000000000..2947f03227948 --- /dev/null +++ b/crates/browser-wallet/tests/integration/persistence.rs @@ -0,0 +1,241 @@ +use crate::integration::utils::{create_test_transaction, wait_for, TestWallet}; +use std::time::Duration; + +#[tokio::test] +async fn test_connection_persistence_across_restarts() -> Result<(), Box> { + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + let chain_id = 31337; + + // First session - connect wallet + { + let wallet = TestWallet::spawn().await?; + + // Connect wallet + wallet.connect(test_address, chain_id).await?; + + // Verify connection + assert!(wallet.server.is_connected()); + let conn = wallet.server.get_connection().unwrap(); + assert_eq!(conn.address.to_string().to_lowercase(), test_address.to_lowercase()); + + // Shutdown server (simulating browser close) + wallet.shutdown().await?; + } + + // Second session - should auto-reconnect + { + let wallet = TestWallet::spawn().await?; + + // The frontend would normally handle auto-reconnection + // For testing, we simulate the frontend checking stored state + // In a real scenario, the JS would read localStorage and reconnect + + // Initially not connected (server side doesn't persist) + assert!(!wallet.server.is_connected()); + + // Simulate frontend auto-reconnection + wallet.connect(test_address, chain_id).await?; + + // Verify reconnection + assert!(wallet.server.is_connected()); + + wallet.shutdown().await?; + } + + Ok(()) +} + +#[tokio::test] +async fn test_chain_switch_persistence() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect to Anvil + wallet.connect(test_address, 31337).await?; + assert_eq!(wallet.server.get_connection().unwrap().chain_id, 31337); + + // Switch to mainnet + wallet.connect(test_address, 1).await?; + assert_eq!(wallet.server.get_connection().unwrap().chain_id, 1); + + // Switch to Polygon + wallet.connect(test_address, 137).await?; + assert_eq!(wallet.server.get_connection().unwrap().chain_id, 137); + + // Address should remain the same + assert_eq!( + wallet.server.get_connection().unwrap().address.to_string().to_lowercase(), + test_address.to_lowercase() + ); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_multiple_account_switches() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let accounts = vec![ + "0x70997970C51812dc3A010C7d01b50e0d17dc79C8", + "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC", + "0x90F79bf6EB2c4f870365E785982E1f101E93b906", + ]; + + // Switch between accounts + for account in &accounts { + wallet.connect(account, 31337).await?; + + let connection = wallet.server.get_connection().unwrap(); + assert_eq!(connection.address.to_string().to_lowercase(), account.to_lowercase()); + assert_eq!(connection.chain_id, 31337); + + // Small delay to simulate user interaction + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Final connection should be the last account + let final_conn = wallet.server.get_connection().unwrap(); + assert_eq!( + final_conn.address.to_string().to_lowercase(), + accounts.last().unwrap().to_lowercase() + ); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_connection_state_during_transactions() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Submit transaction + let tx = create_test_transaction( + "state-test", + alloy_primitives::address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + alloy_primitives::address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + alloy_primitives::U256::from(1000), + ); + + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { wallet_server.request_transaction(tx).await }); + + // Connection should remain stable during transaction + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + // Verify connection is maintained + assert!(wallet.server.is_connected()); + assert_eq!( + wallet.server.get_connection().unwrap().address.to_string().to_lowercase(), + test_address.to_lowercase() + ); + + // Complete transaction + wallet + .report_transaction_result(foundry_browser_wallet::TransactionResponse { + id: "state-test".to_string(), + hash: Some(alloy_primitives::B256::random()), + error: None, + }) + .await?; + + handle.await??; + + // Connection should still be active + assert!(wallet.server.is_connected()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_disconnection_clears_state() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + assert!(wallet.server.is_connected()); + + // Submit a transaction + let tx = create_test_transaction( + "disconnect-test", + alloy_primitives::address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + alloy_primitives::address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + alloy_primitives::U256::from(1000), + ); + + let wallet_server = wallet.server.clone(); + let tx_handle = tokio::spawn(async move { wallet_server.request_transaction(tx).await }); + + // Wait for transaction to be pending + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + // Disconnect wallet + wallet.disconnect().await?; + + // Verify disconnection + assert!(!wallet.server.is_connected()); + assert!(wallet.server.get_connection().is_none()); + + // Transaction should fail or be cancelled + // Report error to complete the flow + wallet + .report_transaction_result(foundry_browser_wallet::TransactionResponse { + id: "disconnect-test".to_string(), + hash: None, + error: Some("Wallet disconnected".to_string()), + }) + .await?; + + let result = tx_handle.await?; + assert!(result.is_err()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_rapid_connect_disconnect_cycles() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Perform rapid connect/disconnect cycles + for _i in 0..5 { + // Connect + wallet.connect(test_address, 31337).await?; + assert!(wallet.server.is_connected()); + + // Small operation + let network = wallet.get_network_details().await?; + assert_eq!(network["chain_id"], 31337); + + // Disconnect + wallet.disconnect().await?; + assert!(!wallet.server.is_connected()); + + // Minimal delay + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // Final state should be disconnected + assert!(!wallet.server.is_connected()); + + wallet.shutdown().await?; + Ok(()) +} diff --git a/crates/browser-wallet/tests/integration/security.rs b/crates/browser-wallet/tests/integration/security.rs new file mode 100644 index 0000000000000..dfb6675f66a58 --- /dev/null +++ b/crates/browser-wallet/tests/integration/security.rs @@ -0,0 +1,302 @@ +use crate::integration::utils::{create_test_signing_request, create_test_transaction, TestWallet}; +use alloy_primitives::{address, U256}; +use foundry_browser_wallet::BrowserTransaction; +use std::{collections::HashSet, sync::Arc}; +use tokio::sync::Mutex; + +#[tokio::test] +async fn test_transaction_replay_prevention() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create a transaction + let tx_id = "replay-test-123"; + let from = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let to = address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + let value = U256::from(1_000_000_000_000_000_000u64); // 1 ETH + + let tx = create_test_transaction(tx_id, from, to, value); + + // Submit transaction first time + let handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + let tx_clone = tx.clone(); + async move { wallet_server.request_transaction(tx_clone).await } + }); + + // Wait a bit for first transaction to be queued + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + // Simulate frontend polling and approve the transaction + crate::integration::utils::simulate_transaction_polling(&wallet, true).await?; + + // Wait for first handle + let first_result = handle.await?; + assert!(first_result.is_ok()); + + // Try to submit the same transaction again (replay attack) + // This should either succeed (if implementation allows duplicates) or fail gracefully + let replay_handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + async move { wallet_server.request_transaction(tx).await } + }); + + // Give it a moment + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Poll again - might find the duplicate or nothing + let _ = crate::integration::utils::simulate_transaction_polling(&wallet, true).await; + + // The replay attempt should complete (either successfully or with error) + let _ = replay_handle.await; + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_signing_replay_prevention() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create a signing request + let request_id = "sign-replay-test"; + let request = create_test_signing_request(request_id, "Test message"); + + // Submit signing request + let handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + let req_clone = request.clone(); + async move { wallet_server.request_signing(req_clone).await } + }); + + // Wait a bit + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + + // Simulate frontend polling and approve + crate::integration::utils::simulate_signing_polling(&wallet, true).await?; + + let first_result = handle.await?; + assert!(first_result.is_ok()); + + // Try to submit the same request again + let replay_handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + async move { wallet_server.request_signing(request).await } + }); + + // Give it a moment + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Poll again + let _ = crate::integration::utils::simulate_signing_polling(&wallet, true).await; + + // The replay attempt should complete + let _ = replay_handle.await; + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_connection_hijacking_protection() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let legitimate_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + let attacker_address = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; + + // Connect with legitimate address + wallet.connect(legitimate_address, 31337).await?; + + // Create transaction from legitimate address + let from = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let to = address!("90F79bf6EB2c4f870365E785982E1f101E93b906"); + let tx = create_test_transaction("hijack-test", from, to, U256::from(1000)); + + // Submit transaction + let tx_handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + let tx_clone = tx.clone(); + async move { wallet_server.request_transaction(tx_clone).await } + }); + + // Wait for transaction to be queued + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Attacker tries to connect and hijack + wallet.connect(attacker_address, 31337).await?; + + // The original transaction should still be processed + // Simulate frontend polling + crate::integration::utils::simulate_transaction_polling(&wallet, true).await?; + + let result = tx_handle.await?; + // The transaction might succeed or fail based on security policy + let _ = result; // Don't assert, just ensure it completes + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_unauthorized_transaction_rejection() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let connected_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + let _unauthorized_address = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"; + + // Connect wallet with one address + wallet.connect(connected_address, 31337).await?; + + // Try to submit transaction from different address + let from = address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); // Different from connected + let to = address!("90F79bf6EB2c4f870365E785982E1f101E93b906"); + let tx = create_test_transaction("unauth-test", from, to, U256::from(1000)); + + // Submit the transaction + let handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + async move { wallet_server.request_transaction(tx).await } + }); + + // Wait a bit + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Simulate frontend polling - it should reject due to address mismatch + crate::integration::utils::simulate_transaction_polling(&wallet, false).await?; + + // The transaction should fail + let result = handle.await?; + assert!(result.is_err()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_concurrent_transaction_safety() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + let processed_ids = Arc::new(Mutex::new(HashSet::new())); + let mut handles = vec![]; + + // Submit multiple transactions concurrently + for i in 0..10 { + let tx_id = format!("concurrent-tx-{i}"); + let from = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let to = address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + let value = U256::from(1000 * i); + + let tx = create_test_transaction(&tx_id, from, to, value); + + let wallet_server = wallet.server.clone(); + let processed = processed_ids.clone(); + + let handle = tokio::spawn(async move { + // Submit transaction + let result = wallet_server.request_transaction(tx).await; + if result.is_ok() { + let mut ids = processed.lock().await; + ids.insert(tx_id); + } + result.map(|_| ()).map_err(|e| Box::new(e) as Box) + }); + + handles.push(handle); + } + + // Spawn a separate task to handle all the frontend polling + let base_url = wallet.base_url.clone(); + let client = wallet.client.clone(); + + let polling_handle = tokio::spawn(async move { + for _ in 0..10 { + // Poll for pending transaction + let response = client.get(format!("{base_url}/api/transaction/pending")).send().await; + + if let Ok(resp) = response { + if resp.status().is_success() { + if let Ok(text) = resp.text().await { + if text.trim() != "null" && !text.is_empty() { + if let Ok(tx) = serde_json::from_str::(&text) { + // Auto-approve the transaction + let js_response = serde_json::json!({ + "id": tx.id, + "status": "success", + "hash": format!("0x{}", hex::encode(alloy_primitives::B256::random())) + }); + + let _ = client + .post(format!("{base_url}/api/transaction/response")) + .json(&js_response) + .send() + .await; + } + } + } + } + } + + tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; + } + Ok::<(), Box>(()) + }); + + // Wait for all transactions + for handle in handles { + let _ = handle.await?; + } + + let _ = polling_handle.await?; + + // Verify all transactions were processed + let ids = processed_ids.lock().await; + assert!(!ids.is_empty(), "At least some transactions should be processed"); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_malformed_transaction_handling() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create transaction with missing required fields + let mut tx = + BrowserTransaction { id: "malformed-test".to_string(), request: Default::default() }; + + // No from address + tx.request.to = Some(address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC").into()); + tx.request.value = Some(U256::from(1000)); + + // Submit malformed transaction + let handle = tokio::spawn({ + let wallet_server = wallet.server.clone(); + async move { wallet_server.request_transaction(tx).await } + }); + + // Wait a bit + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Simulate frontend polling - should handle malformed tx gracefully + let _ = crate::integration::utils::simulate_transaction_polling(&wallet, false).await; + + // The request should timeout or error + let result = handle.await?; + assert!(result.is_err() || result.is_ok()); // Either behavior is acceptable + + wallet.shutdown().await?; + Ok(()) +} diff --git a/crates/browser-wallet/tests/integration/signing.rs b/crates/browser-wallet/tests/integration/signing.rs new file mode 100644 index 0000000000000..37690cc68d1de --- /dev/null +++ b/crates/browser-wallet/tests/integration/signing.rs @@ -0,0 +1,250 @@ +use crate::integration::utils::{create_test_signing_request, wait_for, TestWallet}; +use alloy_primitives::{address, Bytes}; +use foundry_browser_wallet::{SignRequest, SignResponse, SignType}; +use std::time::Duration; + +#[tokio::test] +async fn test_personal_sign_flow() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create signing request + let request = SignRequest { + id: "personal-sign-test".to_string(), + address: address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + message: "Hello, Foundry!".to_string(), + sign_type: SignType::PersonalSign, + }; + + // Submit signing request + let wallet_server = wallet.server.clone(); + let req_clone = request.clone(); + let handle = tokio::spawn(async move { wallet_server.request_signing(req_clone).await }); + + // Wait for pending signing request + wait_for( + || async { wallet.get_pending_signing().await.map(|opt| opt.is_some()).unwrap_or(false) }, + Duration::from_secs(5), + ) + .await?; + + // Verify request details + let pending = wallet.get_pending_signing().await?.unwrap(); + assert_eq!(pending.id, "personal-sign-test"); + assert_eq!(pending.message, "Hello, Foundry!"); + assert_eq!(pending.sign_type, SignType::PersonalSign); + + // Approve signing + let signature = Bytes::from(vec![0xde, 0xad, 0xbe, 0xef]); + wallet + .report_signing_result(SignResponse { + id: "personal-sign-test".to_string(), + signature: Some(signature.clone()), + error: None, + }) + .await?; + + // Wait for completion + let result = handle.await??; + assert_eq!(result, signature); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_typed_data_sign_flow() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create EIP-712 typed data request + let typed_data = r#"{ + "domain": { + "name": "Test", + "version": "1", + "chainId": 31337 + }, + "types": { + "Message": [ + {"name": "content", "type": "string"} + ] + }, + "message": { + "content": "Test message" + } + }"#; + + let request = SignRequest { + id: "typed-data-test".to_string(), + address: address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + message: typed_data.to_string(), + sign_type: SignType::SignTypedData, + }; + + // Submit and process + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { wallet_server.request_signing(request).await }); + + wait_for( + || async { wallet.get_pending_signing().await.map(|opt| opt.is_some()).unwrap_or(false) }, + Duration::from_secs(5), + ) + .await?; + + // Approve + wallet + .report_signing_result(SignResponse { + id: "typed-data-test".to_string(), + signature: Some(Bytes::from(vec![0xaa, 0xbb, 0xcc, 0xdd])), + error: None, + }) + .await?; + + handle.await??; + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_signing_rejection() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create signing request + let request = create_test_signing_request("reject-test", "Reject me!"); + + // Submit request + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { wallet_server.request_signing(request).await }); + + // Wait for pending + wait_for( + || async { wallet.get_pending_signing().await.map(|opt| opt.is_some()).unwrap_or(false) }, + Duration::from_secs(5), + ) + .await?; + + // Reject signing + wallet + .report_signing_result(SignResponse { + id: "reject-test".to_string(), + signature: None, + error: Some("User rejected signing".to_string()), + }) + .await?; + + // Verify rejection + let result = handle.await?; + assert!(result.is_err()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_concurrent_signing_requests() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Submit multiple signing requests + let mut handles = vec![]; + for i in 0..5 { + let id = format!("concurrent-sign-{i}"); + let message = format!("Message {i}"); + let request = SignRequest { + id: id.clone(), + address: address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + message, + sign_type: SignType::PersonalSign, + }; + + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { wallet_server.request_signing(request).await }); + handles.push((id, handle)); + } + + // Process each signing request + for (id, _) in &handles { + // Wait for pending request + wait_for( + || async { + wallet.get_pending_signing().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + // Approve + wallet + .report_signing_result(SignResponse { + id: id.clone(), + signature: Some(Bytes::from(format!("sig-{id}").into_bytes())), + error: None, + }) + .await?; + + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Verify all completed + for (_, handle) in handles { + handle.await??; + } + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_signing_with_disconnected_wallet() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + + // Try to sign without connecting first + let request = create_test_signing_request("no-connect", "Should fail"); + + // This should be handled gracefully + let _result = wallet.server.request_signing(request).await; + + // The behavior depends on implementation - might queue or reject + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_signing_timeout() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create signing request + let request = create_test_signing_request("timeout-sign", "Timeout test"); + + // Submit with timeout + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { + tokio::time::timeout(Duration::from_secs(2), wallet_server.request_signing(request)).await + }); + + // Don't respond, let it timeout + let result = handle.await?; + assert!(result.is_err() || result.unwrap().is_err()); + + wallet.shutdown().await?; + Ok(()) +} diff --git a/crates/browser-wallet/tests/integration/transaction.rs b/crates/browser-wallet/tests/integration/transaction.rs new file mode 100644 index 0000000000000..1d9e7c2a0f260 --- /dev/null +++ b/crates/browser-wallet/tests/integration/transaction.rs @@ -0,0 +1,326 @@ +use crate::integration::utils::{create_test_transaction, wait_for, TestWallet}; +use alloy_primitives::{address, Bytes, B256, U256}; +use alloy_rpc_types::TransactionRequest; +use foundry_browser_wallet::{BrowserTransaction, TransactionResponse}; +use std::time::Duration; + +#[tokio::test] +async fn test_transaction_approval_flow() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create transaction + let from = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let to = address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + let value = U256::from(1_000_000_000_000_000_000u64); // 1 ETH + let tx = create_test_transaction("approval-test", from, to, value); + + // Submit transaction in background + let wallet_server = wallet.server.clone(); + let tx_clone = tx.clone(); + let handle = tokio::spawn(async move { wallet_server.request_transaction(tx_clone).await }); + + // Simulate frontend polling for pending transaction + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + // Verify transaction details + if let Some(pending_tx) = wallet.get_pending_transaction().await? { + assert_eq!(pending_tx.id, "approval-test"); + assert_eq!(pending_tx.request.from, Some(from)); + assert_eq!(pending_tx.request.to, Some(to.into())); + assert_eq!(pending_tx.request.value, Some(value)); + } else { + panic!("Expected pending transaction"); + } + + // Approve transaction + let tx_hash = B256::random(); + wallet + .report_transaction_result(TransactionResponse { + id: "approval-test".to_string(), + hash: Some(tx_hash), + error: None, + }) + .await?; + + // Wait for transaction completion + let result = handle.await??; + assert_eq!(result, tx_hash); + + // Verify no pending transaction remains + assert!(wallet.get_pending_transaction().await?.is_none()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_transaction_rejection_flow() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create transaction + let tx = create_test_transaction( + "rejection-test", + address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + U256::from(1000), + ); + + // Submit transaction + let wallet_server = wallet.server.clone(); + let tx_clone = tx.clone(); + let handle = tokio::spawn(async move { wallet_server.request_transaction(tx_clone).await }); + + // Wait for pending transaction + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + // Reject transaction + wallet + .report_transaction_result(TransactionResponse { + id: "rejection-test".to_string(), + hash: None, + error: Some("User rejected transaction".to_string()), + }) + .await?; + + // Verify rejection + let result = handle.await?; + assert!(result.is_err()); + if let Err(e) = result { + assert!(e.to_string().contains("User rejected")); + } + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_transaction_with_data() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create transaction with contract data + let tx = BrowserTransaction { + id: "data-test".to_string(), + request: TransactionRequest { + from: Some(address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")), + to: Some(address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC").into()), + value: Some(U256::ZERO), + input: alloy_rpc_types::TransactionInput::new(Bytes::from(vec![ + 0x12, 0x34, 0x56, 0x78, + ])), + gas: Some(100000), + ..Default::default() + }, + }; + + // Submit transaction + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { wallet_server.request_transaction(tx).await }); + + // Wait and check pending transaction + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + let pending = wallet.get_pending_transaction().await?.unwrap(); + // Check that input data is present + let input_bytes = pending.request.input.input().cloned().unwrap_or_default(); + assert!(!input_bytes.is_empty()); + + // Approve + wallet + .report_transaction_result(TransactionResponse { + id: "data-test".to_string(), + hash: Some(B256::random()), + error: None, + }) + .await?; + + handle.await??; + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_transaction_timeout() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create transaction with very short timeout + let tx = create_test_transaction( + "timeout-test", + address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"), + address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"), + U256::from(1000), + ); + + // Submit transaction but don't respond + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { + // Use a shorter timeout for testing + tokio::time::timeout(Duration::from_secs(2), wallet_server.request_transaction(tx)).await + }); + + // Let it timeout + let result = handle.await?; + assert!(result.is_err() || result.unwrap().is_err()); + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_multiple_transactions_queue() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + let from = address!("70997970C51812dc3A010C7d01b50e0d17dc79C8"); + let to = address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"); + + // Submit multiple transactions + let tx1 = create_test_transaction("queue-1", from, to, U256::from(1000)); + let tx2 = create_test_transaction("queue-2", from, to, U256::from(2000)); + let tx3 = create_test_transaction("queue-3", from, to, U256::from(3000)); + + // Submit all transactions quickly + let wallet_server = wallet.server.clone(); + let handles = vec![ + { + let server = wallet_server.clone(); + let tx = tx1.clone(); + tokio::spawn(async move { server.request_transaction(tx).await }) + }, + { + let server = wallet_server.clone(); + let tx = tx2.clone(); + tokio::spawn(async move { server.request_transaction(tx).await }) + }, + { + let server = wallet_server.clone(); + let tx = tx3.clone(); + tokio::spawn(async move { server.request_transaction(tx).await }) + }, + ]; + + // Process transactions in order + for _i in 1..=3 { + // Wait for pending transaction + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + let pending = wallet.get_pending_transaction().await?.unwrap(); + + // Approve transaction + wallet + .report_transaction_result(TransactionResponse { + id: pending.id.clone(), + hash: Some(B256::random()), + error: None, + }) + .await?; + + // Small delay to ensure processing + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Wait for all handles + for handle in handles { + handle.await??; + } + + wallet.shutdown().await?; + Ok(()) +} + +#[tokio::test] +async fn test_transaction_with_gas_settings() -> Result<(), Box> { + let wallet = TestWallet::spawn().await?; + let test_address = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8"; + + // Connect wallet + wallet.connect(test_address, 31337).await?; + + // Create transaction with EIP-1559 gas settings + let tx = BrowserTransaction { + id: "gas-test".to_string(), + request: TransactionRequest { + from: Some(address!("70997970C51812dc3A010C7d01b50e0d17dc79C8")), + to: Some(address!("3C44CdDdB6a900fa2b585dd299e03d12FA4293BC").into()), + value: Some(U256::from(1000)), + gas: Some(21000), + max_fee_per_gas: Some(30_000_000_000u128), // 30 gwei + max_priority_fee_per_gas: Some(1_000_000_000u128), // 1 gwei + ..Default::default() + }, + }; + + // Submit and process + let wallet_server = wallet.server.clone(); + let handle = tokio::spawn(async move { wallet_server.request_transaction(tx).await }); + + wait_for( + || async { + wallet.get_pending_transaction().await.map(|opt| opt.is_some()).unwrap_or(false) + }, + Duration::from_secs(5), + ) + .await?; + + let pending = wallet.get_pending_transaction().await?.unwrap(); + assert_eq!(pending.request.gas, Some(21000)); + assert!(pending.request.max_fee_per_gas.is_some()); + assert!(pending.request.max_priority_fee_per_gas.is_some()); + + // Approve + wallet + .report_transaction_result(TransactionResponse { + id: "gas-test".to_string(), + hash: Some(B256::random()), + error: None, + }) + .await?; + + handle.await??; + + wallet.shutdown().await?; + Ok(()) +} diff --git a/crates/browser-wallet/tests/integration/utils.rs b/crates/browser-wallet/tests/integration/utils.rs new file mode 100644 index 0000000000000..d5271c118609d --- /dev/null +++ b/crates/browser-wallet/tests/integration/utils.rs @@ -0,0 +1,314 @@ +use alloy_primitives::{Address, U256}; +use alloy_rpc_types::TransactionRequest; +use foundry_browser_wallet::{ + BrowserTransaction, BrowserWalletServer, SignRequest, SignResponse, SignType, + TransactionResponse, +}; +use reqwest::Client; +use serde_json::json; +use std::time::Duration; + +/// Test wallet wrapper following Anvil's pattern +pub struct TestWallet { + pub server: BrowserWalletServer, + pub client: Client, + pub base_url: String, +} + +impl TestWallet { + /// Spawn a new test wallet server + pub async fn spawn() -> Result> { + // Set test environment variable to skip browser launch + std::env::set_var("BROWSER_WALLET_TEST_MODE", "true"); + // Set shorter timeout for tests (5 seconds) + std::env::set_var("BROWSER_WALLET_TIMEOUT", "5"); + + let mut server = BrowserWalletServer::new(0); // Use port 0 for random assignment + server.start().await?; + + let port = server.port(); + let base_url = format!("http://localhost:{port}"); + + // Wait for server to be ready + let client = Client::builder().timeout(Duration::from_secs(10)).build()?; + + // Health check with retries + for _ in 0..10 { + if client.get(format!("{base_url}/api/heartbeat")).send().await.is_ok() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + Ok(Self { server, client, base_url }) + } + + /// Simulate wallet connection + pub async fn connect( + &self, + address: &str, + chain_id: u64, + ) -> Result<(), Box> { + let response = self + .client + .post(format!("{}/api/account", self.base_url)) + .json(&json!({ + "address": address, + "chain_id": chain_id + })) + .send() + .await?; + + assert!(response.status().is_success(), "Failed to connect wallet: {}", response.status()); + Ok(()) + } + + /// Disconnect wallet + pub async fn disconnect(&self) -> Result<(), Box> { + let response = self + .client + .post(format!("{}/api/account", self.base_url)) + .json(&json!({ + "address": serde_json::Value::Null, + "chain_id": serde_json::Value::Null + })) + .send() + .await?; + + assert!(response.status().is_success()); + Ok(()) + } + + /// Get pending transaction (simulating frontend polling) + pub async fn get_pending_transaction( + &self, + ) -> Result, Box> { + let response = + self.client.get(format!("{}/api/transaction/pending", self.base_url)).send().await?; + + if response.status().is_success() { + let text = response.text().await?; + if text.trim() == "null" || text.is_empty() { + Ok(None) + } else { + Ok(Some(serde_json::from_str(&text)?)) + } + } else { + Ok(None) + } + } + + /// Report transaction result (simulating wallet response) + pub async fn report_transaction_result( + &self, + response: TransactionResponse, + ) -> Result<(), Box> { + // Convert to JavaScript format expected by server + let js_response = if let Some(hash) = response.hash { + json!({ + "id": response.id, + "status": "success", + "hash": format!("0x{}", hex::encode(hash)) + }) + } else { + json!({ + "id": response.id, + "status": "error", + "error": response.error.unwrap_or_else(|| "Unknown error".to_string()) + }) + }; + + let response = self + .client + .post(format!("{}/api/transaction/response", self.base_url)) + .json(&js_response) + .send() + .await?; + + assert!(response.status().is_success()); + Ok(()) + } + + /// Get pending signing request + pub async fn get_pending_signing( + &self, + ) -> Result, Box> { + let response = + self.client.get(format!("{}/api/sign/pending", self.base_url)).send().await?; + + if response.status().is_success() { + let text = response.text().await?; + if text.trim() == "null" || text.is_empty() { + Ok(None) + } else { + Ok(Some(serde_json::from_str(&text)?)) + } + } else { + Ok(None) + } + } + + /// Report signing result + pub async fn report_signing_result( + &self, + response: SignResponse, + ) -> Result<(), Box> { + // Convert to JavaScript format expected by server + let js_response = if let Some(signature) = response.signature { + json!({ + "id": response.id, + "status": "success", + "signature": format!("0x{}", hex::encode(&signature)) + }) + } else { + json!({ + "id": response.id, + "status": "error", + "error": response.error.unwrap_or_else(|| "Unknown error".to_string()) + }) + }; + + let response = self + .client + .post(format!("{}/api/sign/response", self.base_url)) + .json(&js_response) + .send() + .await?; + + assert!(response.status().is_success()); + Ok(()) + } + + /// Check server health + pub async fn health_check(&self) -> Result> { + let response = self.client.get(format!("{}/api/heartbeat", self.base_url)).send().await?; + + Ok(response.status().is_success()) + } + + /// Get network details + pub async fn get_network_details( + &self, + ) -> Result> { + let response = self.client.get(format!("{}/api/network", self.base_url)).send().await?; + + Ok(response.json().await?) + } + + /// Shutdown the server + pub async fn shutdown(mut self) -> Result<(), Box> { + self.server.stop().await?; + Ok(()) + } +} + +/// Create a test transaction +pub fn create_test_transaction( + id: &str, + from: Address, + to: Address, + value: U256, +) -> BrowserTransaction { + BrowserTransaction { + id: id.to_string(), + request: TransactionRequest { + from: Some(from), + to: Some(to.into()), + value: Some(value), + chain_id: Some(31337), // Anvil chain ID + ..Default::default() + }, + } +} + +/// Create a test signing request +pub fn create_test_signing_request(id: &str, message: &str) -> SignRequest { + SignRequest { + id: id.to_string(), + address: Address::ZERO, + message: message.to_string(), + sign_type: SignType::PersonalSign, + } +} + +/// Helper to wait for a condition with timeout +pub async fn wait_for( + mut condition: F, + timeout: Duration, +) -> Result<(), Box> +where + F: FnMut() -> Fut, + Fut: std::future::Future, +{ + let start = tokio::time::Instant::now(); + + loop { + if condition().await { + return Ok(()); + } + + if start.elapsed() > timeout { + return Err("Timeout waiting for condition".into()); + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +/// Simulate frontend polling for transactions +pub async fn simulate_transaction_polling( + wallet: &TestWallet, + auto_approve: bool, +) -> Result<(), Box> { + // Poll for pending transaction + if let Some(tx) = wallet.get_pending_transaction().await? { + // Simulate user action + if auto_approve { + wallet + .report_transaction_result(TransactionResponse { + id: tx.id, + hash: Some(alloy_primitives::B256::random()), + error: None, + }) + .await?; + } else { + wallet + .report_transaction_result(TransactionResponse { + id: tx.id, + hash: None, + error: Some("User rejected transaction".to_string()), + }) + .await?; + } + } + Ok(()) +} + +/// Simulate frontend polling for signing requests +pub async fn simulate_signing_polling( + wallet: &TestWallet, + auto_approve: bool, +) -> Result<(), Box> { + // Poll for pending signing request + if let Some(req) = wallet.get_pending_signing().await? { + // Simulate user action + if auto_approve { + wallet + .report_signing_result(SignResponse { + id: req.id, + signature: Some(alloy_primitives::Bytes::from(vec![0xde, 0xad, 0xbe, 0xef])), + error: None, + }) + .await?; + } else { + wallet + .report_signing_result(SignResponse { + id: req.id, + signature: None, + error: Some("User rejected signature request".to_string()), + }) + .await?; + } + } + Ok(()) +} diff --git a/crates/browser-wallet/tests/integration_tests.rs b/crates/browser-wallet/tests/integration_tests.rs new file mode 100644 index 0000000000000..0bb6d380406fd --- /dev/null +++ b/crates/browser-wallet/tests/integration_tests.rs @@ -0,0 +1,20 @@ +mod integration; + +#[tokio::test] +async fn test_server_can_start() { + use foundry_browser_wallet::BrowserWalletServer; + + // Set test mode to skip browser opening + std::env::set_var("BROWSER_WALLET_TEST_MODE", "1"); + + let mut server = BrowserWalletServer::new(0); // Use random port + + // Start server + server.start().await.expect("Failed to start server"); + + // Verify port was assigned + assert!(server.port() > 0); + + // Stop server + server.stop().await.expect("Failed to stop server"); +} diff --git a/crates/cast/Cargo.toml b/crates/cast/Cargo.toml index 6d7b22e214b5d..3fd39e45a97e8 100644 --- a/crates/cast/Cargo.toml +++ b/crates/cast/Cargo.toml @@ -92,11 +92,12 @@ foundry-test-utils.workspace = true alloy-hardforks.workspace = true [features] -default = ["jemalloc"] +default = ["jemalloc", "browser"] asm-keccak = ["alloy-primitives/asm-keccak"] jemalloc = ["foundry-cli/jemalloc"] mimalloc = ["foundry-cli/mimalloc"] tracy-allocator = ["foundry-cli/tracy-allocator"] aws-kms = ["foundry-wallets/aws-kms"] gcp-kms = ["foundry-wallets/gcp-kms"] +browser = ["foundry-wallets/browser"] isolate-by-default = ["foundry-config/isolate-by-default"] diff --git a/crates/cast/src/cmd/mktx.rs b/crates/cast/src/cmd/mktx.rs index ebfb5afeadd47..1d96662f731ce 100644 --- a/crates/cast/src/cmd/mktx.rs +++ b/crates/cast/src/cmd/mktx.rs @@ -77,6 +77,14 @@ impl MakeTxArgs { pub async fn run(self) -> Result<()> { let Self { to, mut sig, mut args, command, tx, path, eth, raw_unsigned, ethsign } = self; + // Check if browser wallet is being used + if eth.wallet.browser { + eyre::bail!( + "Browser wallets cannot create offline transactions. \ + Use `cast send` instead to sign and broadcast in one step." + ); + } + let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None }; let code = if let Some(MakeTxSubcommands::Create { diff --git a/crates/cast/src/cmd/send.rs b/crates/cast/src/cmd/send.rs index 500c65f6723c2..70e0c9b8eb89c 100644 --- a/crates/cast/src/cmd/send.rs +++ b/crates/cast/src/cmd/send.rs @@ -140,11 +140,14 @@ impl SendTxArgs { let timeout = timeout.unwrap_or(config.transaction_timeout); + // Check if we're using a browser wallet + let using_browser_wallet = eth.wallet.browser; + // Case 1: // Default to sending via eth_sendTransaction if the --unlocked flag is passed. // This should be the only way this RPC method is used as it requires a local node // or remote RPC with unlocked accounts. - if unlocked { + if unlocked && !using_browser_wallet { // only check current chain id if it was specified in the config if let Some(config_chain) = config.chain { let current_chain_id = provider.get_chain_id().await?; @@ -178,6 +181,42 @@ impl SendTxArgs { tx::validate_from_address(eth.wallet.from, from)?; + // Special handling for browser wallets + if using_browser_wallet { + // Browser wallets need to use a different flow + // They handle signing in the browser via eth_sendTransaction + if let foundry_wallets::WalletSigner::Browser(ref browser_signer) = signer { + // Build the transaction + let (tx_request, _) = builder.build(from).await?; + + // Extract the inner TransactionRequest from WithOtherFields + // The browser wallet expects TransactionRequest, not + // WithOtherFields + let inner_tx_request = tx_request.inner; + + // Send via browser wallet using the new API with AlloyTxRequest + let tx_hash = + browser_signer.send_transaction_via_browser(inner_tx_request).await?; + + if cast_async { + sh_println!("{tx_hash:#x}")?; + } else { + let receipt = Cast::new(&provider) + .receipt( + format!("{tx_hash:#x}"), + None, + confirmations, + Some(timeout), + false, + ) + .await?; + sh_println!("{receipt}")?; + } + + return Ok(()); + } + } + let (tx, _) = builder.build(&signer).await?; let wallet = EthereumWallet::from(signer); diff --git a/crates/wallets/Cargo.toml b/crates/wallets/Cargo.toml index fb5aa4132fd3d..6c3310969f408 100644 --- a/crates/wallets/Cargo.toml +++ b/crates/wallets/Cargo.toml @@ -47,9 +47,14 @@ thiserror.workspace = true tracing.workspace = true eth-keystore = "0.5.0" +# browser wallet dependency +foundry-browser-wallet = { workspace = true, optional = true } + [dev-dependencies] -tokio = { workspace = true, features = ["macros"] } +tokio = { workspace = true, features = ["macros", "time"] } +reqwest = { workspace = true, features = ["json"] } [features] aws-kms = ["dep:alloy-signer-aws", "dep:aws-config", "dep:aws-sdk-kms"] gcp-kms = ["dep:alloy-signer-gcp", "dep:gcloud-sdk"] +browser = ["dep:foundry-browser-wallet"] diff --git a/crates/wallets/src/error.rs b/crates/wallets/src/error.rs index 9deb037b71fb8..6399e7c0bdada 100644 --- a/crates/wallets/src/error.rs +++ b/crates/wallets/src/error.rs @@ -10,6 +10,9 @@ use alloy_signer_aws::AwsSignerError; #[cfg(feature = "gcp-kms")] use alloy_signer_gcp::GcpSignerError; +#[cfg(feature = "browser")] +use foundry_browser_wallet::BrowserWalletError; + #[derive(Debug, thiserror::Error)] pub enum PrivateKeyError { #[error("Failed to create wallet from private key. Private key is invalid hex: {0}")] @@ -35,6 +38,9 @@ pub enum WalletSignerError { #[cfg(feature = "gcp-kms")] Gcp(#[from] GcpSignerError), #[error(transparent)] + #[cfg(feature = "browser")] + Browser(#[from] BrowserWalletError), + #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] InvalidHex(#[from] FromHexError), @@ -52,4 +58,8 @@ impl WalletSignerError { pub fn gcp_unsupported() -> Self { Self::UnsupportedSigner("Google Cloud KMS") } + + pub fn browser_unsupported() -> Self { + Self::UnsupportedSigner("browser wallet") + } } diff --git a/crates/wallets/src/wallet.rs b/crates/wallets/src/wallet.rs index 8e3a4dafb332a..1895c7f61dbc1 100644 --- a/crates/wallets/src/wallet.rs +++ b/crates/wallets/src/wallet.rs @@ -84,9 +84,29 @@ pub struct WalletOpts { /// Use Google Cloud Key Management Service. #[arg(long, help_heading = "Wallet options - remote", hide = !cfg!(feature = "gcp-kms"))] pub gcp: bool, + + /// Use a browser wallet (e.g., MetaMask). + #[arg(long, help_heading = "Wallet options - browser", hide = !cfg!(feature = "browser"))] + pub browser: bool, + + /// Port for the browser wallet server. + #[arg( + long, + help_heading = "Wallet options - browser", + value_name = "PORT", + default_value = "9545", + requires = "browser", + hide = !cfg!(feature = "browser") + )] + pub browser_port: u16, } impl WalletOpts { + /// Check if this wallet option represents a browser wallet + pub fn is_browser_wallet(&self) -> bool { + self.browser + } + pub async fn signer(&self) -> Result { trace!("start finding signer"); @@ -106,6 +126,8 @@ impl WalletOpts { let key_name = std::env::var("GCP_KEY_NAME")?; let key_version = std::env::var("GCP_KEY_VERSION")?.parse()?; WalletSigner::from_gcp(project_id, location, keyring, key_name, key_version).await? + } else if self.browser { + WalletSigner::from_browser(self.browser_port).await? } else if let Some(raw_wallet) = self.raw.signer()? { raw_wallet } else if let Some(path) = utils::maybe_get_keystore_path( @@ -130,7 +152,7 @@ impl WalletOpts { Error accessing local wallet. Did you set a private key, mnemonic or keystore? Run the command with --help flag for more information or use the corresponding CLI flag to set your key via: ---private-key, --mnemonic-path, --aws, --gcp, --interactive, --trezor or --ledger. +--private-key, --mnemonic-path, --aws, --gcp, --browser, --interactive, --trezor or --ledger. Alternatively, when using the `cast send` or `cast mktx` commands with a local node or RPC that has unlocked accounts, the --unlocked or --ethsign flags can be used, respectively. The sender address can be specified by setting the `ETH_FROM` environment @@ -198,6 +220,8 @@ mod tests { trezor: false, aws: false, gcp: false, + browser: false, + browser_port: 9545, }; match wallet.signer().await { Ok(_) => { diff --git a/crates/wallets/src/wallet_signer.rs b/crates/wallets/src/wallet_signer.rs index a5d5b70843e5f..6c3349a43cacd 100644 --- a/crates/wallets/src/wallet_signer.rs +++ b/crates/wallets/src/wallet_signer.rs @@ -23,6 +23,9 @@ use { }, }; +#[cfg(feature = "browser")] +use foundry_browser_wallet::BrowserSigner; + pub type Result = std::result::Result; /// Wrapper enum around different signers. @@ -40,6 +43,9 @@ pub enum WalletSigner { /// Wrapper around Google Cloud KMS signer. #[cfg(feature = "gcp-kms")] Gcp(GcpSigner), + /// Wrapper around browser wallet signer (MetaMask, WalletConnect, etc.) + #[cfg(feature = "browser")] + Browser(BrowserSigner), } impl WalletSigner { @@ -110,6 +116,21 @@ impl WalletSigner { Ok(Self::Local(PrivateKeySigner::from_bytes(private_key)?)) } + pub async fn from_browser(port: u16) -> Result { + #[cfg(feature = "browser")] + { + let browser_signer = + BrowserSigner::new(port).await.map_err(|e| WalletSignerError::Browser(e.into()))?; + Ok(Self::Browser(browser_signer)) + } + + #[cfg(not(feature = "browser"))] + { + let _ = port; + Err(WalletSignerError::browser_unsupported()) + } + } + /// Returns a list of addresses available to use with current signer /// /// - for Ledger and Trezor signers the number of addresses to retrieve is specified as argument @@ -155,6 +176,10 @@ impl WalletSigner { Self::Gcp(gcp) => { senders.push(alloy_signer::Signer::address(gcp)); } + #[cfg(feature = "browser")] + Self::Browser(browser) => { + senders.push(alloy_signer::Signer::address(browser)); + } } Ok(senders) } @@ -191,6 +216,8 @@ macro_rules! delegate { Self::Aws($inner) => $e, #[cfg(feature = "gcp-kms")] Self::Gcp($inner) => $e, + #[cfg(feature = "browser")] + Self::Browser($inner) => $e, } }; }