From 480d686c41d1d6723a26c734c4137a4c8ec2a96a Mon Sep 17 00:00:00 2001 From: Tom You Date: Wed, 8 Jul 2026 23:41:07 -0500 Subject: [PATCH] feat(wallet): model RabbyHub MVP and modern dashboard --- docs/rabbyhub-mvp-feature-inventory.md | 63 +++ rabby-core/src/lib.rs | 7 + rabby-core/src/wallet_mvp/mod.rs | 606 +++++++++++++++++++++++++ src-tauri/src/main.rs | 34 +- ui/app.js | 142 +++++- ui/index.html | 155 +++++-- ui/styles.css | 281 +++++++++++- 7 files changed, 1243 insertions(+), 45 deletions(-) create mode 100644 docs/rabbyhub-mvp-feature-inventory.md create mode 100644 rabby-core/src/wallet_mvp/mod.rs diff --git a/docs/rabbyhub-mvp-feature-inventory.md b/docs/rabbyhub-mvp-feature-inventory.md new file mode 100644 index 0000000..2c27029 --- /dev/null +++ b/docs/rabbyhub-mvp-feature-inventory.md @@ -0,0 +1,63 @@ +# RabbyHub/Rabby MVP Feature Inventory + +Reference repository inspected: `https://github.com/rabbyHub/rabby` at commit `69cd265`. + +This document records the feature/functionality classes Rabby Wallet exposes and treats them as the MVP target for this Rust/Tauri Rabby product. The current implementation keeps these as typed Rust models and unit-tested acceptance criteria first; runtime/UI implementation should then move feature-by-feature from `Modeled` to `RuntimeImplemented`. + +## MVP feature list + +| Feature | RabbyHub/Rabby reference | MVP acceptance in this repo | Current status | +| --- | --- | --- | --- | +| Create/unlock wallet | `Welcome`, `CreatePassword`, `Unlock`, `ForgotPassword` | Password/unlock state model and encrypted local state boundary | Modeled | +| Import/create accounts | `CreateMnemonics`, `ImportMnemonics`, `ImportPrivateKey`, `ImportJson`, watch address | Seed/private key/JSON/watch-only account records validate and display | Modeled | +| Hardware/institutional accounts | Ledger, Trezor, Keystone, GridPlus, OneKey, BitBox02, Gnosis Safe, Coinbase, WalletConnect, Cobo Argus | Account source model covers Rabby import surface | Modeled | +| Multi-chain networks | `ChainList`, `CustomRPC`, `CustomTestnet`, offline chain support | Chain config validates id, RPC URL, custom/testnet/offline flags | Modeled | +| Portfolio dashboard | `Dashboard`, token/NFT/DeFi DB services, balance sync | Dashboard summary renders accounts/chains/token balances/risk/activity | UI Prototype | +| Send/receive | `SendToken`, `SendNFT`, `Receive`, `SelectToAddress` | Send request validates recipient, amount, asset, chain | Modeled | +| Swap/bridge | `Swap`, `Bridge`, `rabby-swap`, `rabby-bridge` | Quote model validates route, assets, slippage, provider | Modeled | +| Approvals | `ManageApprovals`, `NFTApproval`, `TokenApproval`, batch approvals | Approval records identify spender, allowance, risk, revoke path | Modeled | +| Dapp provider/permissions | `content-script`, `pageProvider`, `providerController`, `RequestPermission`, `ConnectedSites` | Dapp request validates origin, accounts, chains, permission status | Modeled | +| Signing/security preview | `Approval`, `securityEngine`, `rabby-action`, signing highlighter | Signing request includes message/typed-data/tx preview and risk | Modeled | +| Activity/history | `Activities`, `History`, `TransactionHistory`, `SignedTextHistory` | History entries track account, chain, status, tx/signature identity | Modeled | +| Contacts/whitelist | `contactBook`, `WhitelistInput`, send recipient selection | Contacts validate name/address and whitelist state | Modeled | +| Settings/customization | `AdvanceSettings`, `SwitchLang`, currency, auto-lock, metamask mode | Theme, language, currency, auto-lock, default-wallet mode | UI Prototype | +| Notifications/guides/points | `notification`, `newUserGuide`, `RabbyPoints`, feedback | Dashboard can list notifications and points summary | UI Prototype | + +## RabbyHub/Rabby architecture notes + +RabbyHub/Rabby is a browser extension wallet with these major contexts: + +- `background`: async requests, encryption, wallet/provider controllers, services. +- `content-script`: injected at document start, bridges dapps to background. +- `pageProvider`: injects `window.ethereum` and handles dapp requests. +- `ui`: notification, full tab, and popup views. + +The Rust/Tauri version should map that to: + +- `rabby-core`: wallet domain models and validation. +- `rabby-runtime`: persistence, keychain, network, and OS adapters. +- `src-tauri`: command/event boundary. +- `ui`: modern Rabby-like dashboard, approvals, settings, dapp requests. + +## Immediate product gap + +The current app can open but is still a shell. The MVP work should prioritize: + +1. Wallet onboarding/unlock state and local encrypted persistence. +2. Account import/watch-only flows. +3. Real portfolio dashboard state wired through Tauri. +4. Dapp permission and approval request UI. +5. Send/receive + swap/bridge request forms. +6. Settings/customization: theme, language, currency, auto-lock. + +## Unit test coverage added + +`rabby-core::wallet_mvp` tests now assert: + +- Every MVP feature class is represented. +- Feature keys are unique and documented. +- All Rabby account source types are covered. +- Account, chain, send, swap, and dapp permission validation works. +- Unsafe/incomplete wallet requests are rejected. +- Dashboard totals and high-risk approval counts are computed. +- Settings customization defaults validate. diff --git a/rabby-core/src/lib.rs b/rabby-core/src/lib.rs index d3b15f9..6f7fa97 100644 --- a/rabby-core/src/lib.rs +++ b/rabby-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod shortcut; pub mod terminal_model; pub mod theme; pub mod transfer; +pub mod wallet_mvp; pub mod workspace; pub use config::{AppConfig, ShortcutBinding}; @@ -26,6 +27,12 @@ pub use secret::{SecretKind, SecretRef, SecretString}; pub use shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence}; pub use theme::Theme; pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest}; +pub use wallet_mvp::{ + all_wallet_mvp_features, demo_dashboard, wallet_feature_summary, AccountSource, ActivityEntry, + ApprovalRecord, ChainConfig, Contact, DappPermission, SendRequest, SigningRequest, SwapQuote, + WalletAccount, WalletDashboard, WalletFeatureArea, WalletFeatureSpec, WalletFeatureStatus, + WalletSettings, +}; pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace}; impl Workspace { diff --git a/rabby-core/src/wallet_mvp/mod.rs b/rabby-core/src/wallet_mvp/mod.rs new file mode 100644 index 0000000..6e1afa5 --- /dev/null +++ b/rabby-core/src/wallet_mvp/mod.rs @@ -0,0 +1,606 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum WalletFeatureStatus { + Modeled, + UiPrototype, + RuntimeImplemented, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum WalletFeatureArea { + Onboarding, + Accounts, + Portfolio, + DappConnection, + Signing, + Transaction, + DeFi, + Security, + Customization, + Data, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct WalletFeatureSpec { + pub key: &'static str, + pub title: &'static str, + pub area: WalletFeatureArea, + pub rabby_reference: &'static str, + pub mvp_acceptance: &'static str, + pub status: WalletFeatureStatus, +} + +pub const RABBY_WALLET_MVP_FEATURES: &[WalletFeatureSpec] = &[ + WalletFeatureSpec { key: "onboarding-password", title: "Create/unlock wallet", area: WalletFeatureArea::Onboarding, rabby_reference: "Welcome, CreatePassword, Unlock, ForgotPassword", mvp_acceptance: "User can create a password, lock/unlock, and keep encrypted local state", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "import-create-accounts", title: "Import/create accounts", area: WalletFeatureArea::Accounts, rabby_reference: "CreateMnemonics, ImportMnemonics, ImportPrivateKey, ImportJson, watch address", mvp_acceptance: "Seed phrase/private key/JSON/watch-only account records validate and display", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "hardware-wallets", title: "Hardware and institutional accounts", area: WalletFeatureArea::Accounts, rabby_reference: "Ledger, Trezor, Keystone, GridPlus, OneKey, BitBox02, Gnosis Safe, Coinbase, WalletConnect, Cobo Argus", mvp_acceptance: "Account source model covers hardware/safe/walletconnect/custody sources", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "multi-chain", title: "Multi-chain network support", area: WalletFeatureArea::Portfolio, rabby_reference: "ChainList, CustomRPC, CustomTestnet, offline chain support", mvp_acceptance: "Chains have IDs, RPC URLs, testnet/custom/offline flags, and validation", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "portfolio-dashboard", title: "Portfolio dashboard", area: WalletFeatureArea::Portfolio, rabby_reference: "Dashboard, token/NFT/DeFi DB services, balance sync", mvp_acceptance: "Dashboard summary shows accounts, chains, token balances, NFTs, DeFi positions", status: WalletFeatureStatus::UiPrototype }, + WalletFeatureSpec { key: "send-receive", title: "Send and receive", area: WalletFeatureArea::Transaction, rabby_reference: "SendToken, SendNFT, Receive, SelectToAddress", mvp_acceptance: "Send request validates recipient, amount, asset, chain, and receive view exposes address", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "swap-bridge", title: "Swap and bridge", area: WalletFeatureArea::DeFi, rabby_reference: "Swap, Bridge, rabby-swap, rabby-bridge", mvp_acceptance: "Quote model validates from/to asset, chain route, slippage, and provider", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "approvals", title: "Token and NFT approvals", area: WalletFeatureArea::Security, rabby_reference: "ManageApprovals, NFTApproval, TokenApproval, ManageBatchApprovals", mvp_acceptance: "Approval records identify spender, asset, allowance, risk level, revoke action", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "dapp-provider", title: "Dapp provider and permissions", area: WalletFeatureArea::DappConnection, rabby_reference: "content-script, pageProvider, providerController, RequestPermission, ConnectedSites", mvp_acceptance: "Dapp connection request validates origin, requested accounts/chains, and permission status", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "signing-security", title: "Signing and security previews", area: WalletFeatureArea::Signing, rabby_reference: "Approval, securityEngine, rabby-action, transaction docs, sign message highlighter", mvp_acceptance: "Signing request includes typed data/message/transaction preview, simulation result, and risk checks", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "history-activity", title: "Activity and transaction history", area: WalletFeatureArea::Data, rabby_reference: "Activities, History, TransactionHistory, SignedTextHistory", mvp_acceptance: "History entries record kind, account, chain, status, timestamp, and tx hash or signature id", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "contacts-whitelist", title: "Contacts and whitelist", area: WalletFeatureArea::Security, rabby_reference: "contactBook, WhitelistInput, SelectToAddress", mvp_acceptance: "Contact and whitelist records validate address/name and can be matched before send", status: WalletFeatureStatus::Modeled }, + WalletFeatureSpec { key: "settings-customization", title: "Settings and customization", area: WalletFeatureArea::Customization, rabby_reference: "AdvanceSettings, SwitchLang, currency, autoLock, preference, metamask mode", mvp_acceptance: "Settings model covers theme, language, currency, autolock, default wallet/metamask mode", status: WalletFeatureStatus::UiPrototype }, + WalletFeatureSpec { key: "notifications-points", title: "Notifications, guides, points", area: WalletFeatureArea::Data, rabby_reference: "notification, newUserGuide, RabbyPoints, feedback", mvp_acceptance: "Notification and points summary can be listed in the dashboard shell", status: WalletFeatureStatus::UiPrototype }, +]; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AccountSource { + SeedPhrase, + PrivateKey, + JsonKeystore, + WatchOnly, + Ledger, + Trezor, + Keystone, + GridPlus, + OneKey, + BitBox02, + GnosisSafe, + Coinbase, + WalletConnect, + CoboArgus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletAccount { + pub id: String, + pub address: String, + pub name: String, + pub source: AccountSource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ChainConfig { + pub id: u64, + pub name: String, + pub rpc_url: String, + pub is_testnet: bool, + pub is_custom: bool, + pub is_offline_supported: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TokenBalance { + pub chain_id: u64, + pub symbol: String, + pub amount: f64, + pub usd_value: f64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RiskLevel { + Low, + Medium, + High, + Critical, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ApprovalRecord { + pub account_id: String, + pub chain_id: u64, + pub asset_symbol: String, + pub spender: String, + pub allowance: String, + pub risk: RiskLevel, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SendRequest { + pub from_account_id: String, + pub to_address: String, + pub chain_id: u64, + pub asset_symbol: String, + pub amount: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct SwapQuote { + pub provider: String, + pub from_chain_id: u64, + pub to_chain_id: u64, + pub from_symbol: String, + pub to_symbol: String, + pub from_amount: f64, + pub estimated_to_amount: f64, + pub slippage_bps: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum PermissionStatus { + Pending, + Approved, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DappPermission { + pub origin: String, + pub accounts: Vec, + pub chains: Vec, + pub status: PermissionStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum SigningKind { + Message, + TypedData, + Transaction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SigningRequest { + pub origin: String, + pub account_id: String, + pub kind: SigningKind, + pub preview: String, + pub risk: RiskLevel, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ActivityStatus { + Pending, + Confirmed, + Failed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActivityEntry { + pub id: String, + pub account_id: String, + pub chain_id: u64, + pub label: String, + pub status: ActivityStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Contact { + pub name: String, + pub address: String, + pub whitelisted: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum ThemeMode { + Light, + Dark, + System, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WalletSettings { + pub theme: ThemeMode, + pub language: String, + pub currency: String, + pub auto_lock_minutes: u16, + pub prefer_rabby_over_metamask: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct WalletDashboard { + pub active_account: WalletAccount, + pub chains: Vec, + pub balances: Vec, + pub approvals: Vec, + pub activities: Vec, + pub settings: WalletSettings, +} + +impl WalletAccount { + pub fn validate(&self) -> Result<(), String> { + validate_id(&self.id, "account id")?; + validate_address(&self.address)?; + if self.name.trim().is_empty() { + return Err("account name is required".to_string()); + } + Ok(()) + } +} + +impl ChainConfig { + pub fn validate(&self) -> Result<(), String> { + if self.id == 0 { + return Err("chain id is required".to_string()); + } + if self.name.trim().is_empty() { + return Err("chain name is required".to_string()); + } + if !(self.rpc_url.starts_with("https://") + || self.rpc_url.starts_with("http://") + || self.rpc_url == "offline") + { + return Err("chain RPC URL must be http(s) or offline".to_string()); + } + Ok(()) + } +} + +impl SendRequest { + pub fn validate(&self) -> Result<(), String> { + validate_id(&self.from_account_id, "from account")?; + validate_address(&self.to_address)?; + if self.chain_id == 0 { + return Err("chain id is required".to_string()); + } + if self.asset_symbol.trim().is_empty() { + return Err("asset symbol is required".to_string()); + } + if self.amount <= 0.0 { + return Err("amount must be positive".to_string()); + } + Ok(()) + } +} + +impl SwapQuote { + pub fn validate(&self) -> Result<(), String> { + if self.provider.trim().is_empty() { + return Err("swap provider is required".to_string()); + } + if self.from_chain_id == 0 || self.to_chain_id == 0 { + return Err("swap chain ids are required".to_string()); + } + if self.from_symbol.trim().is_empty() || self.to_symbol.trim().is_empty() { + return Err("swap assets are required".to_string()); + } + if self.from_amount <= 0.0 || self.estimated_to_amount <= 0.0 { + return Err("swap amounts must be positive".to_string()); + } + if self.slippage_bps > 5_000 { + return Err("slippage is too high".to_string()); + } + Ok(()) + } +} + +impl DappPermission { + pub fn validate(&self) -> Result<(), String> { + if !(self.origin.starts_with("https://") || self.origin.starts_with("http://")) { + return Err("dapp origin must be http(s)".to_string()); + } + if self.accounts.is_empty() { + return Err("dapp permission requires at least one account".to_string()); + } + if self.chains.is_empty() { + return Err("dapp permission requires at least one chain".to_string()); + } + Ok(()) + } +} + +impl WalletSettings { + pub fn validate(&self) -> Result<(), String> { + if self.language.trim().is_empty() { + return Err("language is required".to_string()); + } + if self.currency.trim().is_empty() { + return Err("currency is required".to_string()); + } + if self.auto_lock_minutes == 0 { + return Err("auto lock must be at least one minute".to_string()); + } + Ok(()) + } +} + +impl WalletDashboard { + pub fn validate(&self) -> Result<(), String> { + self.active_account.validate()?; + for chain in &self.chains { + chain.validate()?; + } + self.settings.validate()?; + Ok(()) + } + + pub fn total_usd_value(&self) -> f64 { + self.balances.iter().map(|token| token.usd_value).sum() + } + + pub fn high_risk_approval_count(&self) -> usize { + self.approvals + .iter() + .filter(|approval| matches!(approval.risk, RiskLevel::High | RiskLevel::Critical)) + .count() + } +} + +pub fn all_wallet_mvp_features() -> &'static [WalletFeatureSpec] { + RABBY_WALLET_MVP_FEATURES +} + +pub fn missing_wallet_mvp_features<'a>(required_keys: &'a [&'a str]) -> Vec<&'a str> { + required_keys + .iter() + .copied() + .filter(|key| { + !RABBY_WALLET_MVP_FEATURES + .iter() + .any(|feature| feature.key == *key) + }) + .collect() +} + +pub fn wallet_feature_summary() -> Vec<(&'static str, &'static str, WalletFeatureStatus)> { + RABBY_WALLET_MVP_FEATURES + .iter() + .map(|feature| (feature.title, feature.mvp_acceptance, feature.status)) + .collect() +} + +pub fn demo_dashboard() -> WalletDashboard { + WalletDashboard { + active_account: WalletAccount { + id: "main".to_string(), + address: "0x1111111111111111111111111111111111111111".to_string(), + name: "Main Wallet".to_string(), + source: AccountSource::SeedPhrase, + }, + chains: vec![ + ChainConfig { + id: 1, + name: "Ethereum".to_string(), + rpc_url: "https://rpc.ankr.com/eth".to_string(), + is_testnet: false, + is_custom: false, + is_offline_supported: true, + }, + ChainConfig { + id: 8453, + name: "Base".to_string(), + rpc_url: "https://mainnet.base.org".to_string(), + is_testnet: false, + is_custom: false, + is_offline_supported: true, + }, + ], + balances: vec![ + TokenBalance { + chain_id: 1, + symbol: "ETH".to_string(), + amount: 1.24, + usd_value: 4200.00, + }, + TokenBalance { + chain_id: 8453, + symbol: "USDC".to_string(), + amount: 1280.0, + usd_value: 1280.0, + }, + ], + approvals: vec![ApprovalRecord { + account_id: "main".to_string(), + chain_id: 1, + asset_symbol: "USDC".to_string(), + spender: "0x2222222222222222222222222222222222222222".to_string(), + allowance: "unlimited".to_string(), + risk: RiskLevel::High, + }], + activities: vec![ActivityEntry { + id: "act-1".to_string(), + account_id: "main".to_string(), + chain_id: 1, + label: "Swap ETH → USDC".to_string(), + status: ActivityStatus::Confirmed, + }], + settings: WalletSettings { + theme: ThemeMode::System, + language: "en".to_string(), + currency: "USD".to_string(), + auto_lock_minutes: 15, + prefer_rabby_over_metamask: true, + }, + } +} + +fn validate_id(value: &str, label: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{label} is required")); + } + Ok(()) +} + +fn validate_address(address: &str) -> Result<(), String> { + if address.len() != 42 + || !address.starts_with("0x") + || !address.chars().skip(2).all(|ch| ch.is_ascii_hexdigit()) + { + return Err("address must be a 20-byte hex Ethereum address".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + const REQUIRED_RABBY_MVP: &[&str] = &[ + "onboarding-password", + "import-create-accounts", + "hardware-wallets", + "multi-chain", + "portfolio-dashboard", + "send-receive", + "swap-bridge", + "approvals", + "dapp-provider", + "signing-security", + "history-activity", + "contacts-whitelist", + "settings-customization", + "notifications-points", + ]; + + #[test] + fn feature_inventory_covers_rabbyhub_mvp_classes() { + assert_eq!( + missing_wallet_mvp_features(REQUIRED_RABBY_MVP), + Vec::<&str>::new() + ); + } + + #[test] + fn feature_inventory_keys_are_unique_and_described() { + let mut keys = HashSet::new(); + for feature in all_wallet_mvp_features() { + assert!( + keys.insert(feature.key), + "duplicate feature key {}", + feature.key + ); + assert!(!feature.title.trim().is_empty()); + assert!(!feature.rabby_reference.trim().is_empty()); + assert!(!feature.mvp_acceptance.trim().is_empty()); + } + } + + #[test] + fn account_sources_cover_rabby_import_surface() { + let sources = [ + AccountSource::SeedPhrase, + AccountSource::PrivateKey, + AccountSource::JsonKeystore, + AccountSource::WatchOnly, + AccountSource::Ledger, + AccountSource::Trezor, + AccountSource::Keystone, + AccountSource::GridPlus, + AccountSource::OneKey, + AccountSource::BitBox02, + AccountSource::GnosisSafe, + AccountSource::Coinbase, + AccountSource::WalletConnect, + AccountSource::CoboArgus, + ]; + assert_eq!(sources.len(), 14); + } + + #[test] + fn validates_account_chain_send_swap_and_dapp_requests() { + let account = WalletAccount { + id: "a1".to_string(), + address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".to_string(), + name: "Vault".to_string(), + source: AccountSource::Ledger, + }; + assert!(account.validate().is_ok()); + + let chain = ChainConfig { + id: 1, + name: "Ethereum".to_string(), + rpc_url: "https://rpc.example".to_string(), + is_testnet: false, + is_custom: false, + is_offline_supported: true, + }; + assert!(chain.validate().is_ok()); + + let send = SendRequest { + from_account_id: "a1".to_string(), + to_address: "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".to_string(), + chain_id: 1, + asset_symbol: "ETH".to_string(), + amount: 0.5, + }; + assert!(send.validate().is_ok()); + + let quote = SwapQuote { + provider: "Rabby Swap".to_string(), + from_chain_id: 1, + to_chain_id: 8453, + from_symbol: "ETH".to_string(), + to_symbol: "USDC".to_string(), + from_amount: 1.0, + estimated_to_amount: 3300.0, + slippage_bps: 50, + }; + assert!(quote.validate().is_ok()); + + let permission = DappPermission { + origin: "https://debank.com".to_string(), + accounts: vec!["a1".to_string()], + chains: vec![1, 8453], + status: PermissionStatus::Pending, + }; + assert!(permission.validate().is_ok()); + } + + #[test] + fn rejects_unsafe_or_incomplete_wallet_requests() { + let bad_send = SendRequest { + from_account_id: "".to_string(), + to_address: "not-an-address".to_string(), + chain_id: 0, + asset_symbol: "".to_string(), + amount: 0.0, + }; + assert!(bad_send.validate().is_err()); + + let bad_quote = SwapQuote { + provider: "".to_string(), + from_chain_id: 1, + to_chain_id: 1, + from_symbol: "ETH".to_string(), + to_symbol: "USDC".to_string(), + from_amount: 1.0, + estimated_to_amount: 1.0, + slippage_bps: 9000, + }; + assert!(bad_quote.validate().is_err()); + + let bad_permission = DappPermission { + origin: "javascript:alert(1)".to_string(), + accounts: vec![], + chains: vec![], + status: PermissionStatus::Pending, + }; + assert!(bad_permission.validate().is_err()); + } + + #[test] + fn dashboard_summarizes_value_and_risk() { + let dashboard = demo_dashboard(); + + assert!(dashboard.validate().is_ok()); + assert_eq!(dashboard.total_usd_value(), 5480.0); + assert_eq!(dashboard.high_risk_approval_count(), 1); + } + + #[test] + fn settings_require_customization_defaults() { + let settings = WalletSettings { + theme: ThemeMode::Dark, + language: "en".to_string(), + currency: "USD".to_string(), + auto_lock_minutes: 5, + prefer_rabby_over_metamask: true, + }; + assert!(settings.validate().is_ok()); + } +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index d9205be..1addfeb 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,4 +1,6 @@ -use rabby_core::Workspace; +use rabby_core::{ + demo_dashboard, wallet_feature_summary, WalletDashboard, WalletFeatureStatus, Workspace, +}; use serde::Serialize; #[derive(Serialize)] @@ -7,6 +9,13 @@ struct FeatureRow { detail: &'static str, } +#[derive(Serialize)] +struct WalletFeatureRow { + feature: &'static str, + acceptance: &'static str, + status: WalletFeatureStatus, +} + #[tauri::command] fn workspace_summary() -> Vec { Workspace::feature_matrix() @@ -15,9 +24,30 @@ fn workspace_summary() -> Vec { .collect() } +#[tauri::command] +fn wallet_mvp_summary() -> Vec { + wallet_feature_summary() + .into_iter() + .map(|(feature, acceptance, status)| WalletFeatureRow { + feature, + acceptance, + status, + }) + .collect() +} + +#[tauri::command] +fn wallet_dashboard() -> WalletDashboard { + demo_dashboard() +} + fn main() { tauri::Builder::default() - .invoke_handler(tauri::generate_handler![workspace_summary]) + .invoke_handler(tauri::generate_handler![ + workspace_summary, + wallet_mvp_summary, + wallet_dashboard + ]) .run(tauri::generate_context!()) .expect("failed to run Rabby Tauri application"); } diff --git a/ui/app.js b/ui/app.js index 58e82b4..1ec139d 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1,15 +1,129 @@ -async function loadFeatures(){ - const fallback = [ - ["Terminal", "VT-style surface, tabs, split panes, Unicode"], - ["Connections", "Local shell, SSH, Telnet, Serial profile model"], - ["UX", "Command palette, shortcuts, quake window, restored sessions"], - ["Footprint", "Rust core + Tauri shell instead of Electron"] - ]; - let rows = fallback; - try { - const { invoke } = window.__TAURI__.core; - rows = (await invoke('workspace_summary')).map(x => [x.feature, x.detail]); - } catch (_) {} - document.getElementById('features').innerHTML = rows.map(([f,d]) => `
  • ${f}: ${d}
  • `).join(''); +const fallbackDashboard = { + active_account: { + name: 'Main Wallet', + address: '0x1111111111111111111111111111111111111111', + source: 'SeedPhrase' + }, + chains: [ + { id: 1, name: 'Ethereum' }, + { id: 8453, name: 'Base' } + ], + balances: [ + { chain_id: 1, symbol: 'ETH', amount: 1.24, usd_value: 4200 }, + { chain_id: 8453, symbol: 'USDC', amount: 1280, usd_value: 1280 } + ], + approvals: [ + { chain_id: 1, asset_symbol: 'USDC', spender: '0x2222222222222222222222222222222222222222', allowance: 'unlimited', risk: 'High' } + ], + activities: [ + { label: 'Swap ETH → USDC', status: 'Confirmed', chain_id: 1 } + ], + settings: { + theme: 'System', + language: 'en', + currency: 'USD', + auto_lock_minutes: 15, + prefer_rabby_over_metamask: true + } +}; + +const fallbackFeatures = [ + ['Create/unlock wallet', 'Password, lock/unlock, encrypted local state', 'Modeled'], + ['Import/create accounts', 'Seed phrase, private key, JSON, watch-only', 'Modeled'], + ['Multi-chain networks', 'Built-in, custom RPC, testnet, offline chain flags', 'Modeled'], + ['Portfolio dashboard', 'Balances, NFTs, DeFi positions, activity', 'UiPrototype'], + ['Dapp provider and permissions', 'Origin/account/chain permissions', 'Modeled'], + ['Signing and security previews', 'Typed data/message/transaction previews and risk', 'Modeled'], + ['Settings and customization', 'Theme, language, currency, auto-lock, default wallet mode', 'UiPrototype'] +]; + +function money(value) { + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(value || 0); } -loadFeatures(); + +function shortAddress(address) { + if (!address || address.length < 12) return address || ''; + return `${address.slice(0, 6)}…${address.slice(-4)}`; +} + +function statusLabel(value) { + if (typeof value === 'string') return value; + if (value && typeof value === 'object') return Object.keys(value)[0] || 'Modeled'; + return 'Modeled'; +} + +async function invokeOrFallback(command, fallback) { + try { + const tauri = window.__TAURI__?.core; + if (!tauri) return fallback; + return await tauri.invoke(command); + } catch (_) { + return fallback; + } +} + +function renderDashboard(dashboard) { + const total = dashboard.balances.reduce((sum, token) => sum + token.usd_value, 0); + const highRisk = dashboard.approvals.filter((approval) => ['High', 'Critical'].includes(statusLabel(approval.risk))).length; + + document.getElementById('account-address').textContent = shortAddress(dashboard.active_account.address); + document.getElementById('portfolio-total').textContent = money(total); + document.getElementById('hero-total').textContent = money(total); + document.getElementById('chain-count').textContent = dashboard.chains.length; + document.getElementById('risk-count').textContent = highRisk; + document.getElementById('approval-risk').textContent = highRisk; + + document.getElementById('token-list').innerHTML = dashboard.balances.map((token) => ` +
    +
    ${token.symbol.slice(0, 1)}
    +
    ${token.symbol}Chain ${token.chain_id}
    +
    ${money(token.usd_value)}${token.amount}
    +
    + `).join(''); + + document.getElementById('approval-list').innerHTML = dashboard.approvals.map((approval) => ` +
    +
    ${approval.asset_symbol} allowance${shortAddress(approval.spender)}
    + ${statusLabel(approval.risk)} +
    + `).join(''); + + document.getElementById('activity-list').innerHTML = dashboard.activities.map((activity) => ` +
    ${activity.label}${statusLabel(activity.status)} · Chain ${activity.chain_id}
    + `).join(''); + + const settings = dashboard.settings; + document.getElementById('settings-summary').innerHTML = [ + ['Theme', statusLabel(settings.theme)], + ['Language', settings.language], + ['Currency', settings.currency], + ['Auto-lock', `${settings.auto_lock_minutes} min`], + ['Default wallet', settings.prefer_rabby_over_metamask ? 'Rabby' : 'Browser default'] + ].map(([label, value]) => `
    ${label}${value}
    `).join(''); +} + +function renderFeatures(features) { + document.getElementById('wallet-features').innerHTML = features.map((feature) => { + const title = Array.isArray(feature) ? feature[0] : feature.feature; + const acceptance = Array.isArray(feature) ? feature[1] : feature.acceptance; + const status = Array.isArray(feature) ? feature[2] : statusLabel(feature.status); + return ` +
    + ${status} + ${title} +

    ${acceptance}

    +
    + `; + }).join(''); +} + +async function boot() { + const [dashboard, features] = await Promise.all([ + invokeOrFallback('wallet_dashboard', fallbackDashboard), + invokeOrFallback('wallet_mvp_summary', fallbackFeatures) + ]); + renderDashboard(dashboard); + renderFeatures(features); +} + +boot(); diff --git a/ui/index.html b/ui/index.html index 4b832d4..74380eb 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,35 +7,134 @@ - -
    -
    -
    Local Shell
    Prod SSH
    -
    -
    -
    bash — rabby-core tests
    $ cargo test -p rabby-core
    -running 6 tests
    -test slug_normalizes_profile_ids ... ok
    -test profile_validation_rejects_empty_command ... ok
    -test split_panes_count_nested_leaves ... ok
    -test split_ratio_has_sane_bounds ... ok
    -test workspace_adds_tabs_for_known_profiles ... ok
    -test feature_matrix_covers_tabby_readme_capabilities ... ok
    +  
    + -Rabby keeps Tabby-style UX while moving the app shell to Rust/Tauri.
    -
    profile inspector
    Session restore

    Tabs and split tree are represented in Rust.

    Small footprint target

    No Electron runtime; static HTML/CSS plus Tauri backend.

    -
    -
    + + +
    +
    +
    +

    RabbyHub/Rabby inspired UI

    +

    Portfolio dashboard

    +
    +
    + + +
    +
    + +
    +
    +

    Total balance

    + $0.00 + Across modeled token balances +
    +
    +

    Chains

    + 0 + Custom RPC ready +
    +
    +

    High-risk approvals

    + 0 + Review before signing +
    +
    + +
    +
    +
    +
    +

    Assets

    +

    Token balances

    +
    + +
    +
    +
    + +
    +
    +
    +

    Security

    +

    Approvals

    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +

    Activity

    +

    Recent wallet events

    +
    +
    +
    +
    + +
    +
    +
    +

    Settings

    +

    Customization

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Feature record

    +

    RabbyHub MVP coverage

    +
    + docs/rabbyhub-mvp-feature-inventory.md +
    +
    +
    +
    + diff --git a/ui/styles.css b/ui/styles.css index 4f407fb..dd5f944 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -1 +1,280 @@ -:root{font-family:Inter,ui-sans-serif,system-ui;color:#dce7ff;background:#090d18}*{box-sizing:border-box}body{margin:0;display:flex;min-height:100vh;background:radial-gradient(circle at 70% 10%,#203d7a55,transparent 38%),#090d18}.sidebar{width:290px;padding:22px;border-right:1px solid #263149;background:#0d1324cc;backdrop-filter:blur(14px)}.brand{display:flex;gap:12px;align-items:center;margin-bottom:24px}.logo{display:grid;place-items:center;width:42px;height:42px;border-radius:12px;background:linear-gradient(135deg,#7c5cff,#00d4ff);box-shadow:0 0 34px #3b82f6}.brand small{display:block;color:#8ea0c5}.primary,button{border:1px solid #34425f;background:#141d33;color:#e8f0ff;border-radius:10px;padding:10px 13px}button.primary{width:100%;background:linear-gradient(135deg,#5d5fef,#16c5d9);border:0;font-weight:700}nav{display:grid;gap:8px;margin:22px 0}nav a{padding:11px 12px;border-radius:10px;color:#aebfe4}.active,nav a:hover{background:#1d2a49;color:white}h3{font-size:12px;text-transform:uppercase;letter-spacing:.14em;color:#6f82aa}ul{padding-left:20px;color:#aebfe4;line-height:1.5}main{flex:1;display:flex;flex-direction:column}.tabs{height:62px;display:flex;gap:8px;align-items:center;padding:0 18px;border-bottom:1px solid #263149;background:#0b1020}.tab{padding:12px 16px;border-radius:12px 12px 0 0;background:#10182d;color:#aebfe4}.tab.active{background:#18233d;color:#fff}.tab span{color:#20e3b2}.spacer{flex:1}.workspace{flex:1;display:grid;grid-template-columns:1.35fr .65fr;gap:16px;padding:18px}.pane{border:1px solid #273753;border-radius:18px;background:#0c1222;overflow:hidden;box-shadow:0 24px 70px #0008}.chrome{height:42px;display:flex;align-items:center;padding:0 16px;background:#111a30;border-bottom:1px solid #273753;color:#8fa4d0;font-size:13px}pre{margin:0;padding:22px;color:#8df7c8;font:15px/1.6 'SFMono-Regular',Consolas,monospace}.cards{display:grid;gap:14px;padding:16px}article{padding:16px;border-radius:14px;background:#121d34;border:1px solid #283957}article p{color:#9fb0d4} +:root { + color: #19213d; + background: #eef3ff; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 12% 8%, rgba(112, 139, 255, 0.26), transparent 26%), + radial-gradient(circle at 88% 14%, rgba(85, 213, 190, 0.24), transparent 30%), + linear-gradient(135deg, #f8fbff 0%, #edf3ff 48%, #e8f1ff 100%); +} + +button, a { font: inherit; } +button { cursor: pointer; } + +.app-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 76px 320px minmax(0, 1fr); +} + +.rail { + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + padding: 22px 12px; + background: #ffffffcc; + border-right: 1px solid rgba(111, 130, 180, 0.18); + backdrop-filter: blur(18px); +} + +.brand-mark { + width: 44px; + height: 44px; + display: grid; + place-items: center; + border-radius: 16px; + color: white; + font-weight: 900; + letter-spacing: -0.05em; + background: linear-gradient(135deg, #7b61ff, #36d2b6); + box-shadow: 0 18px 42px rgba(90, 99, 255, 0.28); +} + +.rail-icon { + width: 44px; + height: 44px; + border: 0; + border-radius: 15px; + color: #72809f; + background: transparent; +} + +.rail-icon.active, +.rail-icon:hover { + color: #3154ff; + background: #eef2ff; +} + +.wallet-panel { + padding: 26px; + background: rgba(255, 255, 255, 0.72); + border-right: 1px solid rgba(111, 130, 180, 0.18); + backdrop-filter: blur(22px); +} + +.brand-row, +.topbar, +.panel-heading, +.account-topline, +.top-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +.eyebrow { + margin: 0 0 6px; + color: #7a88a8; + text-transform: uppercase; + font-size: 11px; + font-weight: 800; + letter-spacing: 0.14em; +} + +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 0; font-size: 24px; line-height: 1.1; } +h2 { margin-bottom: 0; font-size: 30px; letter-spacing: -0.04em; } +h3 { margin-bottom: 0; font-size: 18px; } + +.status-dot, +.feature-status, +.risk { + display: inline-flex; + align-items: center; + height: 28px; + padding: 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; +} + +.status-dot, +.feature-status { color: #3154ff; background: #edf1ff; } + +.account-card, +.panel, +.balance-card, +.metric-card { + border: 1px solid rgba(111, 130, 180, 0.18); + border-radius: 28px; + background: rgba(255, 255, 255, 0.82); + box-shadow: 0 20px 60px rgba(65, 78, 128, 0.12); +} + +.account-card { + margin: 24px 0; + padding: 22px; + background: linear-gradient(145deg, #22305b, #11172c); + color: white; + box-shadow: 0 22px 60px rgba(24, 35, 78, 0.28); +} + +.account-card p { color: #aebbe2; } +.account-card strong { font-size: 21px; } + +.account-actions { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + margin-top: 18px; +} + +.account-actions button, +.primary, +.secondary, +.ghost-button { + border: 0; + border-radius: 14px; + padding: 10px 14px; + font-weight: 800; +} + +.account-actions button, +.primary { + color: white; + background: linear-gradient(135deg, #6f64ff, #35ceb4); +} + +.secondary { color: #3154ff; background: #eef2ff; } +.ghost-button { color: #647194; background: #f3f6ff; } +.account-card .ghost-button { color: white; background: rgba(255, 255, 255, 0.12); } + +.side-list { display: grid; gap: 10px; } +.side-list a { + display: flex; + justify-content: space-between; + padding: 14px 16px; + color: #62708f; + text-decoration: none; + border-radius: 16px; +} +.side-list a.active, +.side-list a:hover { color: #24345f; background: #f2f5ff; } + +.content { + min-width: 0; + padding: 30px; + overflow: auto; +} + +.topbar { margin-bottom: 24px; } + +.hero-grid { + display: grid; + grid-template-columns: 1.5fr repeat(2, minmax(180px, 0.7fr)); + gap: 16px; + margin-bottom: 16px; +} + +.balance-card, +.metric-card, +.panel { padding: 22px; } +.balance-card { color: white; background: linear-gradient(145deg, #4e64ff, #8c62ff); } +.balance-card p, +.balance-card span { color: rgba(255, 255, 255, 0.76); } +.balance-card strong { display: block; font-size: 42px; letter-spacing: -0.05em; } +.metric-card strong { display: block; font-size: 36px; letter-spacing: -0.04em; } +.metric-card p, +.metric-card span { color: #7a88a8; } +.metric-card.warning strong { color: #ff7a45; } + +.two-column { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr); + gap: 16px; + margin-bottom: 16px; +} + +.asset-list, +.approval-list, +.activity-list, +.settings-grid, +.feature-grid { margin-top: 18px; } + +.asset-row, +.approval-row, +.activity-row { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0; + border-top: 1px solid #edf1fb; +} + +.asset-row:first-child, +.approval-row:first-child, +.activity-row:first-child { border-top: 0; } + +.coin-mark { + width: 42px; + height: 42px; + display: grid; + place-items: center; + color: white; + font-weight: 900; + border-radius: 50%; + background: linear-gradient(135deg, #35ceb4, #3154ff); +} + +.asset-row span, +.approval-row span, +.activity-row small { display: block; color: #8390ad; } +.asset-value { margin-left: auto; text-align: right; } + +.risk.high, +.risk.critical { color: #b94b18; background: #fff0e8; } +.risk.medium { color: #8a6500; background: #fff6d8; } +.risk.low { color: #147a58; background: #e5fbf3; } + +.settings-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} +.settings-grid div { + padding: 14px; + border-radius: 16px; + background: #f7f9ff; +} +.settings-grid span { display: block; color: #7d89a5; font-size: 12px; } + +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.feature-card { + padding: 16px; + border-radius: 20px; + background: #f8faff; + border: 1px solid #edf1fb; +} +.feature-card strong { display: block; margin: 12px 0 8px; } +.feature-card p { color: #6c7895; margin-bottom: 0; } +.doc-link { color: #3154ff; text-decoration: none; font-weight: 800; } + +@media (max-width: 1080px) { + .app-shell { grid-template-columns: 68px 1fr; } + .wallet-panel { display: none; } + .hero-grid, + .two-column { grid-template-columns: 1fr; } +}