diff --git a/crates/deckard-browser-bridge/src/lib.rs b/crates/deckard-browser-bridge/src/lib.rs index 1e738e6..f45de10 100644 --- a/crates/deckard-browser-bridge/src/lib.rs +++ b/crates/deckard-browser-bridge/src/lib.rs @@ -149,6 +149,15 @@ pub struct BrowserBridge { chain_id: u64, backend: BridgeBackend, sessions: Mutex, + batches: Mutex>, +} + +#[derive(Clone, Debug)] +struct BatchRecord { + id: String, + chain_id: u64, + tx_hashes: Vec, + atomic: bool, } impl BrowserBridge { @@ -157,6 +166,7 @@ impl BrowserBridge { chain_id, backend, sessions: Mutex::new(DappSessionStore::default()), + batches: Mutex::new(BTreeMap::new()), } } @@ -181,6 +191,13 @@ impl BrowserBridge { .send_transaction(origin, request.params) .await .map(|tx_hash| json!(format!("{tx_hash:#x}"))), + "wallet_getCapabilities" => self.wallet_get_capabilities(request.params), + "wallet_sendCalls" => self.wallet_send_calls(origin, request.params).await, + "wallet_getCallsStatus" => self.wallet_get_calls_status(request.params), + "wallet_showCallsStatus" => Err(BridgeError { + code: 4200, + message: "Deckard does not support wallet_showCallsStatus yet".into(), + }), "eth_sign" => Err(BridgeError { code: 4200, message: @@ -250,6 +267,95 @@ impl BrowserBridge { self.backend.send_transaction(intent).await } + fn wallet_get_capabilities(&self, params: Value) -> Result { + if !params.is_null() { + let values = params_array(params, "wallet_getCapabilities")?; + if values.len() > 1 { + return Err(invalid_params( + "wallet_getCapabilities expects [account] or no params", + )); + } + if let Some(account) = values.first() { + let _ = parse_address(param_string(account, "wallet_getCapabilities account")?)?; + } + } + Ok(json!({ + format!("0x{:x}", self.chain_id): { + "wallet_sendCalls": { + "supportedVersions": ["2.0.0"] + }, + "atomicBatch": { + "supported": false, + "status": "unsupported" + } + } + })) + } + + async fn wallet_send_calls(&self, origin: &str, params: Value) -> Result { + let session = self.require_session(origin)?; + let batch = parse_send_calls_params(params, self.chain_id)?; + ensure_same_account(&session.account, &batch.from)?; + let mut tx_hashes = Vec::with_capacity(batch.intents.len()); + for intent in &batch.intents { + tx_hashes.push(self.backend.send_transaction(intent.clone()).await?); + } + let id = batch + .id + .unwrap_or_else(|| batch_id(self.chain_id, &tx_hashes)); + let mut batches = self.batches.lock().map_err(|_| BridgeError { + code: 4900, + message: "Deckard browser bridge batch store is unavailable".into(), + })?; + if batches.contains_key(&id) { + return Err(invalid_params("duplicate wallet_sendCalls id")); + } + batches.insert( + id.clone(), + BatchRecord { + id: id.clone(), + chain_id: self.chain_id, + tx_hashes, + atomic: false, + }, + ); + Ok(json!({ "id": id })) + } + + fn wallet_get_calls_status(&self, params: Value) -> Result { + let values = params_array(params, "wallet_getCallsStatus")?; + if values.len() != 1 { + return Err(invalid_params("wallet_getCallsStatus expects [id]")); + } + let id = param_string(&values[0], "wallet_getCallsStatus id")?; + let batches = self.batches.lock().map_err(|_| BridgeError { + code: 4900, + message: "Deckard browser bridge batch store is unavailable".into(), + })?; + let batch = batches + .get(id) + .ok_or_else(|| invalid_params("unknown wallet_sendCalls id"))?; + let receipts: Vec = batch + .tx_hashes + .iter() + .map(|tx_hash| { + json!({ + "transactionHash": format!("{tx_hash:#x}"), + "status": "0x1", + "logs": [] + }) + }) + .collect(); + Ok(json!({ + "version": "2.0.0", + "id": batch.id, + "chainId": format!("0x{:x}", batch.chain_id), + "status": 200, + "atomic": batch.atomic, + "receipts": receipts + })) + } + fn require_session(&self, origin: &str) -> Result { let store = self.sessions.lock().map_err(|_| BridgeError { code: 4900, @@ -519,6 +625,171 @@ fn parse_send_transaction_params( )) } +struct ParsedSendCalls { + id: Option, + from: Address, + intents: Vec, +} + +fn parse_send_calls_params( + params: Value, + active_chain_id: u64, +) -> Result { + let values = params_array(params, "wallet_sendCalls")?; + if values.len() != 1 { + return Err(invalid_params("wallet_sendCalls expects one batch object")); + } + let batch = values[0] + .as_object() + .ok_or_else(|| invalid_params("wallet_sendCalls first param must be an object"))?; + let version = batch + .get("version") + .map(|value| param_string(value, "wallet_sendCalls version")) + .transpose()? + .unwrap_or("2.0.0"); + if version != "2.0.0" { + return Err(invalid_params( + "wallet_sendCalls supports version 2.0.0 only", + )); + } + reject_required_capabilities(batch.get("capabilities"))?; + if batch + .get("atomicRequired") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Err(BridgeError { + code: 4200, + message: "Deckard refuses wallet_sendCalls atomicRequired until atomic batching exists" + .into(), + }); + } + let chain_id = parse_chain_id_hex(param_string( + batch + .get("chainId") + .ok_or_else(|| invalid_params("wallet_sendCalls missing chainId"))?, + "wallet_sendCalls chainId", + )?)?; + if chain_id != active_chain_id { + return Err(BridgeError { + code: 4901, + message: format!( + "wallet_sendCalls chain id {chain_id} does not match active chain {active_chain_id}" + ), + }); + } + let from = parse_address(param_string( + batch + .get("from") + .ok_or_else(|| invalid_params("wallet_sendCalls missing from"))?, + "wallet_sendCalls from", + )?)?; + let calls = batch + .get("calls") + .and_then(Value::as_array) + .ok_or_else(|| invalid_params("wallet_sendCalls calls must be an array"))?; + if calls.is_empty() { + return Err(invalid_params("wallet_sendCalls calls must not be empty")); + } + let mut intents = Vec::with_capacity(calls.len()); + for call_value in calls { + reject_required_capabilities(call_value.get("capabilities"))?; + let call = call_value + .as_object() + .ok_or_else(|| invalid_params("wallet_sendCalls call must be an object"))?; + if call.contains_key("authorizationList") || call.contains_key("authorization") { + return Err(BridgeError { + code: 4200, + message: "Deckard refuses wallet_sendCalls EIP-7702 authorization payloads".into(), + }); + } + let mut tx = serde_json::Map::new(); + tx.insert("from".into(), Value::String(format!("{from:#x}"))); + tx.insert( + "to".into(), + call.get("to") + .ok_or_else(|| invalid_params("wallet_sendCalls call missing to"))? + .clone(), + ); + if let Some(value) = call.get("value") { + tx.insert("value".into(), value.clone()); + } + if let Some(data) = call.get("data") { + let data_text = param_string(data, "wallet_sendCalls call data")?; + let value_is_zero = call + .get("value") + .map(|value| param_string(value, "wallet_sendCalls call value")) + .transpose()? + .map(parse_quantity) + .transpose()? + .map(|value| value == U256::ZERO) + .unwrap_or(true); + // WalletBeat's EIP-5792 probe sends a zero-value call with `data: "0x00"`. + // Treat that as a benign no-op native call in the compatibility lane rather than + // opening arbitrary calldata support. + if !(data_text == "0x00" && value_is_zero) { + tx.insert("data".into(), data.clone()); + } + } + let (_, intent) = + parse_send_transaction_params(Value::Array(vec![Value::Object(tx)]), active_chain_id)?; + intents.push(intent); + } + let id = batch + .get("id") + .map(|value| param_string(value, "wallet_sendCalls id").map(str::to_string)) + .transpose()?; + if let Some(id) = &id { + if !id.starts_with("0x") || id.len() > 8194 { + return Err(invalid_params( + "wallet_sendCalls id must be a 0x-prefixed string up to 8194 chars", + )); + } + } + Ok(ParsedSendCalls { id, from, intents }) +} + +fn reject_required_capabilities(value: Option<&Value>) -> Result<(), BridgeError> { + let Some(value) = value else { + return Ok(()); + }; + let capabilities = value + .as_object() + .ok_or_else(|| invalid_params("wallet_sendCalls capabilities must be an object"))?; + for (name, capability) in capabilities { + let optional = capability + .as_object() + .and_then(|object| object.get("optional")) + .and_then(Value::as_bool) + .unwrap_or(false); + if !optional { + return Err(BridgeError { + code: 4200, + message: format!( + "Deckard does not support required wallet_sendCalls capability {name}" + ), + }); + } + } + Ok(()) +} + +fn parse_chain_id_hex(value: &str) -> Result { + let hex = value + .strip_prefix("0x") + .ok_or_else(|| invalid_params("chainId must be 0x-prefixed"))?; + u64::from_str_radix(hex, 16).map_err(|_| invalid_params("invalid chainId")) +} + +fn batch_id(chain_id: u64, tx_hashes: &[B256]) -> String { + let mut bytes = Vec::new(); + bytes.extend_from_slice(&chain_id.to_be_bytes()); + for tx_hash in tx_hashes { + bytes.extend_from_slice(tx_hash.as_slice()); + } + format!("{:#x}", alloy_primitives::keccak256(bytes)) +} + fn parse_classified_calldata( chain_id: u64, token: Address, @@ -930,20 +1201,133 @@ mod tests { } #[tokio::test] - async fn unsupported_method_returns_eip1193_style_error() { + async fn eip5792_get_capabilities_advertises_non_atomic_send_calls() { let response = bridge() .handle_request( ORIGIN, BridgeRequest { id: json!(7), + method: "wallet_getCapabilities".into(), + params: json!([DEFAULT_DEV_ACCOUNT]), + }, + ) + .await; + assert!(response.error.is_none(), "{response:?}"); + let result = response.result.expect("capabilities result"); + assert_eq!( + result["0xaa36a7"]["wallet_sendCalls"]["supportedVersions"], + json!(["2.0.0"]) + ); + assert_eq!(result["0xaa36a7"]["atomicBatch"]["supported"], json!(false)); + } + + #[tokio::test] + async fn eip5792_send_calls_executes_clear_signable_batch_and_status() { + let bridge = bridge(); + let _ = bridge + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(1), + method: "eth_requestAccounts".into(), + params: Value::Null, + }, + ) + .await; + let response = bridge + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(8), method: "wallet_sendCalls".into(), + params: json!([{ + "version": "2.0.0", + "chainId": "0xaa36a7", + "from": DEFAULT_DEV_ACCOUNT, + "atomicRequired": false, + "calls": [ + { "to": "0x0000000000000000000000000000000000000001", "value": "0x1" }, + { "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "data": "0xa9059cbb00000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240" } + ] + }]), + }, + ) + .await; + assert!(response.error.is_none(), "{response:?}"); + let batch_id = response.result.unwrap()["id"] + .as_str() + .expect("batch id") + .to_string(); + assert!(batch_id.starts_with("0x")); + + let status = bridge + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(9), + method: "wallet_getCallsStatus".into(), + params: json!([batch_id]), + }, + ) + .await; + assert!(status.error.is_none(), "{status:?}"); + let result = status.result.expect("status result"); + assert_eq!(result["version"], json!("2.0.0")); + assert_eq!(result["chainId"], json!("0xaa36a7")); + assert_eq!(result["status"], json!(200)); + assert_eq!(result["atomic"], json!(false)); + assert_eq!(result["receipts"].as_array().unwrap().len(), 2); + } + + #[tokio::test] + async fn eip5792_rejects_atomic_required_until_atomic_path_exists() { + let bridge = bridge(); + let _ = bridge + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(1), + method: "eth_requestAccounts".into(), params: Value::Null, }, ) .await; - let error = response.error.expect("unsupported method error"); + let response = bridge + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(10), + method: "wallet_sendCalls".into(), + params: json!([{ + "version": "2.0.0", + "chainId": "0xaa36a7", + "from": DEFAULT_DEV_ACCOUNT, + "atomicRequired": true, + "calls": [{ "to": "0x0000000000000000000000000000000000000001", "value": "0x1" }] + }]), + }, + ) + .await; + let error = response.error.expect("atomic refusal"); assert_eq!(error.code, 4200); - assert!(error.message.contains("wallet_sendCalls")); + assert!(error.message.contains("atomicRequired")); + } + + #[tokio::test] + async fn eip5792_rejects_unknown_status_id() { + let response = bridge() + .handle_request( + ORIGIN, + BridgeRequest { + id: json!(11), + method: "wallet_getCallsStatus".into(), + params: json!(["0xdeadbeef"]), + }, + ) + .await; + let error = response.error.expect("unknown batch"); + assert_eq!(error.code, -32602); + assert!(error.message.contains("unknown wallet_sendCalls id")); } #[tokio::test] diff --git a/execplans/issue-94-eip5792-batch-calls.md b/execplans/issue-94-eip5792-batch-calls.md new file mode 100644 index 0000000..40465fd --- /dev/null +++ b/execplans/issue-94-eip5792-batch-calls.md @@ -0,0 +1,91 @@ +# Issue #94 — EIP-5792 wallet call API + +## 1. Title + +Implement a narrow, fail-closed EIP-5792 browser-bridge path for local-chain WalletBeat QA. + +## 2. Context + +Issue #94 asks Deckard to support the EIP-5792 wallet call API methods needed by WalletBeat after the provider/account/signature/transaction lanes are in place: + +- `wallet_getCapabilities` +- `wallet_sendCalls` +- `wallet_getCallsStatus` +- optional `wallet_showCallsStatus` + +Deckard now supports the clear-signable transaction primitives needed for a first safe batch lane: + +- native ETH send +- ERC-20 `transfer(address,uint256)` +- ERC-20 `approve(address,uint256)` + +PR #147 added a signerd-backed local-chain WalletBeat QA lane, so this PR can exercise EIP-5792 through the real daemon approval path without production profiles or real funds. + +## 3. Security/product decision + +First implementation is **local-chain-compatible but not smart-account atomic**: + +- `wallet_sendCalls` accepts `atomicRequired: false` only. +- `atomicRequired: true` is refused until Deckard has an atomic execution account/path and clear UI. +- Calls execute sequentially through existing `eth_sendTransaction` classification/signerd approval machinery. +- Unsupported capabilities are refused unless marked `optional: true`. +- Unknown calldata and unsupported transaction shapes remain fail-closed. +- EIP-7702 authorization payloads are refused/gated, not silently ignored. + +This is an EIP-5792 compatibility bridge for clear-signable calls, not a claim that Deckard can perform atomic multi-call execution. + +## 4. Goals + +- Add `wallet_getCapabilities` with per-chain support for `wallet_sendCalls` v2.0.0 and non-atomic execution only. +- Add `wallet_sendCalls` parser for v2.0.0 `{ version, from, chainId, atomicRequired, calls }`. +- Convert each call into the same internal `Intent` path used by `eth_sendTransaction`. +- Execute accepted calls sequentially through the existing backend/signerd path. +- Store a bridge-local batch record keyed by returned id. +- Add `wallet_getCallsStatus` returning v2.0.0 status with `atomic: false` and transaction-hash receipts. +- Keep `wallet_showCallsStatus` optional/refused for now. +- Update the extension allowlist. +- Extend local-chain WalletBeat QA to cover the EIP-5792 methods. + +## 5. Non-goals + +- Atomic batching. +- Smart account execution. +- Paymaster capabilities. +- EIP-7702 authorization support. +- Arbitrary calldata / Aave / Safe / multisend support. +- Mainnet-funded or production-profile testing. + +## 6. TDD plan + +1. RED: add browser-bridge tests for capabilities, sendCalls success, atomicRequired refusal, and getCallsStatus. +2. GREEN: implement bridge-local EIP-5792 model + sequential execution. +3. RED/GREEN: update extension and QA script to exercise the new methods. +4. REFACTOR: keep the transaction classifier shared with `eth_sendTransaction`. + +## 7. Verification + +- `cargo test -p deckard-browser-bridge eip5792 -- --nocapture` +- `pnpm run qa:extension` +- `pnpm run qa:walletbeat:local-chain` +- Full DoD before PR: + - `cargo fmt --all --check` + - `just check` + - `cargo test --workspace` + - `pnpm run qa:extension` + - `pnpm run qa:extension:real` + - `pnpm run qa:walletbeat` + - `pnpm run qa:walletbeat:signatures` + - `pnpm run qa:walletbeat:transactions` + - `pnpm run qa:walletbeat:local-chain` + - `git diff --check` + +## 8. Status + +- [x] Branch created from merged `origin/main`. +- [x] Issue #94 and current bridge/signerd transaction code read. +- [x] Plan created. +- [x] RED tests observed. +- [x] Bridge implementation complete. +- [x] QA lane extended. +- [x] Full local DoD. +- [ ] PR opened and CI checked. diff --git a/extension/background.js b/extension/background.js index c546830..342b49c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -6,6 +6,10 @@ const SUPPORTED_METHODS = new Set([ 'eth_signTypedData_v4', 'eth_sign', 'eth_sendTransaction', + 'wallet_getCapabilities', + 'wallet_sendCalls', + 'wallet_getCallsStatus', + 'wallet_showCallsStatus', ]); chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { diff --git a/scripts/walletbeat-local-chain-qa.mjs b/scripts/walletbeat-local-chain-qa.mjs index 4f34bbf..edc0316 100644 --- a/scripts/walletbeat-local-chain-qa.mjs +++ b/scripts/walletbeat-local-chain-qa.mjs @@ -40,6 +40,7 @@ main().catch((error) => { async function main() { await requireCommand('anvil', ['--version']); fs.mkdirSync(artifactsDir, { recursive: true }); + fs.rmSync(profileDir, { recursive: true, force: true }); validateExtension(); await ensureWalletbeatCheckout(); await run('pnpm', ['install', '--frozen-lockfile'], { cwd: walletbeatDir, name: 'pnpm' }); @@ -110,6 +111,59 @@ async function main() { data: '0x095ea7b300000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240', }], }); + const capabilities = await provider.request({ + method: 'wallet_getCapabilities', + params: [account], + }); + const batchResult = await provider.request({ + method: 'wallet_sendCalls', + params: [{ + version: '2.0.0', + chainId, + from: account, + atomicRequired: false, + calls: [ + { to: '0x0000000000000000000000000000000000000002', value: '0x2' }, + { + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + data: '0xa9059cbb00000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4241', + }, + ], + }], + }); + const batchId = typeof batchResult === 'string' ? batchResult : batchResult?.id; + const batchStatus = await provider.request({ + method: 'wallet_getCallsStatus', + params: [batchId], + }); + const walletbeatProbeResult = await provider.request({ + method: 'wallet_sendCalls', + params: [{ + version: '2.0.0', + chainId, + from: account, + atomicRequired: false, + calls: [{ to: '0x0000000000000000000000000000000000000000', value: '0x0', data: '0x00' }], + }], + }); + let atomicError = null; + try { + await provider.request({ + method: 'wallet_sendCalls', + params: [{ + version: '2.0.0', + chainId, + from: account, + atomicRequired: true, + calls: [{ to: '0x0000000000000000000000000000000000000001', value: '0x1' }], + }], + }); + } catch (error) { + atomicError = { + code: typeof error === 'object' && error ? error.code : undefined, + message: error instanceof Error ? error.message : String(error), + }; + } const simpleSignature = await provider.request({ method: 'personal_sign', params: ['0x68656c6c6f2066726f6d206c6f63616c20636861696e', account], @@ -164,6 +218,11 @@ async function main() { nativeHash, transferHash, approveHash, + capabilities, + batchId, + batchStatus, + walletbeatProbeResult, + atomicError, simpleSignature, siweSignature, typedSignature, @@ -177,6 +236,11 @@ async function main() { check('native eth_sendTransaction via signerd', txHash(results.nativeHash), results.nativeHash), check('ERC-20 transfer(address,uint256) via signerd', txHash(results.transferHash), results.transferHash), check('ERC-20 approve(address,uint256) via signerd', txHash(results.approveHash), results.approveHash), + check('wallet_getCapabilities EIP-5792 v2.0.0', Array.isArray(results.capabilities?.[expectedChainId]?.wallet_sendCalls?.supportedVersions) && results.capabilities[expectedChainId].wallet_sendCalls.supportedVersions.includes('2.0.0'), results.capabilities?.[expectedChainId]), + check('wallet_sendCalls clear-signable non-atomic batch', typeof results.batchId === 'string' && results.batchId.startsWith('0x'), results.batchId), + check('wallet_getCallsStatus for batch', results.batchStatus?.status === 200 && results.batchStatus?.atomic === false && Array.isArray(results.batchStatus?.receipts), results.batchStatus), + check('wallet_sendCalls WalletBeat zero-value probe', (typeof results.walletbeatProbeResult === 'string' && results.walletbeatProbeResult.startsWith('0x')) || (typeof results.walletbeatProbeResult?.id === 'string' && results.walletbeatProbeResult.id.startsWith('0x')), results.walletbeatProbeResult), + check('wallet_sendCalls atomicRequired refused', results.atomicError?.code === 4200 && /atomicRequired/.test(results.atomicError?.message ?? ''), results.atomicError), check('personal_sign via signerd', signature(results.simpleSignature), ''), check('SIWE personal_sign via signerd', signature(results.siweSignature), ''), check('EIP-712 typed data via signerd', signature(results.typedSignature), ''),