diff --git a/crates/deckard-contract/src/clear_signing.rs b/crates/deckard-contract/src/clear_signing.rs new file mode 100644 index 0000000..56b7694 --- /dev/null +++ b/crates/deckard-contract/src/clear_signing.rs @@ -0,0 +1,325 @@ +//! ERC-7730 clear-signing descriptor spike. +//! +//! The types here intentionally model only the small subset Deckard needs to prove the +//! consumption path: bind descriptor context first, then normalize user-facing intent + field rows. +//! A descriptor is display metadata, not a security oracle. + +use std::collections::BTreeMap; +use std::str::FromStr; + +use alloy_primitives::Address; +use serde::{Deserialize, Serialize}; + +/// Minimal ERC-7730 descriptor model. Unknown fields are ignored by serde so draft-version additions +/// do not make old Deckard builds fail closed before context binding and format validation run. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Descriptor { + #[serde(rename = "$schema")] + pub schema: Option, + pub context: Erc7730Context, + #[serde(default)] + pub metadata: Erc7730Metadata, + pub display: Erc7730Display, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Context { + pub contract: Option, + /// Kept as a typed presence bit for the spike. Full EIP-712 message binding is future work. + #[serde(default)] + pub messages: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730ContractContext { + #[serde(default)] + pub deployments: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Deployment { + #[serde(rename = "chainId")] + pub chain_id: u64, + pub address: String, +} + +/// Placeholder for the message-binding half of ERC-7730. The map is intentionally opaque in this +/// spike so unknown draft fields do not get rendered as trusted UI. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Erc7730MessageContext {} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Metadata { + pub owner: Option, + #[serde(rename = "contractName")] + pub contract_name: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Display { + #[serde(default)] + pub formats: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Format { + pub intent: String, + #[serde(rename = "interpolatedIntent")] + pub interpolated_intent: Option, + #[serde(default)] + pub fields: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Erc7730Field { + pub path: String, + pub label: String, + pub format: String, + /// Minimal parameter support for common ERC-7730 descriptors such as `tokenPath`. + #[serde(default)] + pub params: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClearSigningReview { + pub intent: String, + pub interpolated_intent: Option, + pub owner: Option, + pub contract_name: Option, + pub fields: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClearSigningField { + pub path: String, + pub label: String, + pub format: ClearSigningFieldFormat, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClearSigningFieldFormat { + Raw, + AddressName, + TokenAmount { token_path: Option }, + Amount, + Date, + String, + Bytes, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClearSigningFallback { + pub reason: ClearSigningError, + pub warning: &'static str, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClearSigningError { + DescriptorMissing, + DescriptorInvalid, + UnsupportedMessageContext, + ContextMismatch, + FormatMissing, + EmptyIntent, + EmptyFieldLabel, + EmptyFieldPath, + UnsupportedFieldFormat(String), +} + +/// Normalize an ERC-7730 contract-call descriptor only after binding it to the reviewed chain and +/// target contract. The `format_key` is the ERC-7730 display key, e.g. +/// `transfer(address to,uint256 value)`. +pub fn normalize_contract_call_descriptor( + descriptor: &Erc7730Descriptor, + chain_id: u64, + verifying_contract: Address, + format_key: &str, +) -> Result { + let Some(contract) = &descriptor.context.contract else { + return Err(ClearSigningError::UnsupportedMessageContext); + }; + + if !deployment_matches(contract, chain_id, verifying_contract) { + return Err(ClearSigningError::ContextMismatch); + } + + let Some(format) = descriptor.display.formats.get(format_key) else { + return Err(ClearSigningError::FormatMissing); + }; + + if format.intent.trim().is_empty() { + return Err(ClearSigningError::EmptyIntent); + } + + let fields = format + .fields + .iter() + .map(normalize_field) + .collect::, _>>()?; + + Ok(ClearSigningReview { + intent: format.intent.clone(), + interpolated_intent: format.interpolated_intent.clone(), + owner: descriptor.metadata.owner.clone(), + contract_name: descriptor.metadata.contract_name.clone(), + fields, + }) +} + +pub fn clear_signing_fallback(reason: ClearSigningError) -> ClearSigningFallback { + ClearSigningFallback { + reason, + warning: + "Clear-signing descriptor unavailable or unsafe to apply; show blind-signing warning.", + } +} + +fn deployment_matches( + contract: &Erc7730ContractContext, + chain_id: u64, + verifying_contract: Address, +) -> bool { + contract.deployments.iter().any(|deployment| { + if deployment.chain_id != chain_id { + return false; + } + Address::from_str(&deployment.address) + .map(|address| address == verifying_contract) + .unwrap_or(false) + }) +} + +fn normalize_field(field: &Erc7730Field) -> Result { + if field.label.trim().is_empty() { + return Err(ClearSigningError::EmptyFieldLabel); + } + if field.path.trim().is_empty() { + return Err(ClearSigningError::EmptyFieldPath); + } + + Ok(ClearSigningField { + path: field.path.clone(), + label: field.label.clone(), + format: normalize_field_format(field)?, + }) +} + +fn normalize_field_format( + field: &Erc7730Field, +) -> Result { + match field.format.as_str() { + "raw" => Ok(ClearSigningFieldFormat::Raw), + "addressName" => Ok(ClearSigningFieldFormat::AddressName), + "tokenAmount" => Ok(ClearSigningFieldFormat::TokenAmount { + token_path: field.params.get("tokenPath").cloned(), + }), + "amount" => Ok(ClearSigningFieldFormat::Amount), + "date" => Ok(ClearSigningFieldFormat::Date), + "string" => Ok(ClearSigningFieldFormat::String), + "bytes" => Ok(ClearSigningFieldFormat::Bytes), + other => Err(ClearSigningError::UnsupportedFieldFormat(other.into())), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const USDT: &str = "0xdAC17F958D2ee523a2206206994597C13D831ec7"; + + fn descriptor() -> Erc7730Descriptor { + serde_json::from_str(include_str!( + "../tests/fixtures/erc7730-valid-transfer.json" + )) + .expect("valid fixture parses") + } + + #[test] + fn descriptor_present_normalizes_review_rows() { + let contract = Address::from_str(USDT).expect("address parses"); + let review = normalize_contract_call_descriptor( + &descriptor(), + 1, + contract, + "transfer(address to,uint256 value)", + ) + .expect("descriptor applies"); + + assert_eq!(review.intent, "Send"); + assert_eq!(review.owner.as_deref(), Some("Example")); + assert_eq!(review.contract_name.as_deref(), Some("Example Token")); + assert_eq!(review.fields.len(), 2); + assert_eq!( + review.fields, + vec![ + ClearSigningField { + path: "value".into(), + label: "Amount".into(), + format: ClearSigningFieldFormat::TokenAmount { + token_path: Some("@.to".into()), + }, + }, + ClearSigningField { + path: "to".into(), + label: "To".into(), + format: ClearSigningFieldFormat::AddressName, + }, + ] + ); + } + + #[test] + fn context_mismatch_falls_back_before_rendering() { + let other_contract = Address::repeat_byte(0x11); + let err = normalize_contract_call_descriptor( + &descriptor(), + 1, + other_contract, + "transfer(address to,uint256 value)", + ) + .expect_err("wrong contract must not render descriptor labels"); + + assert_eq!(err, ClearSigningError::ContextMismatch); + let fallback = clear_signing_fallback(err); + assert_eq!(fallback.reason, ClearSigningError::ContextMismatch); + assert!(fallback.warning.contains("blind-signing")); + } + + #[test] + fn invalid_descriptor_falls_back_to_blind_warning() { + let parse_err = serde_json::from_str::(include_str!( + "../tests/fixtures/erc7730-invalid-missing-display.json" + )); + assert!(parse_err.is_err()); + + let fallback = clear_signing_fallback(ClearSigningError::DescriptorInvalid); + assert_eq!(fallback.reason, ClearSigningError::DescriptorInvalid); + assert!(fallback.warning.contains("unsafe to apply")); + } + + #[test] + fn unsupported_format_is_explicit_not_silent() { + let mut descriptor = descriptor(); + let format = descriptor + .display + .formats + .get_mut("transfer(address to,uint256 value)") + .expect("format exists"); + let field = format.fields.first_mut().expect("field exists"); + field.format = "magicRiskHidingFormat".into(); + + let contract = Address::from_str(USDT).expect("address parses"); + let err = normalize_contract_call_descriptor( + &descriptor, + 1, + contract, + "transfer(address to,uint256 value)", + ) + .expect_err("unsupported field format must fail closed"); + + assert_eq!( + err, + ClearSigningError::UnsupportedFieldFormat("magicRiskHidingFormat".into()) + ); + } +} diff --git a/crates/deckard-contract/src/lib.rs b/crates/deckard-contract/src/lib.rs index bc008a5..cf50145 100644 --- a/crates/deckard-contract/src/lib.rs +++ b/crates/deckard-contract/src/lib.rs @@ -22,6 +22,7 @@ //! way: a bare number literal above `u64::MAX` — routine for wei (> ~18.4 ETH) — is parsed //! as a float and rejected on decode. CBOR has no such limit. +pub mod clear_signing; pub mod decision; pub mod deny_reasons; pub mod intent; @@ -33,6 +34,11 @@ pub mod shield_status; pub mod signer; pub mod swap_order; +pub use clear_signing::{ + clear_signing_fallback, normalize_contract_call_descriptor, ClearSigningError, + ClearSigningFallback, ClearSigningField, ClearSigningFieldFormat, ClearSigningReview, + Erc7730Descriptor, +}; pub use decision::{Decision, RequestId}; pub use intent::{Intent, IntentKind}; pub use mock::MockSigner; diff --git a/crates/deckard-contract/tests/fixtures/erc7730-invalid-missing-display.json b/crates/deckard-contract/tests/fixtures/erc7730-invalid-missing-display.json new file mode 100644 index 0000000..094cfd0 --- /dev/null +++ b/crates/deckard-contract/tests/fixtures/erc7730-invalid-missing-display.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "context": { + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + } + ] + } + }, + "metadata": { + "owner": "Example", + "contractName": "Example Token" + } +} diff --git a/crates/deckard-contract/tests/fixtures/erc7730-valid-transfer.json b/crates/deckard-contract/tests/fixtures/erc7730-valid-transfer.json new file mode 100644 index 0000000..fe0aad8 --- /dev/null +++ b/crates/deckard-contract/tests/fixtures/erc7730-valid-transfer.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://eips.ethereum.org/assets/eip-7730/erc7730-v2.schema.json", + "context": { + "contract": { + "deployments": [ + { + "chainId": 1, + "address": "0xdAC17F958D2ee523a2206206994597C13D831ec7" + } + ] + } + }, + "metadata": { + "owner": "Example", + "contractName": "Example Token" + }, + "display": { + "formats": { + "transfer(address to,uint256 value)": { + "intent": "Send", + "interpolatedIntent": "Send {value} to {to}", + "fields": [ + { + "path": "value", + "label": "Amount", + "format": "tokenAmount", + "params": { + "tokenPath": "@.to" + } + }, + { + "path": "to", + "label": "To", + "format": "addressName" + } + ] + } + } + } +} diff --git a/docs/adr/0005-erc-7730-clear-signing.md b/docs/adr/0005-erc-7730-clear-signing.md new file mode 100644 index 0000000..275d0a7 --- /dev/null +++ b/docs/adr/0005-erc-7730-clear-signing.md @@ -0,0 +1,72 @@ +# ADR 0005: ERC-7730 clear-signing descriptor consumption + +## Status + +Accepted as a spike result for issue #65. + +## Context + +Deckard needs a path for EIP-712 and contract-call clear signing that can consume emerging ERC-7730 descriptors without making descriptor metadata part of the signing security boundary. + +ERC-7730 descriptors have three useful parts for Deckard: + +- `context`: binding rules that say which chain/contract or typed-data message the descriptor applies to. +- `metadata`: public project/contract details that may help orient the signer. +- `display.formats`: intent labels and field formatting rules for a specific function or message. + +The Clear Signing build docs emphasize that wallets must enforce their own trust policy. Registry entries can be missing, low-quality, stale, or malicious. A wallet decides which descriptor source and review signals are acceptable before showing descriptor-enhanced signing UI. + +## Decision + +Deckard will use a normalized internal `ClearSigningReview` representation rather than render ERC-7730 JSON directly. + +The consumption path is: + +1. A resolver fetches or loads candidate ERC-7730 descriptors from a trusted source configured by Deckard. +2. The parser decodes descriptor JSON into typed structures. +3. The normalizer binds the descriptor to the reviewed data before any user-facing labels are used: + - for calldata: `chainId` and `to`/verifying contract must match a `context.contract.deployments` entry; + - for EIP-712 messages: the message/domain binding must match before rendering (future work). +4. The normalizer emits a small Deckard-owned review model: intent label, optional owner/contract name, and ordered field rows with labels, paths, and supported format kinds. +5. The GPUI clear-signing card renders only the normalized model, never arbitrary descriptor JSON. + +Descriptor metadata is advisory display metadata only. It is not proof that a contract is safe, that a frontend is honest, or that a signature should be approved. + +## Fallback behavior + +Deckard must show an explicit blind/undecodable signing warning when: + +- no descriptor is available; +- descriptor JSON is invalid; +- context binding fails; +- the requested function/message format is missing; +- a field format is unsupported; +- future schema features cannot be interpreted safely. + +The fallback state still allows the human review flow to exist, but it must not render as a calm decoded transaction. + +## Trust, versioning, and cache rules + +- A descriptor source is a trust input and must be configured/reviewed as such. +- Cached descriptors need source identity, schema/version, fetch time, and invalidation behavior before production use. +- Registry poisoning and stale descriptors are expected attack paths, not edge cases. +- Interpolated intent strings must be treated carefully: unresolved or attacker-shaped substitutions must degrade to explicit field rows or fallback. + +## Scope of this spike + +The code added for issue #65 implements the typed descriptor subset and normalizer needed to prove the architecture: + +- contract deployment context binding; +- metadata owner/contract name; +- format intent and fields; +- supported format taxonomy; +- explicit fallback states; +- fixtures and tests for descriptor-present and invalid/mismatched fallback. + +It does not implement descriptor fetching, registry governance, full JSON-schema validation, EIP-712 domain matching, calldata decoding, or GPUI rendering. + +## Consequences + +- Deckard gets an internal seam for clear-signing descriptors without committing to a registry or schema interpreter yet. +- Future EIP-712/message-signing work can extend the same normalized model. +- Review code can stay security-oriented: bind first, render second, fall back loudly. diff --git a/execplans/issue-65-erc-7730-clear-signing.md b/execplans/issue-65-erc-7730-clear-signing.md new file mode 100644 index 0000000..511db13 --- /dev/null +++ b/execplans/issue-65-erc-7730-clear-signing.md @@ -0,0 +1,100 @@ +# Issue #65 — ERC-7730 Clear-Signing Descriptor Spike + +## 1. Title + +Issue #65 — consume ERC-7730 descriptors for clear signing. + +## 2. Context + +Deckard's clear-signing UX currently has purpose-built surfaces for shield/swap and CoW EIP-712 digest machinery, but it does not have a generic path for ERC-7730 clear-signing metadata. Issue #65 asks for a first-class spike so Deckard can align message-signing UX with ERC-7730 instead of inventing a local schema. + +This is discovery plus a small parser/normalizer spike. It affects the key-less typed-data / transaction review layer, not signing authority. + +## 3. Source Of Truth + +- User instructions: start work on GitHub issue #65 after closing #62. +- GitHub issue: https://github.com/hellno/deckard/issues/65 +- Repo guidance: `AGENTS.md`, `PLANS.md` +- Design guidance: `DESIGN.md` clear-signing review language, but this spike does not change UI rendering. +- ADRs / docs: `docs/WALLETBEAT-COMPATIBILITY.md`, `docs/adr/0001-dapp-connectivity-architecture.md` +- Relevant code files: `crates/deckard-contract/src/*`, `crates/deckard-core/src/cow_types.rs` +- External standards: ERC-7730 (`https://eips.ethereum.org/EIPS/eip-7730`), Clear Signing build docs (`https://clearsigning.org/build/`) + +## 4. Current State Analysis + +- `deckard-contract::Intent` supports generic `ContractCall`, but there is no structured review model for arbitrary calldata or EIP-712 messages. +- `deckard-core::cow_types` computes a CoW order EIP-712 digest, but this is protocol-specific and not a general descriptor consumer. +- ERC-7730 describes descriptor context, metadata, and display formats. Wallets must bind descriptors to the reviewed chain/contract/message before applying labels. +- Clear Signing docs emphasize wallet-owned trust policy: registry metadata can be low-quality or malicious; wallets decide what reaches the signing screen. + +## 5. Target State + +- Add a small typed ERC-7730 descriptor model and normalizer spike. +- Normalize only the minimum Deckard needs now: context binding, metadata owner/contract name, display intent, and ordered field labels/paths/formats. +- Provide explicit fallback states for descriptor-missing, descriptor-invalid, context-mismatch, format-missing, and unsupported-format cases. +- Add fixtures for descriptor-present and descriptor-invalid fallback. +- Add a short ADR/design note that records where descriptor lookup/parsing should live and what Deckard must not trust. + +## 6. Security And Trust Invariants + +- ERC-7730 metadata is never a security oracle. +- Descriptor formatting is applied only after chain/address or message context binding succeeds. +- Missing, invalid, mismatched, or unsupported descriptors fall back to blind/undecodable signing warnings. +- Message signatures remain human-approved; no auto-approval is introduced. +- No seed, key, passphrase, or decrypted vault material is touched. + +## 7. UX And Design Constraints + +- This spike does not render GPUI screens, so screenshots are not required. +- Future UI must use `DESIGN.md` clear-signing primitives: transaction-as-hero, plain labels, caution line for warnings, and no calm rendering for blind-signing. +- Fallback copy should be plain: "Descriptor missing", "Descriptor invalid", "Descriptor does not match this contract", "Unsupported descriptor format". + +## 8. Execution Plan + +1. Add `clear_signing` module to `deckard-contract` with ERC-7730 descriptor structs and normalized review structs. +2. Implement context-bound normalization for EVM contract descriptors. +3. Add fallback helper and error taxonomy. +4. Add JSON fixtures for valid and invalid descriptors. +5. Add tests for descriptor-present rendering and invalid/mismatched fallback. +6. Add ADR describing the consumption path and trust rules. +7. Run `cargo fmt --all --check`, focused tests, `just check`, and `cargo test --workspace`. + +## 9. Validation Criteria + +Default Deckard Definition of Done: + +```text +cargo fmt --all --check +just check +cargo test --workspace +``` + +Task-specific checks: + +- `cargo test -p deckard-contract clear_signing` +- Tests prove descriptor-present normalization and descriptor-invalid fallback. + +## 10. Failure Signals + +- Descriptor labels render without context binding. +- Unknown formats are silently accepted as safe. +- Registry/source metadata is treated as trusted proof of contract safety. +- Parser changes pull heavy runtime dependencies into key-less binaries. + +## 11. Risks And Tradeoffs + +- This is intentionally a small normalizer, not a full ERC-7730 interpreter. +- It does not yet decode calldata or EIP-712 typed values; it prepares the review model and fallback policy. +- Full registry trust, caching, revocation, and schema validation remain future work. + +## 12. Out Of Scope + +- Hosting or building an ERC-7730 registry. +- Auto-approval of message signatures. +- GPUI rendering. +- Full calldata/EIP-712 value interpolation. +- Onchain registry or attestation verification. + +## 13. Status Notes + +- 2026-06-23: Created plan after #62 was verified and closed.