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
37 changes: 27 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,13 @@ jobs:
# (deckard-signerd's anvil-fork lane). The daemon tests skip gracefully if it's absent,
# but CI installs it so the broadcast path is actually exercised.
- uses: foundry-rs/foundry-toolchain@v1
- run: cargo build --workspace
- run: cargo build -p deckard-app --features tray
- run: cargo test --workspace
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo clippy -p deckard-app --all-targets --features tray -- -D warnings
# --locked everywhere: reproducibility lives in the committed Cargo.lock (it pins the exact
# git gpui/helios/railgun commits). --locked fails CI if the lock is stale, keeping it committed.
- run: cargo build --locked --workspace
- run: cargo build --locked -p deckard-app --features tray
- run: cargo test --locked --workspace
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
- run: cargo clippy --locked -p deckard-app --all-targets --features tray -- -D warnings

linux:
runs-on: ubuntu-latest
Expand All @@ -64,8 +66,23 @@ jobs:
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
# Foundry (anvil) for the deckard-signerd broadcast integration tests.
- uses: foundry-rs/foundry-toolchain@v1
- run: cargo build --workspace
- run: cargo build -p deckard-app --features tray
- run: cargo test --workspace
- run: cargo clippy --workspace --all-targets -- -D warnings
- run: cargo clippy -p deckard-app --all-targets --features tray -- -D warnings
# Formatting is OS-independent, so gate it once on the cheaper Linux runner.
- run: cargo fmt --all --check
- run: cargo build --locked --workspace
- run: cargo build --locked -p deckard-app --features tray
- run: cargo test --locked --workspace
- run: cargo clippy --locked --workspace --all-targets -- -D warnings
- run: cargo clippy --locked -p deckard-app --all-targets --features tray -- -D warnings

# Supply-chain gate (advisories / licenses / bans / sources). Config: deny.toml.
# Lands NON-BLOCKING (continue-on-error): promote to a required check after one green run AND
# after seeding deny.toml [licenses].allow from a local `cargo deny check licenses`.
# Rationale: docs/AGENTIC-ENGINEERING.md §4.
cargo-deny:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: EmbarkStudios/cargo-deny-action@v2
with:
command: check advisories bans sources licenses
52 changes: 52 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# AGENTS.md — Deckard

Guidance for any coding agent (Codex, Claude Code, etc.) working in this repo. Claude Code also
reads `CLAUDE.md`; the two are kept in sync. Full rationale: `docs/AGENTIC-ENGINEERING.md`.

## What this is
Deckard is a native, self-custodial **Ethereum wallet** (GPUI + Rust, macOS + Linux). It is
**security-sensitive**: it holds private keys, BIP-39 seed phrases, and an encrypted keystore.
Treat key material with care.

## Layout (virtual Cargo workspace — every crate lives under `crates/`)
- `crates/deckard-app` — the `deckard` GPUI desktop app (the view layer / shell; binary `deckard`).
- `crates/deckard-core` — the headless engine (Ethereum provider + verified reads, balances, HD keys,
encrypted keystore, key-less shield builder). **No GPUI dependency; fully unit-testable.** Most logic
belongs here, not in the app.
- `crates/deckard-contract` — the frozen wire contract (Intent / Decision / Policy / RPC / ReadStatus).
- `crates/deckard-signerd` — the process-isolated signer daemon (owns the key; UDS server).

