Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 183 additions & 14 deletions crates/deckard-browser-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -501,28 +503,81 @@ 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 {
chain_id,
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<Intent, BridgeError> {
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<Address, BridgeError> {
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<Vec<Value>, BridgeError> {
match params {
Value::Array(values) => Ok(values),
Expand Down Expand Up @@ -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(
Expand All @@ -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]
Expand Down
22 changes: 22 additions & 0 deletions crates/deckard-core/src/cow_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions crates/deckard-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
49 changes: 35 additions & 14 deletions crates/deckard-signerd/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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
Expand All @@ -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)");
Expand Down
10 changes: 4 additions & 6 deletions crates/deckard-signerd/tests/daemon_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading