This commit is contained in:
@@ -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.
|
||||||
@@ -12,6 +12,7 @@ pub mod shortcut;
|
|||||||
pub mod terminal_model;
|
pub mod terminal_model;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
pub mod transfer;
|
pub mod transfer;
|
||||||
|
pub mod wallet_mvp;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
pub use config::{AppConfig, ShortcutBinding};
|
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 shortcut::{validate_unique_bindings, CommandBinding, KeyChord, ShortcutSequence};
|
||||||
pub use theme::Theme;
|
pub use theme::Theme;
|
||||||
pub use transfer::{TransferDirection, TransferEvent, TransferProtocol, TransferRequest};
|
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};
|
pub use workspace::{ConnectionKind, PaneNode, Profile, SplitAxis, Tab, Workspace};
|
||||||
|
|
||||||
impl Workspace {
|
impl Workspace {
|
||||||
|
|||||||
@@ -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
@@ -1,4 +1,6 @@
|
|||||||
use rabby_core::Workspace;
|
use rabby_core::{
|
||||||
|
demo_dashboard, wallet_feature_summary, WalletDashboard, WalletFeatureStatus, Workspace,
|
||||||
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -7,6 +9,13 @@ struct FeatureRow {
|
|||||||
detail: &'static str,
|
detail: &'static str,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct WalletFeatureRow {
|
||||||
|
feature: &'static str,
|
||||||
|
acceptance: &'static str,
|
||||||
|
status: WalletFeatureStatus,
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn workspace_summary() -> Vec<FeatureRow> {
|
fn workspace_summary() -> Vec<FeatureRow> {
|
||||||
Workspace::feature_matrix()
|
Workspace::feature_matrix()
|
||||||
@@ -15,9 +24,30 @@ fn workspace_summary() -> Vec<FeatureRow> {
|
|||||||
.collect()
|
.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() {
|
fn main() {
|
||||||
tauri::Builder::default()
|
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!())
|
.run(tauri::generate_context!())
|
||||||
.expect("failed to run Rabby Tauri application");
|
.expect("failed to run Rabby Tauri application");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,129 @@
|
|||||||
async function loadFeatures(){
|
const fallbackDashboard = {
|
||||||
const fallback = [
|
active_account: {
|
||||||
["Terminal", "VT-style surface, tabs, split panes, Unicode"],
|
name: 'Main Wallet',
|
||||||
["Connections", "Local shell, SSH, Telnet, Serial profile model"],
|
address: '0x1111111111111111111111111111111111111111',
|
||||||
["UX", "Command palette, shortcuts, quake window, restored sessions"],
|
source: 'SeedPhrase'
|
||||||
["Footprint", "Rust core + Tauri shell instead of Electron"]
|
},
|
||||||
];
|
chains: [
|
||||||
let rows = fallback;
|
{ id: 1, name: 'Ethereum' },
|
||||||
try {
|
{ id: 8453, name: 'Base' }
|
||||||
const { invoke } = window.__TAURI__.core;
|
],
|
||||||
rows = (await invoke('workspace_summary')).map(x => [x.feature, x.detail]);
|
balances: [
|
||||||
} catch (_) {}
|
{ chain_id: 1, symbol: 'ETH', amount: 1.24, usd_value: 4200 },
|
||||||
document.getElementById('features').innerHTML = rows.map(([f,d]) => `<li><b>${f}</b>: ${d}</li>`).join('');
|
{ 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) => `
|
||||||
|
<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();
|
||||||
|
|||||||
+127
-28
@@ -7,35 +7,134 @@
|
|||||||
<link rel="stylesheet" href="styles.css" />
|
<link rel="stylesheet" href="styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<aside class="sidebar">
|
<div class="app-shell">
|
||||||
<div class="brand"><span class="logo">▰</span><div><strong>Rabby</strong><small>Rust + Tauri terminal</small></div></div>
|
<aside class="rail" aria-label="Primary navigation">
|
||||||
<button class="primary">+ New tab</button>
|
<div class="brand-mark" aria-label="Rabby">R</div>
|
||||||
<nav>
|
<button class="rail-icon active" title="Dashboard">⌂</button>
|
||||||
<a class="active">⌘ Local Shell</a>
|
<button class="rail-icon" title="Approvals">✓</button>
|
||||||
<a>🔐 SSH: production</a>
|
<button class="rail-icon" title="Activity">↗</button>
|
||||||
<a>☎ Telnet lab</a>
|
<button class="rail-icon" title="Settings">⚙</button>
|
||||||
<a>🔌 Serial /dev/ttyUSB0</a>
|
</aside>
|
||||||
</nav>
|
|
||||||
<section><h3>Features</h3><ul id="features"><li>Loading Rust backend…</li></ul></section>
|
|
||||||
</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>
|
<aside class="wallet-panel">
|
||||||
<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>
|
<header class="brand-row">
|
||||||
</section>
|
<div>
|
||||||
</main>
|
<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>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+280
-1
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user