feat(wallet): model RabbyHub MVP and modern dashboard
test / workspace (push) Successful in 13m26s

This commit is contained in:
Tom You
2026-07-08 23:41:07 -05:00
parent eb8540bb71
commit 480d686c41
7 changed files with 1243 additions and 45 deletions
+63
View File
@@ -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.
+7
View File
@@ -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 {
+606
View File
@@ -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<String>,
pub chains: Vec<u64>,
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<ChainConfig>,
pub balances: Vec<TokenBalance>,
pub approvals: Vec<ApprovalRecord>,
pub activities: Vec<ActivityEntry>,
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());
}
}
+32 -2
View File
@@ -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<FeatureRow> {
Workspace::feature_matrix()
@@ -15,9 +24,30 @@ fn workspace_summary() -> Vec<FeatureRow> {
.collect()
}
#[tauri::command]
fn wallet_mvp_summary() -> Vec<WalletFeatureRow> {
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");
}
+128 -14
View File
@@ -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]) => `<li><b>${f}</b>: ${d}</li>`).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
}
loadFeatures();
};
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);
}
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) => `
<div class="asset-row">
<div class="coin-mark">${token.symbol.slice(0, 1)}</div>
<div><strong>${token.symbol}</strong><span>Chain ${token.chain_id}</span></div>
<div class="asset-value"><strong>${money(token.usd_value)}</strong><span>${token.amount}</span></div>
</div>
`).join('');
document.getElementById('approval-list').innerHTML = dashboard.approvals.map((approval) => `
<div class="approval-row">
<div><strong>${approval.asset_symbol} allowance</strong><span>${shortAddress(approval.spender)}</span></div>
<span class="risk ${statusLabel(approval.risk).toLowerCase()}">${statusLabel(approval.risk)}</span>
</div>
`).join('');
document.getElementById('activity-list').innerHTML = dashboard.activities.map((activity) => `
<div class="activity-row"><span>↗</span><div><strong>${activity.label}</strong><small>${statusLabel(activity.status)} · Chain ${activity.chain_id}</small></div></div>
`).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]) => `<div><span>${label}</span><strong>${value}</strong></div>`).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 `
<article class="feature-card">
<span class="feature-status">${status}</span>
<strong>${title}</strong>
<p>${acceptance}</p>
</article>
`;
}).join('');
}
async function boot() {
const [dashboard, features] = await Promise.all([
invokeOrFallback('wallet_dashboard', fallbackDashboard),
invokeOrFallback('wallet_mvp_summary', fallbackFeatures)
]);
renderDashboard(dashboard);
renderFeatures(features);
}
boot();
+124 -25
View File
@@ -7,35 +7,134 @@
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<aside class="sidebar">
<div class="brand"><span class="logo"></span><div><strong>Rabby</strong><small>Rust + Tauri terminal</small></div></div>
<button class="primary">+ New tab</button>
<nav>
<a class="active">⌘ Local Shell</a>
<a>🔐 SSH: production</a>
<a>☎ Telnet lab</a>
<a>🔌 Serial /dev/ttyUSB0</a>
</nav>
<section><h3>Features</h3><ul id="features"><li>Loading Rust backend…</li></ul></section>
<div class="app-shell">
<aside class="rail" aria-label="Primary navigation">
<div class="brand-mark" aria-label="Rabby">R</div>
<button class="rail-icon active" title="Dashboard"></button>
<button class="rail-icon" title="Approvals"></button>
<button class="rail-icon" title="Activity"></button>
<button class="rail-icon" title="Settings"></button>
</aside>
<main>
<header class="tabs">
<div class="tab active">Local Shell <span></span></div><div class="tab">Prod SSH</div><div class="spacer"></div><button>Command palette</button><button>Quake mode</button>
</header>
<section class="workspace">
<div class="pane large"><div class="chrome">bash — rabby-core tests</div><pre>$ 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.</pre></div>
<div class="pane"><div class="chrome">profile inspector</div><div class="cards"><article><b>Session restore</b><p>Tabs and split tree are represented in Rust.</p></article><article><b>Small footprint target</b><p>No Electron runtime; static HTML/CSS plus Tauri backend.</p></article></div></div>
<aside class="wallet-panel">
<header class="brand-row">
<div>
<p class="eyebrow">Rabby Wallet MVP</p>
<h1>Clean multi-chain wallet</h1>
</div>
<span class="status-dot">Modeled</span>
</header>
<section class="account-card">
<div class="account-topline">
<span>Main Wallet</span>
<button class="ghost-button">•••</button>
</div>
<strong id="account-address">0x1111…1111</strong>
<p>Seed phrase · Ethereum + Base</p>
<div class="account-actions">
<button>Send</button>
<button>Receive</button>
<button>Swap</button>
</div>
</section>
<nav class="side-list">
<a class="active"><span>Portfolio</span><b id="portfolio-total">$0.00</b></a>
<a><span>Dapp permissions</span><b id="dapp-count">MVP</b></a>
<a><span>Approvals at risk</span><b id="risk-count">0</b></a>
<a><span>Customization</span><b>Theme</b></a>
</nav>
</aside>
<main class="content">
<header class="topbar">
<div>
<p class="eyebrow">RabbyHub/Rabby inspired UI</p>
<h2>Portfolio dashboard</h2>
</div>
<div class="top-actions">
<button class="secondary">Lock</button>
<button class="primary">Connect dapp</button>
</div>
</header>
<section class="hero-grid">
<article class="balance-card">
<p>Total balance</p>
<strong id="hero-total">$0.00</strong>
<span>Across modeled token balances</span>
</article>
<article class="metric-card">
<p>Chains</p>
<strong id="chain-count">0</strong>
<span>Custom RPC ready</span>
</article>
<article class="metric-card warning">
<p>High-risk approvals</p>
<strong id="approval-risk">0</strong>
<span>Review before signing</span>
</article>
</section>
<section class="two-column">
<article class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Assets</p>
<h3>Token balances</h3>
</div>
<button class="ghost-button">Refresh</button>
</div>
<div id="token-list" class="asset-list"></div>
</article>
<article class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Security</p>
<h3>Approvals</h3>
</div>
<button class="ghost-button">Revoke</button>
</div>
<div id="approval-list" class="approval-list"></div>
</article>
</section>
<section class="two-column lower">
<article class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Activity</p>
<h3>Recent wallet events</h3>
</div>
</div>
<div id="activity-list" class="activity-list"></div>
</article>
<article class="panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Settings</p>
<h3>Customization</h3>
</div>
</div>
<div id="settings-summary" class="settings-grid"></div>
</article>
</section>
<section class="panel feature-panel">
<div class="panel-heading">
<div>
<p class="eyebrow">Feature record</p>
<h3>RabbyHub MVP coverage</h3>
</div>
<a class="doc-link" href="../docs/rabbyhub-mvp-feature-inventory.md">docs/rabbyhub-mvp-feature-inventory.md</a>
</div>
<div id="wallet-features" class="feature-grid"></div>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>
+280 -1
View File
@@ -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; }
}