diff --git a/crates/deckard-browser-bridge/src/lib.rs b/crates/deckard-browser-bridge/src/lib.rs index c495340..1e738e6 100644 --- a/crates/deckard-browser-bridge/src/lib.rs +++ b/crates/deckard-browser-bridge/src/lib.rs @@ -31,6 +31,8 @@ const DEV_ACCOUNT_ENV: &str = "DECKARD_BRIDGE_DEV_ACCOUNT"; const DEFAULT_DEV_ACCOUNT: &str = "0xdec0ded000000000000000000000000000001193"; const MESSAGE_APPROVAL_TIMEOUT: Duration = Duration::from_secs(120); const MESSAGE_APPROVAL_POLL: Duration = Duration::from_millis(250); +const ERC20_TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb]; +const ERC20_APPROVE_SELECTOR: [u8; 4] = [0x09, 0x5e, 0xa7, 0xb3]; /// Per-origin dapp session remembered by the bridge process. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -501,15 +503,9 @@ fn parse_send_transaction_params( .map(|value| param_string(value, "eth_sendTransaction data")) .transpose()? .unwrap_or("0x"); - let calldata = if data == "0x" || data.is_empty() { - Bytes::new() - } else { - return Err(BridgeError { - code: 4200, - message: "Deckard does not yet support eth_sendTransaction with contract calldata" - .into(), - }); - }; + if data != "0x" && !data.is_empty() { + return parse_classified_calldata(chain_id, to, value, data).map(|intent| (from, intent)); + } Ok(( from, Intent { @@ -517,12 +513,71 @@ fn parse_send_transaction_params( to, token: None, value, - calldata, + calldata: Bytes::new(), kind: IntentKind::Send, }, )) } +fn parse_classified_calldata( + chain_id: u64, + token: Address, + native_value: U256, + data: &str, +) -> Result { + if native_value != U256::ZERO { + return Err(BridgeError { + code: 4200, + message: "Deckard refuses ERC-20 eth_sendTransaction calldata with native value".into(), + }); + } + let calldata = message_bytes(data)?; + let bytes = calldata.as_ref(); + if bytes.len() < 4 { + return Err(invalid_params("ERC-20 calldata is too short")); + } + if bytes.len() != 4 + 32 + 32 { + return Err(invalid_params("ERC-20 calldata must be exactly 68 bytes")); + } + let selector = [bytes[0], bytes[1], bytes[2], bytes[3]]; + match selector { + ERC20_TRANSFER_SELECTOR => { + let recipient = abi_address_word(&bytes[4..36])?; + let amount = U256::from_be_slice(&bytes[36..68]); + Ok(Intent { + chain_id, + to: recipient, + token: Some(token), + value: amount, + calldata: Bytes::new(), + kind: IntentKind::Send, + }) + } + ERC20_APPROVE_SELECTOR => Ok(Intent { + chain_id, + to: token, + token: None, + value: U256::ZERO, + calldata, + kind: IntentKind::ContractCall, + }), + _ => Err(BridgeError { + code: 4200, + message: "Deckard refuses unsupported transaction calldata selector".into(), + }), + } +} + +fn abi_address_word(word: &[u8]) -> Result { + if word.len() != 32 { + return Err(invalid_params("ABI address word must be 32 bytes")); + } + if word[..12].iter().any(|byte| *byte != 0) { + return Err(invalid_params("ERC-20 calldata address is not ABI encoded")); + } + Ok(Address::from_slice(&word[12..32])) +} + fn params_array(params: Value, method: &str) -> Result, BridgeError> { match params { Value::Array(values) => Ok(values), @@ -962,7 +1017,7 @@ mod tests { } #[tokio::test] - async fn send_transaction_rejects_contract_calldata_until_clear_signing_exists() { + async fn send_transaction_rejects_unknown_contract_selector() { let bridge = bridge(); let _ = bridge .handle_request( @@ -980,13 +1035,127 @@ mod tests { BridgeRequest { id: json!(23), method: "eth_sendTransaction".into(), - params: json!([{ "from": DEFAULT_DEV_ACCOUNT, "to": "0x0000000000000000000000000000000000000001", "data": "0xa9059cbb" }]), + params: json!([{ "from": DEFAULT_DEV_ACCOUNT, "to": "0x0000000000000000000000000000000000000001", "data": "0xdeadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001" }]), + }, + ) + .await; + let error = response.error.expect("unknown selector refusal"); + assert_eq!(error.code, 4200); + assert!(error.message.contains("unsupported transaction calldata")); + } + + #[tokio::test] + async fn send_transaction_erc20_transfer_returns_dev_hash() { + 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!(24), + method: "eth_sendTransaction".into(), + params: json!([{ "from": DEFAULT_DEV_ACCOUNT, "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "data": "0xa9059cbb00000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240" }]), + }, + ) + .await; + assert!(response.error.is_none(), "{response:?}"); + let tx_hash = response.result.unwrap().as_str().unwrap().to_string(); + assert!(tx_hash.starts_with("0x")); + assert_eq!(tx_hash.len(), 66); + } + + #[tokio::test] + async fn send_transaction_erc20_approve_returns_dev_hash() { + 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!(25), + method: "eth_sendTransaction".into(), + params: json!([{ "from": DEFAULT_DEV_ACCOUNT, "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "data": "0x095ea7b300000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240" }]), + }, + ) + .await; + assert!(response.error.is_none(), "{response:?}"); + let tx_hash = response.result.unwrap().as_str().unwrap().to_string(); + assert!(tx_hash.starts_with("0x")); + assert_eq!(tx_hash.len(), 66); + } + + #[tokio::test] + async fn send_transaction_erc20_calldata_rejects_native_value() { + 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!(26), + method: "eth_sendTransaction".into(), + params: json!([{ "from": DEFAULT_DEV_ACCOUNT, "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "value": "0x1", "data": "0x095ea7b300000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240" }]), }, ) .await; - let error = response.error.expect("contract-call refusal"); + let error = response.error.expect("native value refusal"); assert_eq!(error.code, 4200); - assert!(error.message.contains("contract calldata")); + assert!(error.message.contains("native value")); + } + + #[tokio::test] + async fn send_transaction_erc20_calldata_rejects_malformed_length() { + 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!(27), + method: "eth_sendTransaction".into(), + params: json!([{ "from": DEFAULT_DEV_ACCOUNT, "to": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", "data": "0xa9059cbb00000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e2" }]), + }, + ) + .await; + let error = response.error.expect("malformed calldata refusal"); + assert_eq!(error.code, -32602); + assert!(error.message.contains("ERC-20 calldata")); } #[tokio::test] diff --git a/crates/deckard-core/src/cow_types.rs b/crates/deckard-core/src/cow_types.rs index 31685cf..498b4ef 100644 --- a/crates/deckard-core/src/cow_types.rs +++ b/crates/deckard-core/src/cow_types.rs @@ -107,6 +107,16 @@ pub fn decode_approve(calldata: &[u8]) -> Option<(Address, U256)> { )) } +/// Build exact `transfer(address,uint256)` calldata for an ERC-20 token send. +pub fn build_erc20_transfer_calldata(recipient: Address, amount: U256) -> Bytes { + let mut data = vec![0xa9, 0x05, 0x9c, 0xbb]; + let mut recipient_word = [0u8; 32]; + recipient_word[12..].copy_from_slice(recipient.as_slice()); + data.extend_from_slice(&recipient_word); + data.extend_from_slice(&amount.to_be_bytes::<32>()); + Bytes::from(data) +} + /// Calldata for `invalidateOrder(bytes orderUid)` to the settlement contract (cancellation). pub fn build_invalidate_order_calldata(uid: &[u8; 56]) -> Bytes { invalidateOrderCall { @@ -254,6 +264,18 @@ string kind,bool partiallyFillable,string sellTokenBalance,string buyTokenBalanc assert!(decode_approve(&calldata).is_none()); } + #[test] + fn build_erc20_transfer_calldata_encodes_selector_recipient_and_amount() { + let recipient = Address::repeat_byte(0x22); + let amount = U256::from(1_000_000u64); + let calldata = build_erc20_transfer_calldata(recipient, amount); + + assert_eq!(&calldata[..4], &[0xa9, 0x05, 0x9c, 0xbb]); + assert_eq!(&calldata[4..16], &[0u8; 12]); + assert_eq!(&calldata[16..36], recipient.as_slice()); + assert_eq!(&calldata[36..68], amount.to_be_bytes::<32>()); + } + #[test] fn order_uid_layout() { let digest = B256::repeat_byte(0x11); diff --git a/crates/deckard-core/src/lib.rs b/crates/deckard-core/src/lib.rs index 8c4bd13..b99528c 100644 --- a/crates/deckard-core/src/lib.rs +++ b/crates/deckard-core/src/lib.rs @@ -75,9 +75,9 @@ pub use chain::{ // CoW order machinery, re-exported so the daemon + app + MCP can build/sign/cancel orders and // decode shaped approvals through core without naming the `cow_types` path directly. pub use cow_types::{ - apply_slippage, build_invalidate_order_calldata, cow_api_base, decode_approve, order_digest, - order_uid, APPROVE_SELECTOR, APP_DATA_DOC, APP_DATA_HASH, GPV2_SETTLEMENT, GPV2_VAULT_RELAYER, - ORDER_TYPE_HASH, + apply_slippage, build_erc20_transfer_calldata, build_invalidate_order_calldata, cow_api_base, + decode_approve, order_digest, order_uid, APPROVE_SELECTOR, APP_DATA_DOC, APP_DATA_HASH, + GPV2_SETTLEMENT, GPV2_VAULT_RELAYER, ORDER_TYPE_HASH, }; // The orderbook REST client + its serde types + pure parse helpers, re-exported only when the // `cow-client` feature is on (the daemon, built without it, never sees these symbols). diff --git a/crates/deckard-signerd/src/daemon.rs b/crates/deckard-signerd/src/daemon.rs index 9dab1c3..88c0619 100644 --- a/crates/deckard-signerd/src/daemon.rs +++ b/crates/deckard-signerd/src/daemon.rs @@ -536,6 +536,17 @@ impl Daemon { // the normal Send caps path below (the broadcast carries `intent.calldata` as-is). if intent.kind == IntentKind::ContractCall && intent.token.is_none() { if let Some((spender, amount)) = deckard_core::decode_approve(&intent.calldata) { + if matches!(origin, ProposalOrigin::App) { + if intent.value != U256::ZERO { + return Decision::Deny { + reason: deny_reasons::APPROVE_WITH_VALUE.into(), + }; + } + // Browser/dapp approvals are clear-signable as an exact approve tuple and + // must always raise a human card. Agent-origin swap approvals keep the + // stricter shaped-approve gate below. + return self.finish_propose(intent, true, origin); + } if let Some(deny) = self.shaped_approve_admission(intent, spender, amount) { return deny; } @@ -556,13 +567,14 @@ impl Daemon { reason: deny_reasons::UNSUPPORTED_V1.into(), }; } - // v1 spine is native ETH only; an ERC-20 (`token = Some`) Send is a fast-follow. - // A native shield is `token: None` (the value rides as msg.value via RelayAdapt - // wrapBase), so it passes this guard. - if intent.token.is_some() { - return Decision::Deny { - reason: deny_reasons::ERC20_UNSUPPORTED_V1.into(), - }; + if intent.kind == IntentKind::Send && intent.token.is_some() { + if !intent.calldata.is_empty() { + return Decision::Deny { + reason: deny_reasons::UNDECODABLE.into(), + }; + } + // ERC-20 value is token atoms, not wei; do not compare it to ETH caps or auto-allow. + return self.finish_propose(intent, true, origin); } // A Shield must target the chain's RelayAdapt contract. The contract crate's policy // gate deliberately can't express this (it is chain-blind); without the pre-check a @@ -1240,7 +1252,7 @@ impl Daemon { // Phase 1 (lock held): TOCTOU re-check + eligibility, then extract tx params and the // raw scalar (transiently, into `Zeroizing`). Borrows end before the await. - let (to, value, calldata, scalar) = { + let (to, value, calldata, reserve_value, scalar) = { let vault = match &self.state { // STOP landed first — refuse even a previously-approved request. VaultState::Locked => { @@ -1312,11 +1324,21 @@ impl Daemon { }; // Only the version-stable raw scalar crosses into our alloy stack; zeroized on drop. let scalar = Zeroizing::new(signer.to_bytes().0); - // Calldata is empty for a native Send (→ broadcast is byte-identical to before) and - // carries the RelayAdapt call for a Shield (or the shaped approve). The empty-vs- - // non-empty input IS the native/contract-call discriminator, so no IntentKind branch - // is needed here. - (intent.to, intent.value, intent.calldata.clone(), scalar) + let (to, value, calldata, reserve_value) = match (&intent.kind, intent.token) { + (IntentKind::Send, Some(token)) => ( + token, + U256::ZERO, + deckard_core::build_erc20_transfer_calldata(intent.to, intent.value), + U256::ZERO, + ), + _ => ( + intent.to, + intent.value, + intent.calldata.clone(), + intent.value, + ), + }; + (to, value, calldata, reserve_value, scalar) }; // Reserve the spend DURABLY before releasing the signature (issue #108): a crash between @@ -1330,7 +1352,6 @@ impl Daemon { // TODO(#108 follow-up): if the STOP latency bites on a slow/contended disk, move these two // fsyncs off the reactor via `tokio::task::spawn_blocking` instead of widening the brake's // critical section. Deliberately NOT an issue yet — revisit only if measured latency hurts. - let reserve_value = value; if !reserve_value.is_zero() { if let Err(e) = self.spend.reserve(reserve_value) { eprintln!("signerd: ⚠ spend reserve failed ({e}); refusing to sign (fail-closed)"); diff --git a/crates/deckard-signerd/tests/daemon_e2e.rs b/crates/deckard-signerd/tests/daemon_e2e.rs index 54b0b61..6a185ac 100644 --- a/crates/deckard-signerd/tests/daemon_e2e.rs +++ b/crates/deckard-signerd/tests/daemon_e2e.rs @@ -151,15 +151,13 @@ async fn propose_decision_matrix() { } ); - // ERC-20 send (token = Some) is a fast-follow. + // ERC-20 sends (token = Some) are admitted as reviewed browser transactions. let mut erc20 = send(to, 1_000); erc20.token = Some(Address::repeat_byte(0xEE)); - assert_eq!( + assert!(matches!( client.propose(&erc20, ProposalOrigin::App).await.unwrap(), - Decision::Deny { - reason: "erc20_unsupported_v1".into() - } - ); + Decision::NeedsApproval { .. } + )); } #[tokio::test] diff --git a/crates/deckard-signerd/tests/erc20_browser_tx.rs b/crates/deckard-signerd/tests/erc20_browser_tx.rs new file mode 100644 index 0000000..0e7ea18 --- /dev/null +++ b/crates/deckard-signerd/tests/erc20_browser_tx.rs @@ -0,0 +1,85 @@ +mod common; + +use alloy_primitives::{Address, Bytes, U256}; +use deckard_contract::{Decision, Intent, IntentKind, ProposalOrigin}; +use deckard_signerd::SignerClient; + +use common::*; + +const SEPOLIA: u64 = 11_155_111; +const DUMMY_RPC: &str = "http://127.0.0.1:1"; + +fn erc20_transfer_intent(token: Address, recipient: Address, amount: u64) -> Intent { + Intent { + chain_id: SEPOLIA, + to: recipient, + token: Some(token), + value: U256::from(amount), + calldata: Bytes::new(), + kind: IntentKind::Send, + } +} + +fn approve_calldata(spender: Address, amount: U256) -> Bytes { + let mut data = vec![0x09, 0x5e, 0xa7, 0xb3]; + let mut spender_word = [0u8; 32]; + spender_word[12..].copy_from_slice(spender.as_slice()); + data.extend_from_slice(&spender_word); + data.extend_from_slice(&amount.to_be_bytes::<32>()); + Bytes::from(data) +} + +fn approve_intent(token: Address, spender: Address, amount: u64) -> Intent { + Intent { + chain_id: SEPOLIA, + to: token, + token: None, + value: U256::ZERO, + calldata: approve_calldata(spender, U256::from(amount)), + kind: IntentKind::ContractCall, + } +} + +#[tokio::test] +async fn erc20_transfer_send_is_admitted_as_human_review_transaction() { + let dir = TempDir::new("erc20-transfer-admit"); + let (_wallet, recipient) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, SEPOLIA, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let decision = client + .propose( + &erc20_transfer_intent(Address::repeat_byte(0xa0), recipient, 1_000_000), + ProposalOrigin::App, + ) + .await + .unwrap(); + + assert!( + matches!(decision, Decision::NeedsApproval { .. }), + "ERC-20 transfer must raise a human card, got {decision:?}" + ); +} + +#[tokio::test] +async fn browser_origin_erc20_approve_is_admitted_as_human_review_transaction() { + let dir = TempDir::new("erc20-approve-admit"); + let (_wallet, spender) = seal_account0(dir.path()); + let d = spawn_daemon(dir.path(), DUMMY_RPC, SEPOLIA, &[]); + let client = SignerClient::new(d.socket_path.clone()); + client.unlock(PASS).await.unwrap(); + + let decision = client + .propose( + &approve_intent(Address::repeat_byte(0xa0), spender, 1_000_000), + ProposalOrigin::App, + ) + .await + .unwrap(); + + assert!( + matches!(decision, Decision::NeedsApproval { .. }), + "ERC-20 approve must raise a human card, got {decision:?}" + ); +} diff --git a/execplans/issue-142-erc20-send-transaction.md b/execplans/issue-142-erc20-send-transaction.md new file mode 100644 index 0000000..fd116b9 --- /dev/null +++ b/execplans/issue-142-erc20-send-transaction.md @@ -0,0 +1,115 @@ +# Issue #142 — ERC-20 `eth_sendTransaction` classifier + +## 1. Title + +Support narrow ERC-20 `transfer(address,uint256)` and `approve(address,uint256)` browser-bridge transaction shapes. + +## 2. Context + +PR #141 added native `eth_sendTransaction` through the browser bridge and intentionally refused non-empty calldata. The next WalletBeat/dapp compatibility step is to admit only the first two ERC-20 calldata shapes that can be reviewed plainly: + +- `transfer(address,uint256)` selector `0xa9059cbb` +- `approve(address,uint256)` selector `0x095ea7b3` + +This remains security-sensitive wallet RPC work. The bridge must parse and classify but must not sign, broadcast, or silently pass arbitrary calldata. Production signing/broadcast remains in `deckard-signerd`. + +## 3. Source Of Truth + +- Issue #142: browser bridge ERC-20 transfer/approve classifier. +- Issue #93: WalletBeat local-chain transaction/signature QA lane. +- `crates/deckard-browser-bridge/src/lib.rs`. +- `crates/deckard-contract/src/intent.rs` and `src/policy.rs`. +- `crates/deckard-signerd/src/daemon.rs`. +- `scripts/walletbeat-transactions-qa.mjs`. +- `tests/extension/browser-bridge-extension.spec.ts`. + +## 4. Current State + +- Native sends lower to `IntentKind::Send { token: None, calldata: empty }`. +- Non-empty browser calldata is refused before signerd. +- `Intent` already has `token: Option
` for ERC-20 sends. +- `deckard-signerd` currently denies `token.is_some()` sends with `erc20_unsupported_v1`. +- The daemon already has a structured `PendingPayloadView::Approve` for exact approve calldata. +- Existing app rendering can show token sends generically as `tokens` and approvals as structured approve cards. + +## 5. Target State + +- Browser bridge decodes ERC-20 `transfer` calldata and lowers it to: + - `IntentKind::Send` + - `to = transfer recipient` + - `token = Some(token contract)` + - `value = token amount` + - `calldata = empty` +- Browser bridge decodes ERC-20 `approve` calldata and lowers it to: + - `IntentKind::ContractCall` + - `to = token contract` + - `token = None` + - `value = 0` + - `calldata = original exact approve calldata` +- Browser bridge refuses: + - malformed calldata length/ABI words, + - unknown selectors, + - non-zero native value with ERC-20 calldata, + - missing/mismatched `from`, + - Aave/Safe/multisend/EIP-5792/arbitrary calldata. +- signerd admits ERC-20 token sends and generic exact approvals only as human-review transactions, never auto-allow. +- Execution for ERC-20 token sends broadcasts `transfer(recipient, amount)` to the token contract with `msg.value = 0`. +- Execution for approvals broadcasts the exact approve calldata to the token contract with `msg.value = 0`. + +## 6. Security Invariants + +- The bridge remains key-less. +- Unknown calldata remains fail-closed. +- ERC-20 classifier admission is exact selector + exact ABI length only. +- ERC-20 transactions always require a human card; token amounts are not compared to ETH caps. +- No native ETH is sent alongside ERC-20 transfer/approve calldata. +- Production signing and broadcast stay exclusively in signerd. + +## 7. TDD Plan + +1. Add failing browser-bridge tests: + - ERC-20 transfer calldata returns a dev tx hash. + - ERC-20 approve calldata returns a dev tx hash. + - unknown selector stays refused. + - malformed ERC-20 calldata stays refused. + - ERC-20 calldata with native value is refused. +2. Add/adjust signerd tests for token send / generic approve admission and broadcast shaping if necessary. +3. Implement bridge calldata classifier. +4. Implement signerd token-send broadcast shaping and generic approve admission. +5. Extend WalletBeat transaction QA to exercise native send + ERC-20 transfer + ERC-20 approve. +6. Run focused tests, QA lanes, and full DoD. + +## 8. Validation + +Focused: + +```text +cargo test -p deckard-browser-bridge erc20 -- --nocapture +cargo test -p deckard-signerd erc20 -- --nocapture +pnpm run qa:walletbeat:transactions +``` + +Full DoD: + +```text +cargo fmt --all --check +just check +cargo test --workspace +pnpm run qa:extension +pnpm run qa:walletbeat +pnpm run qa:walletbeat:signatures +pnpm run qa:walletbeat:transactions +git diff --check +``` + +## 9. Status + +- [x] Issue #142 created and added to the GitHub Project. +- [x] Branch created from merged `origin/main`. +- [x] Plan created. +- [x] RED tests added and observed failing. +- [x] Bridge classifier implemented. +- [x] signerd execution/admission implemented. +- [x] WalletBeat transaction QA extended. +- [x] Full local DoD. +- [ ] PR opened and CI checked. diff --git a/scripts/walletbeat-transactions-qa.mjs b/scripts/walletbeat-transactions-qa.mjs index 13bebc5..846b885 100644 --- a/scripts/walletbeat-transactions-qa.mjs +++ b/scripts/walletbeat-transactions-qa.mjs @@ -83,6 +83,22 @@ async function main() { method: 'eth_sendTransaction', params: [{ from: account, to: '0x0000000000000000000000000000000000000001', value: '0x1' }], }); + const transferHash = await provider.request({ + method: 'eth_sendTransaction', + params: [{ + from: account, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + data: '0xa9059cbb00000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240', + }], + }); + const approveHash = await provider.request({ + method: 'eth_sendTransaction', + params: [{ + from: account, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + data: '0x095ea7b300000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240', + }], + }); let contractCallError = null; try { await provider.request({ @@ -90,7 +106,7 @@ async function main() { params: [{ from: account, to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - data: '0x095ea7b300000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240', + data: '0xdeadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', }], }); } catch (error) { @@ -99,7 +115,7 @@ async function main() { message: error instanceof Error ? error.message : String(error), }; } - return { accounts, activeChain, nativeHash, contractCallError }; + return { accounts, activeChain, nativeHash, transferHash, approveHash, contractCallError }; }, { account: mockAccount }); const checks = [ @@ -109,8 +125,18 @@ async function main() { detail: results.nativeHash, }, { - name: 'ERC-20 contract call refused until clear-signing classifier exists', - passed: results.contractCallError?.code === 4200 && /contract calldata/.test(results.contractCallError?.message ?? ''), + name: 'ERC-20 transfer(address,uint256)', + passed: typeof results.transferHash === 'string' && /^0x[0-9a-f]{64}$/.test(results.transferHash), + detail: results.transferHash, + }, + { + name: 'ERC-20 approve(address,uint256)', + passed: typeof results.approveHash === 'string' && /^0x[0-9a-f]{64}$/.test(results.approveHash), + detail: results.approveHash, + }, + { + name: 'unknown contract calldata refused', + passed: results.contractCallError?.code === 4200 && /unsupported transaction calldata/.test(results.contractCallError?.message ?? ''), detail: results.contractCallError, }, { diff --git a/tests/extension/browser-bridge-extension.spec.ts b/tests/extension/browser-bridge-extension.spec.ts index 8bbfb4a..8a7b12b 100644 --- a/tests/extension/browser-bridge-extension.spec.ts +++ b/tests/extension/browser-bridge-extension.spec.ts @@ -215,7 +215,7 @@ test('local dapp can request reviewed message signatures in dev mode', async ({ }); }); -test('local dapp can request a native send transaction in dev mode', async ({ page }) => { +test('local dapp can request reviewed send transactions in dev mode', async ({ page }) => { await page.goto('/'); await expect(page.locator('#output')).toContainText('window.ethereum detected'); @@ -225,16 +225,36 @@ test('local dapp can request a native send transaction in dev mode', async ({ pa throw new Error('window.ethereum missing'); } await provider.request({ method: 'eth_requestAccounts' }); - const txHash = await provider.request({ + const nativeHash = await provider.request({ method: 'eth_sendTransaction', params: [{ from: account, to: '0x0000000000000000000000000000000000000001', value: '0x1' }], }); + const transferHash = await provider.request({ + method: 'eth_sendTransaction', + params: [{ + from: account, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + data: '0xa9059cbb00000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240', + }], + }); + const approveHash = await provider.request({ + method: 'eth_sendTransaction', + params: [{ + from: account, + to: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + data: '0x095ea7b300000000000000000000000087870bca3f3fd6335c3f4ce8392d69350b4fa4e200000000000000000000000000000000000000000000000000000000000f4240', + }], + }); let contractCallError: { code?: number; message?: string } | null = null; try { await provider.request({ method: 'eth_sendTransaction', - params: [{ from: account, to: '0x0000000000000000000000000000000000000001', data: '0xa9059cbb' }], + params: [{ + from: account, + to: '0x0000000000000000000000000000000000000001', + data: '0xdeadbeef00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001', + }], }); } catch (error) { contractCallError = { @@ -243,13 +263,15 @@ test('local dapp can request a native send transaction in dev mode', async ({ pa }; } - return { txHash, contractCallError }; + return { nativeHash, transferHash, approveHash, contractCallError }; }, mockAccount); - expect(state.txHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(state.nativeHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(state.transferHash).toMatch(/^0x[0-9a-f]{64}$/); + expect(state.approveHash).toMatch(/^0x[0-9a-f]{64}$/); expect(state.contractCallError).toEqual({ code: 4200, - message: expect.stringContaining('contract calldata'), + message: expect.stringContaining('unsupported transaction calldata'), }); });