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
+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());
}
}