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
326 changes: 211 additions & 115 deletions crates/deckard-app/src/activity_view.rs

Large diffs are not rendered by default.

371 changes: 249 additions & 122 deletions crates/deckard-app/src/commit_view.rs

Large diffs are not rendered by default.

76 changes: 55 additions & 21 deletions crates/deckard-app/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,44 +34,51 @@ pub fn humanize_read_error(raw: &str) -> String {

/// Map a daemon deny/`reason` tag to a calm, user-facing line (the wire tags are terse +
/// machine-readable; the UI shouldn't show `chain_mismatch` raw).
///
/// The **shared** tags (broadcast/session/process — reachable by Send, Shield, AND swap-fallthrough)
/// are worded action-neutrally ("the transaction…"), so a plain Send never reads as a "deposit".
/// Only the genuinely shield-only tags (`shield_to_mismatch`, `erc20_unsupported_v1`) keep shield
/// wording, since a send/swap can't reach them. Swaps still route swap-specific tags through
/// [`humanize_swap_deny`] first.
pub fn humanize_deny(reason: &str) -> String {
// The broadcast error carries a variable RPC suffix, so match it by prefix.
if reason.starts_with("broadcast_failed") {
return "the deposit couldn't be broadcast — check your network, then review again".into();
return "the transaction couldn't be broadcast — check your network, then review again"
.into();
}
match reason {
"locked" => "unlock your wallet first".into(),
"revoked" => "the signer is paused (STOP is active)".into(),
"chain_mismatch" => {
"the signer is on a different chain than this deposit — reconcile the chain first"
"the signer is on a different chain than this transaction — reconcile the chain first"
.into()
}
"over_cap" | "cap_exceeded" => "it exceeds the agent's spending cap".into(),
"off_allowlist" => "the recipient isn't on the allowlist".into(),
"undecodable" => "the deposit calldata didn't validate".into(),
"undecodable" => "the transaction calldata didn't validate".into(),
// Shield-only: a send/swap never targets the Railgun contract, so keep the deposit wording.
"shield_to_mismatch" => {
"the deposit doesn't target the Railgun contract for this chain".into()
}
"not_approved" => "this deposit hasn't been approved yet — review it again".into(),
"unknown_request" => {
"the signer session was reset — review the deposit again".into()
}
"not_approved" => "this transaction hasn't been approved yet — review it again".into(),
"unknown_request" => "the signer session was reset — review the transaction again".into(),
// Shield-only: v1 only shields native ETH.
"erc20_unsupported_v1" => "only native-ETH shields are supported in v1".into(),
"unsupported_v1" => "that action isn't supported in v1".into(),
"broadcast_timeout" => {
"the network didn't confirm in time — your deposit may already be in flight, so check your activity before retrying"
"the network didn't confirm in time — your transaction may already be in flight, so check your activity before retrying"
.into()
}
"already_executed" => "this deposit was already submitted".into(),
"already_executed" => "this transaction was already submitted".into(),
other => other.to_string(),
}
}

/// Map a daemon deny `reason` to calm, **swap-specific** copy (#25). [`humanize_deny`] is
/// deposit/shield-worded ("the deposit…"), which reads wrong on a swap, so the swap path routes
/// its denies here. The swap-only policy/admission tags get distinct copy; anything else falls
/// through to [`humanize_deny`] so the shared session/process tags (`locked`, `chain_mismatch`,
/// `broadcast_*`, …) keep their single source of truth.
/// Map a daemon deny `reason` to calm, **swap-specific** copy (#25). The swap-only policy/admission
/// tags (order/approve) read wrong under [`humanize_deny`], so the swap path routes its denies here
/// for distinct copy; anything else falls through to [`humanize_deny`] so the shared session/process
/// tags (`locked`, `chain_mismatch`, `broadcast_*`, …) keep their single source of truth (now
/// action-neutral, so the fallthrough reads correctly for a swap too).
pub fn humanize_swap_deny(reason: &str) -> String {
match reason {
// --- order admission (deckard-contract::evaluate_order) ---
Expand Down Expand Up @@ -145,7 +152,7 @@ mod tests {
);
assert_eq!(
humanize_deny("chain_mismatch"),
"the signer is on a different chain than this deposit — reconcile the chain first"
"the signer is on a different chain than this transaction — reconcile the chain first"
);
// The two-tag arm collapses to one line.
assert_eq!(
Expand All @@ -162,19 +169,20 @@ mod tests {
);
assert_eq!(
humanize_deny("undecodable"),
"the deposit calldata didn't validate"
"the transaction calldata didn't validate"
);
// Shield-only tag: legitimately keeps deposit wording.
assert_eq!(
humanize_deny("shield_to_mismatch"),
"the deposit doesn't target the Railgun contract for this chain"
);
assert_eq!(
humanize_deny("not_approved"),
"this deposit hasn't been approved yet — review it again"
"this transaction hasn't been approved yet — review it again"
);
assert_eq!(
humanize_deny("unknown_request"),
"the signer session was reset — review the deposit again"
"the signer session was reset — review the transaction again"
);
assert_eq!(
humanize_deny("erc20_unsupported_v1"),
Expand All @@ -186,19 +194,45 @@ mod tests {
);
assert_eq!(
humanize_deny("broadcast_timeout"),
"the network didn't confirm in time — your deposit may already be in flight, so check your activity before retrying"
"the network didn't confirm in time — your transaction may already be in flight, so check your activity before retrying"
);
assert_eq!(
humanize_deny("already_executed"),
"this deposit was already submitted"
"this transaction was already submitted"
);
}

#[test]
fn shared_deny_tags_are_action_neutral_not_deposit_worded() {
// A plain Send reuses `humanize_deny`, so the multi-flow tags (broadcast/session/process)
// must NOT read "deposit" — a send isn't a deposit. Only the genuinely shield-only tags
// keep shield wording (a send/swap can't reach them).
for tag in [
"broadcast_failed",
"broadcast_failed: connection refused",
"chain_mismatch",
"undecodable",
"not_approved",
"unknown_request",
"broadcast_timeout",
"already_executed",
] {
let line = humanize_deny(tag).to_lowercase();
assert!(
!line.contains("deposit"),
"{tag} must be action-neutral, not deposit-worded: {line}"
);
}
// The shield-only tags legitimately keep shield/deposit wording.
assert!(humanize_deny("shield_to_mismatch").contains("deposit"));
assert!(humanize_deny("erc20_unsupported_v1").contains("shields"));
}

#[test]
fn humanize_deny_matches_broadcast_failed_by_prefix() {
// The broadcast error carries a variable RPC suffix, so any `broadcast_failed*` maps to
// the same calm line.
let line = "the deposit couldn't be broadcast — check your network, then review again";
let line = "the transaction couldn't be broadcast — check your network, then review again";
assert_eq!(humanize_deny("broadcast_failed"), line);
assert_eq!(
humanize_deny("broadcast_failed: connection refused (http://localhost:8545)"),
Expand Down
21 changes: 7 additions & 14 deletions crates/deckard-app/src/send_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,15 @@ pub static SEND_VIEW: CommitView = CommitView {
compose_hint_dynamic: None,

// --- review ---
review_title: "Review transfer",
review_subtitle: "Confirm the amount and the destination address, then send with ⌘↵.",
// No Railgun fee / private net line for a public send.
extra_rows: &[],
honesty: &[
HonestyLine {
text: "This transfer is public on Ethereum and can't be undone.",
emphasized: true,
danger: true,
},
HonestyLine {
text: "Double-check the destination address: funds sent to the wrong address are lost.",
emphasized: false,
danger: true,
},
],
// The shared Review renders the ONE canonical danger line ("This can't be undone.") itself, so
// the descriptor carries only the surface-specific amber caution below it (DESIGN §Clear-signing).
honesty: &[HonestyLine {
text: "Double-check the destination address; funds sent to the wrong address are lost.",
emphasized: true,
danger: false,
}],
hold_id: "send-hold",
hold_label_idle: "Send",
hold_label_busy: "Sending…",
Expand Down
2 changes: 0 additions & 2 deletions crates/deckard-app/src/shield_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ pub static SHIELD_VIEW: CommitView = CommitView {
compose_hint_dynamic: Some(shield_compose_hint),

// --- review ---
review_title: "Review deposit",
review_subtitle: "Confirm what leaves, where it goes, and the fee, then shield with ⌘↵.",
// The Railgun fee + the net private receipt, computed from the proposal's gross value.
extra_rows: &[
MoneyRow {
Expand Down
50 changes: 41 additions & 9 deletions crates/deckard-app/src/swap_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@ use gpui_component::{
v_flex, ActiveTheme, Disableable, Icon, IconName,
};

use deckard_contract::Rule;
use deckard_core::{tokens_for, Address, U256};

use crate::commit_view::{CommitView, HonestyLine};
use crate::money::money;
use crate::shell::{Shell, Surface};
use crate::theme;
use crate::widgets::{origin_header, Origin};

/// The Swap surface descriptor. Unlike Send/Shield it does NOT feed `render_commit` (Swap's
/// compose + review are bespoke — see [`Shell::render_swap`]); it carries the copy, ids, glyph tone,
Expand Down Expand Up @@ -66,8 +68,6 @@ pub static SWAP_VIEW: CommitView = CommitView {
compose_hint_dynamic: None,

// --- review (read by `commit_heading` on the review arm) ---
review_title: "Review swap",
review_subtitle: "Confirm what you sell, the minimum you receive, and where it goes, then swap with ⌘↵.",
// The bespoke review card builds its own token-denominated rows; the generic ETH money rows
// don't apply.
extra_rows: &[],
Expand Down Expand Up @@ -457,10 +457,33 @@ impl Shell {
let fg = theme.foreground;
let muted = theme.muted_foreground;
let border = theme.border;
let danger = theme.danger;
let mark_fill = theme::identity_square(theme.is_dark());
let mono = theme.mono_font_family.clone();
let chain_id = self.chain_id();

// The request-origin rail — a self-initiated swap is You (amber "You are swapping"). Built
// now, while `theme` is borrowed; the `self.*(cx)` calls below re-borrow it. This rail is
// the ONLY thing that differs across origins — the review body is the shared surface.
let wallet_name = self.wallet_name();
let origin_rail = origin_header(
Origin::You {
account: &wallet_name,
verb: "swapping",
},
None,
theme,
);
// The **Allowed by** line for a swap: the Swap rule permitted it. A swap ALWAYS asks (it
// never auto-allows), so there is no numeric cap-after the daily line could truthfully claim
// — the label alone is the honest authority (never a claim the engine doesn't back). `None`
// when the policy is unavailable or carries no swap rule.
let swap_rule_label = self
.agent_policy
.as_ref()
.and_then(|p| p.rules.iter().find(|r| matches!(r, Rule::Swap { .. })))
.map(|r| r.label());

// Pull the bound figures off the last quote (the quote that produced this proposal). The
// proposal's `intent` carries the swap's value, but the token-denominated sell/buy figures
// come from the quote snapshot still held on the shell — cleared only on a compose edit,
Expand Down Expand Up @@ -562,7 +585,17 @@ impl Shell {
.into_any_element(),
);
if let Some(valid) = valid_row {
rows.push(kv_text_row("Valid until", valid, mono, muted, muted).into_any_element());
rows.push(
kv_text_row("Valid until", valid, mono.clone(), muted, muted).into_any_element(),
);
}
// The Allowed-by authority line — the Swap rule that permitted it (a swap always asks, so no
// numeric cap-after to claim). Omitted when no policy / no swap rule.
if let Some(label) = swap_rule_label {
rows.push(
kv_text_row("Allowed by", label.to_string(), mono.clone(), fg, muted)
.into_any_element(),
);
}

let mut kvlist = v_flex()
Expand All @@ -584,12 +617,9 @@ impl Shell {
v_flex()
.w_full()
.gap_4()
.child(self.commit_heading(
&SWAP_VIEW,
SWAP_VIEW.review_title,
SWAP_VIEW.review_subtitle,
cx,
))
// The shared request-origin rail — the ONLY cross-origin difference. The swap keeps
// its native two-token body below it (per E5: same rail, honest native content).
.child(origin_rail)
// A faint reminder of the human-readable summary the proposal snapshot carries
// (e.g. "0.05 WETH → at least 92.1 COW"), so the list matches what was reviewed.
.child(
Expand All @@ -598,6 +628,8 @@ impl Shell {
.text_color(muted)
.child(proposal.recipient.clone()),
)
// The ONE danger line for every value move (DESIGN §Clear-signing).
.child(crate::widgets::error_line(danger, "This can't be undone."))
.child(kvlist)
.child(self.commit_honesty_swap(cx))
.children(self.swap.error.as_ref().map(|e| error_line(e, cx)))
Expand Down
1 change: 1 addition & 0 deletions crates/deckard-app/src/welcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,7 @@ mod tests {
},
Rule::Shield {
approval: ApprovalMode::OverCap,
per_tx_cap_wei: None,
},
Rule::Swap {
tokens: Allowlist::Any,
Expand Down
22 changes: 11 additions & 11 deletions crates/deckard-app/src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,7 @@ pub(crate) enum KeyCap {

/// The primary-modifier label for `os` (pass `std::env::consts::OS`): `⌘` on macOS, `Ctrl`
/// everywhere else. A pure fn so the platform mapping is unit-testable without a window.
#[allow(dead_code)] // reason: the platform half of `key_cap`; consumed by E5/E6 via `key_cap`.
/// The platform half of `key_cap`, now consumed by the shared Review (E5) via `key_cap`.
fn primary_mod_label(os: &str) -> &'static str {
if os == "macos" {
"⌘"
Expand All @@ -294,7 +294,7 @@ fn primary_mod_label(os: &str) -> &'static str {
}

/// The glyphs a [`KeyCap`] renders for `os`. Pure (no rendering) so it is unit-testable.
#[allow(dead_code)] // reason: the label half of `key_cap`; consumed by E5/E6 via `key_cap`.
/// The label half of `key_cap`, now consumed by the shared Review (E5) via `key_cap`.
fn key_cap_label(cap: KeyCap, os: &str) -> String {
match cap {
KeyCap::CmdEnter => format!("{}↵", primary_mod_label(os)),
Expand All @@ -308,9 +308,8 @@ fn key_cap_label(cap: KeyCap, os: &str) -> String {
/// `std::env::consts::OS`), the `⌘↵` chord as ONE cap. `armed` renders the amber border + amber
/// text (no fill) of the live confirm; at rest it is `border.strong` + `text.muted`. The one
/// key-cap so no view hardcodes a `⌘`/`Ctrl` glyph.
// reason: the v4 confirm button (E5, #185) + activity / needs-you key hints (E6, #186) consume
// this; E1 lands the shared, platform-aware glyph so no later view re-rolls it.
#[allow(dead_code)]
// Consumed by the v4 confirm button (E5, #185 — the shared Review's ⌘↵) and, later, activity /
// needs-you key hints (E6, #186); the one platform-aware glyph so no view re-rolls a ⌘/Ctrl.
pub(crate) fn key_cap(
cap: KeyCap,
armed: bool,
Expand Down Expand Up @@ -456,7 +455,8 @@ pub(crate) enum KvValue<'a> {
/// `text.primary`, or sans, or `success`, or `warn` for a loud downgrade), the row clamped so a long
/// value truncates rather than overflowing. Shared by clear-signing quiet-facts, the policy ledger,
/// and the metadata rail.
// reason: consumed by the v4 metadata rail (E3, #183) + the Review quiet facts (E5, #185).
// Consumed by the Review quiet facts (E5, #185 — From / Network / Allowed by) AND the v4 metadata
// rail (E3, #183).
pub(crate) fn kv_row(
label: &str,
value: KvValue,
Expand Down Expand Up @@ -542,9 +542,8 @@ pub(crate) fn page_header(

/// Who a shared-Review request came FROM (DESIGN §The request-origin model): the human, an agent,
/// or a dapp. The verb is the human's action (`You are sending`); agents `propose`, dapps `request`.
// reason: `You`/`Agent` are wired by the E3 request rail (#183); `Dapp` lands with the browser
// bridge origin (ADR-0001 / #44).
#[allow(dead_code)]
/// Constructed per request by the shared Review (E5, #185 — You / Agent / Dapp, the last for a dapp
/// message) and the E3 request rail (#183, You / Agent). Every variant is now built, so no allow.
pub(crate) enum Origin<'a> {
/// The human principal — a round identity mark (`account` seeds it) + amber `You are {verb}`.
You { account: &'a str, verb: &'a str },
Expand All @@ -565,7 +564,7 @@ pub(crate) enum Trust {
}

/// The small state-color trust badge for the [`origin_header`] rail.
#[allow(dead_code)] // reason: the badge half of `origin_header`; consumed via `origin_header`.
/// The badge half of `origin_header`, consumed via `origin_header` (E5, #185).
fn trust_badge(trust: Trust, theme: &Theme) -> AnyElement {
let is_dark = theme.is_dark();
// The tint is hue-keyed (DESIGN §Opacity): the amber caution reads weaker at equal alpha, so it
Expand Down Expand Up @@ -609,7 +608,8 @@ fn trust_badge(trust: Trust, theme: &Theme) -> AnyElement {
/// fooled by. A dapp/external origin is a neutral identity + a state-color badge, NEVER a third
/// signal color; the agent mark is the bordered cyan squircle ([`agent_mark`], handle-aware).
/// `You` is amber, an agent is cyan, a dapp is neutral.
// reason: consumed by the ONE shared Review (E5, #185) + the rail's compact clear-signing (E3).
// Consumed by the ONE shared Review (E5, #185) for every origin (self Send/Shield/Swap, an agent
// proposal, a dapp request) AND the rail's compact clear-signing (E3, #183).
pub(crate) fn origin_header(origin: Origin, trust: Option<Trust>, theme: &Theme) -> AnyElement {
let is_dark = theme.is_dark();
let amber = crate::theme::amber(is_dark);
Expand Down
Loading
Loading