## Commands
- Iterate fast: `just core` — clippy + test the GPUI-free engine (`deckard-core`) without building the
gpui app (the heavy verified-reads/shield deps compile once, then it's fast). Use while working on the
engine; the full DoD still applies before done. UI work needs `just check`.
- Lint: `just check` — clippy `-D warnings` on the whole workspace + the app's `--features tray` config.
- Format: `just fmt` (`cargo fmt`); CI gates `cargo fmt --all --check`.
- Test: `cargo test --workspace`.
- Bump the git GPUI stack: `just bump-gpui` (the ONLY way to change those pins).

## Definition of done (all must hold; show command output as evidence)
1. `cargo fmt --all --check` clean
2. `just check` green (both feature configs)
3. `cargo test --workspace` green
4. No new/changed dependencies in `Cargo.toml` or `Cargo.lock` unless explicitly approved

Never report a task complete while any of these is red.

## Code constraints (see docs/AGENTIC-ENGINEERING.md for the full rationale)
**Enforced workspace-wide** (CI fails the build): `todo!` / `dbg!` denied; `unused_must_use` denied;
`deckard-core` is `#![forbid(unsafe_code)]` and the app crate is `unsafe_code = "deny"` (a new `unsafe`
block needs a reviewed `// SAFETY:` comment + explicit `#[allow]`); `std::mem::forget` /
`core::mem::forget` / `rand::thread_rng` denied (use `drop()` / `OsRng`).

**Enforced in `deckard-core`** via crate-level `#![deny(...)]`: no `.unwrap()` / `.expect()` / `panic!` /
raw slice indexing in non-test code — propagate with `Result` / `?` and parse untrusted bytes through the
bounded `Reader` in `keystore.rs`. The app crate may `unwrap` infallible GPUI handles; the engine must
not. Genuinely-unrecoverable boundaries use a scoped `#[allow]` + `// reason` (see `eth.rs`), never a
bare `unwrap`.

**Always:** never log or `Debug`-print a seed, key, or passphrase. Secrets live in `Zeroizing`.

## Design
Before any visual/UI change, read `DESIGN.md` (and the constraints in `CLAUDE.md`). Ground design in
the real reference screenshots, not remembered descriptions.
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,34 @@ Ground all design work in **real reference screenshots** (Linear, Conductor, Spl
Stripe), never in remembered descriptions — that is how the first drafts went wrong. The
interactive, dogfooded reference lives at
`~/.gstack/projects/hellno-deckard/designs/deckard-foundation-preview.html`.

## Engineering & verification
The full rationale (what we enforce and *why*, plus deliberately-rejected rules) is in
`docs/AGENTIC-ENGINEERING.md`. The quick reference:

**Iterate fast:** `just core` — clippy + tests the GPUI-free engine (`deckard-core`) in seconds, no
gpui build. Reach for it while working on the engine; the full Definition of Done below still applies
before you're done. (For UI work you must build the app: `just check`.)

**Definition of done** (ALL must hold; paste the command output as evidence — do not claim done while red):
1. `cargo fmt --all --check` is clean
2. `just check` is green — runs clippy `-D warnings` on BOTH the default and `--features tray` configs
3. `cargo test --workspace` is green
4. No new or changed dependencies (`Cargo.toml` / `Cargo.lock`) unless explicitly approved
(the git GPUI stack is bumped only via `just bump-gpui` — never hand-edit those pins)

## Code constraints

**Enforced workspace-wide** by `[workspace.lints]` + `clippy.toml` (CI fails the build):
- `todo!` and `dbg!` are denied; ignored `Result`s (`unused_must_use`) are denied.
- `deckard-core` is `#![forbid(unsafe_code)]`; the app crate is `unsafe_code = "deny"`.
- `std::mem::forget` / `core::mem::forget` and `rand::thread_rng` are denied — use `drop()` / `OsRng`.

**Enforced in `deckard-core`** (the trust core) via crate-level `#![deny(...)]` — clippy fails the build:
- No `.unwrap()` / `.expect()` / `panic!` / raw slice indexing in non-test code — propagate with
`Result` / `?` and parse untrusted bytes through the bounded `Reader` in `keystore.rs`. The app
crate may `unwrap` infallible GPUI handles; the engine must not. The two startup-fatal `expect`s in
`eth.rs` carry a documented local `#[allow]` — match that pattern (a `// reason` + scoped `#[allow]`)
for any genuinely-unrecoverable boundary, don't reach for a bare `unwrap`.

**Always, every crate:** never log or `Debug`-print a seed, key, or passphrase — secrets stay in `Zeroizing`.
12 changes: 12 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@ members = [
]
default-members = ["crates/deckard-app"]

# Lint policy lives in the manifest (not just CI flags) so rust-analyzer and `cargo check` surface
# the exact same rules CI enforces. Every member opts in via `[lints] workspace = true`.
# Rationale + the deliberately-rejected lints: docs/AGENTIC-ENGINEERING.md §1.
[workspace.lints.rust]
unused_must_use = "deny" # an ignored Result from a fallible crypto/IO call is a silent bug
unsafe_code = "deny" # deny (not forbid): leaves a reviewed escape hatch if objc2 ever needs raw unsafe

[workspace.lints.clippy]
all = { level = "warn", priority = -1 } # priority = -1 is required on a lint *group*
todo = "deny" # a stray todo!() left on a code path panics in production
dbg_macro = "deny" # debug noise — and dbg!(seed) is a key leak

[workspace.dependencies]
# Single-sourced versions shared across crates. deckard-contract pins to these;
# the app and core may layer extra features on top (e.g. alloy-primitives `serde`).
Expand Down
35 changes: 35 additions & 0 deletions clippy.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Project-wide clippy configuration. See docs/AGENTIC-ENGINEERING.md §2.

# Keep clippy's API suggestions within what the pinned toolchain supports.
msrv = "1.95.0"

# These pair with deckard-core's crate-level restriction lints (unwrap_used/expect_used/panic, denied
# in its lib.rs): production code must not unwrap/expect/panic, but test code may freely. Without these
# keys the deny would also fire inside #[cfg(test)].
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true # tamper tests index byte offsets on purpose

# Crypto / Ethereum jargon that would otherwise trip clippy::doc_markdown. ".." keeps clippy's
# built-in default identifier list (GitHub, OAuth, ...) and extends it.
doc-valid-idents = [
"..",
"BIP-39",
"BIP-32",
"EIP-1559",
"ERC-20",
"XChaCha20",
"Argon2id",
"ChaCha20-Poly1305",
"secp256k1",
"keccak",
"Multicall3",
]

# Wallet-specific footguns, banned by construction (both are unused today, so zero migration cost).
disallowed-methods = [
{ path = "std::mem::forget", reason = "skips Drop, so skips zeroize scrubbing of key material; use drop()" },
{ path = "core::mem::forget", reason = "same as std::mem::forget (re-export) — skips zeroize scrubbing; use drop()" },
{ path = "rand::thread_rng", reason = "use OsRng for anything key-derived (keystore.rs already does)" },
]
4 changes: 4 additions & 0 deletions crates/deckard-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,7 @@ category = "public.app-category.finance"
short_description = "A native, self-custodial Ethereum wallet for onchain operators."
long_description = "Deckard is a fast, keyboard-first, self-custodial Ethereum wallet for people who live onchain. Native (macOS + Linux), trustless by construction, open source (AGPL-3.0)."
osx_minimum_system_version = "11.0"

# Inherit the workspace lint policy (root Cargo.toml [workspace.lints]).
[lints]
workspace = true
57 changes: 38 additions & 19 deletions crates/deckard-app/src/onboarding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ impl Shell {
}

fn render_create_setup(&self, cx: &mut Context<Self>) -> impl IntoElement {
let busy_label = if self.auth_busy { "Encrypting…" } else { "Continue" };
let busy_label = if self.auth_busy {
"Encrypting…"
} else {
"Continue"
};
v_flex()
.gap_5()
.child(self.auth_heading(
Expand Down Expand Up @@ -146,7 +150,11 @@ impl Shell {
let t = cx.theme();
(t.border, t.secondary, t.foreground, t.muted_foreground)
};
let busy_label = if self.auth_busy { "Saving…" } else { "Confirm & finish" };
let busy_label = if self.auth_busy {
"Saving…"
} else {
"Confirm & finish"
};

// The 12-word grid: each cell shows the word only while held-to-reveal.
let words: Vec<String> = self
Expand Down Expand Up @@ -175,12 +183,7 @@ impl Shell {
.border_1()
.border_color(border)
.bg(surface)
.child(
div()
.text_xs()
.text_color(muted)
.child(format!("{n}")),
)
.child(div().text_xs().text_color(muted).child(format!("{n}")))
.child(
div()
.text_sm()
Expand Down Expand Up @@ -256,7 +259,11 @@ impl Shell {
}

fn render_import(&self, cx: &mut Context<Self>) -> impl IntoElement {
let busy_label = if self.auth_busy { "Importing…" } else { "Import" };
let busy_label = if self.auth_busy {
"Importing…"
} else {
"Import"
};
v_flex()
.gap_5()
.child(self.auth_heading(
Expand Down Expand Up @@ -292,7 +299,11 @@ impl Shell {

fn render_migrate(&self, cx: &mut Context<Self>) -> impl IntoElement {
let danger = cx.theme().danger;
let busy_label = if self.auth_busy { "Encrypting…" } else { "Encrypt & continue" };
let busy_label = if self.auth_busy {
"Encrypting…"
} else {
"Encrypt & continue"
};
v_flex()
.gap_5()
.child(self.auth_heading(
Expand Down Expand Up @@ -324,7 +335,11 @@ impl Shell {
}

fn render_unlock(&self, cx: &mut Context<Self>) -> impl IntoElement {
let busy_label = if self.auth_busy { "Unlocking…" } else { "Unlock" };
let busy_label = if self.auth_busy {
"Unlocking…"
} else {
"Unlock"
};
v_flex()
.gap_5()
.child(self.auth_heading(
Expand All @@ -346,7 +361,12 @@ impl Shell {

// --- small shared pieces ---

fn auth_heading(&self, title: &str, subtitle: &str, cx: &mut Context<Self>) -> impl IntoElement {
fn auth_heading(
&self,
title: &str,
subtitle: &str,
cx: &mut Context<Self>,
) -> impl IntoElement {
let theme = cx.theme();
v_flex()
.gap_2()
Expand Down Expand Up @@ -389,12 +409,11 @@ impl Shell {
/// A one-line error, or nothing.
fn error_line(&self, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme();
div()
.children(self.auth_error.as_ref().map(|e| {
div()
.text_sm()
.text_color(theme.danger)
.child(format!("⚠ {e}"))
}))
div().children(self.auth_error.as_ref().map(|e| {
div()
.text_sm()
.text_color(theme.danger)
.child(format!("⚠ {e}"))
}))
}
}
28 changes: 16 additions & 12 deletions crates/deckard-app/src/palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ impl Shell {
.text_color(muted)
.child("Commands"),
)
.child(row("cmd-portfolio", "Go to Portfolio", "").on_click(cx.listener(
|this, _, _, cx| {
this.palette_open = false;
this.navigate(Route::Welcome, cx);
},
)))
.child(
row("cmd-portfolio", "Go to Portfolio", "").on_click(cx.listener(
|this, _, _, cx| {
this.palette_open = false;
this.navigate(Route::Welcome, cx);
},
)),
)
.child(row("cmd-receive", "Receive", "").on_click(cx.listener(
|this, _, _, cx| {
this.palette_open = false;
Expand All @@ -87,12 +89,14 @@ impl Shell {
cx.notify();
},
)))
.child(row("cmd-theme", "Toggle theme", "⌘⇧D").on_click(cx.listener(
|this, _, _, cx| {
this.palette_open = false;
this.toggle_mode(cx);
},
)))
.child(
row("cmd-theme", "Toggle theme", "⌘⇧D").on_click(cx.listener(
|this, _, _, cx| {
this.palette_open = false;
this.toggle_mode(cx);
},
)),
)
.child(row("cmd-lock", "Lock wallet", "").on_click(cx.listener(
|this, _, _, cx| {
this.palette_open = false;
Expand Down
12 changes: 4 additions & 8 deletions crates/deckard-app/src/receive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,7 @@
//! string, and a working copy-to-clipboard. The address is a real keypair
//! (see wallet.rs); anyone can send to it.

use gpui::{
div, px, rgb, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, Styled,
};
use gpui::{div, px, rgb, ClipboardItem, Context, FontWeight, IntoElement, ParentElement, Styled};
use gpui_component::{
button::{Button, ButtonVariants},
h_flex, v_flex, ActiveTheme,
Expand Down Expand Up @@ -96,11 +94,9 @@ impl Shell {
));
})),
)
.child(
Button::new("receive-back").ghost().label("Back").on_click(
cx.listener(|this, _, _, cx| this.navigate(Route::Welcome, cx)),
),
),
.child(Button::new("receive-back").ghost().label("Back").on_click(
cx.listener(|this, _, _, cx| this.navigate(Route::Welcome, cx)),
)),
),
)
}
Expand Down
4 changes: 3 additions & 1 deletion crates/deckard-app/src/settings_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@ impl Shell {

let name_control = Input::new(&self.name_input).w(px(220.0)).into_any_element();
let rpc_control = Input::new(&self.rpc_input).w(px(260.0)).into_any_element();
let watch_control = Input::new(&self.watch_input).w(px(260.0)).into_any_element();
let watch_control = Input::new(&self.watch_input)
.w(px(260.0))
.into_any_element();

let launch_control = Switch::new("launch-min")
.checked(self.settings.launch_minimized)
Expand Down
Loading
Loading