diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..3deb0e11c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Sage is a light wallet for the Chia blockchain built with Tauri v2 (Rust backend + React/TypeScript frontend). It supports desktop (macOS, Linux, Windows) and mobile (iOS, Android) platforms. + +## Common Commands + +### Frontend +```bash +pnpm dev # Vite dev server (port 1420) +pnpm tauri dev # Full app in dev mode +pnpm tauri dev --release # Dev mode with optimizations +pnpm build # Build frontend only +pnpm tauri build # Build complete application +pnpm lint # ESLint +pnpm prettier # Format code +pnpm prettier:check # Check formatting +pnpm extract # Extract i18n translations +pnpm compile # Compile i18n translations +``` + +### Backend (Rust) +```bash +cargo clippy --workspace --all-features --all-targets # Lint +cargo fmt --all -- --files-with-diff --check # Check formatting +cargo test -p sage-wallet # Run wallet tests (main test suite) +cargo test --workspace --all-features # Run all tests +``` + +### Database (requires sqlx-cli) +```bash +# Needs .env with DATABASE_URL=sqlite://./test.sqlite +sqlx db reset -y +cargo sqlx prepare --workspace +``` + +## Architecture + +### Data Flow +``` +React Frontend (src/) → IPC (tauri-specta) → Tauri Commands (src-tauri/) → sage crate → sage-wallet → sage-database (SQLite) +``` + +TypeScript bindings are auto-generated from Rust types via `specta`/`tauri-specta` into `src/bindings.ts`. + +### Rust Workspace Crates (`crates/`) +- **sage** — Top-level orchestration, sync management +- **sage-wallet** — Core wallet logic, blockchain sync, coin drivers +- **sage-database** — SQLite via sqlx, compile-time checked queries +- **sage-api** — API definitions shared between Tauri and OpenAPI/RPC +- **sage-api/macro** — Proc macros for API generation +- **sage-keychain** — BIP39 mnemonics, AES-GCM encryption, Argon2 key derivation +- **sage-config** — TOML configuration management +- **sage-client** — RPC client +- **sage-rpc** — Axum-based RPC server +- **sage-cli** — CLI binary +- **sage-assets** — External asset fetching + +### Frontend (`src/`) +- **components/ui/** — Shadcn UI components (New York style, Radix primitives) +- **pages/** — Route pages (hash router for desktop compatibility) +- **hooks/** — Custom React hooks +- **contexts/** — React contexts (Wallet, Peer, Price, Error, etc.) +- **state.ts** — Zustand global stores +- **locales/** — Lingui i18n (en-US, de-DE, zh-CN, es-MX) +- **themes/** — CSS variable-based theming with light/dark mode + +### Key Patterns +- **State**: Zustand for global state, React Context for scoped state +- **Forms**: react-hook-form + zod validation +- **Tables**: @tanstack/react-table +- **i18n**: Lingui with PO format (extract → compile workflow) +- **Rust errors**: `thiserror` custom error types +- **Async**: Tokio runtime, Arc+Mutex for shared state, MPSC channels for sync events +- **Chia SDK**: `chia-wallet-sdk` v0.33.0 is the primary blockchain dependency + +## Code Style + +### Rust +- Edition 2024, toolchain 1.89.0 +- `unsafe_code = "deny"` workspace-wide +- Strict clippy: `all = deny`, `pedantic = warn` +- Rustfmt with edition 2024 settings + +### Frontend +- TypeScript strict mode, ESM modules +- pnpm (v10.13.1) as package manager +- Tailwind CSS for styling +- Path alias: `@/*` → `./src/*` + +## Platform-Specific +- **src-tauri/** — Tauri wrapper and entry point +- **tauri-plugin-sage/** — Custom native plugin (iOS/Android platform code) +- Mobile uses conditional compilation (`cfg(mobile)`) +- Windows build requires CMake, Clang, NASM diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 16263fdd9..263433760 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -3,14 +3,15 @@ "logout": true, "resync": true, "generate_mnemonic": false, - "import_key": true, - "delete_key": false, + "import_wallet": true, + "import_addresses": true, + "delete_wallet": false, "delete_database": false, - "rename_key": false, + "rename_wallet": false, "set_wallet_emoji": false, - "get_key": false, - "get_secret_key": false, - "get_keys": false, + "get_wallet": false, + "get_wallet_secrets": false, + "get_wallets": false, "get_sync_status": true, "get_version": false, "get_database_stats": true, diff --git a/crates/sage-api/src/records.rs b/crates/sage-api/src/records.rs index e48eb253c..a729de870 100644 --- a/crates/sage-api/src/records.rs +++ b/crates/sage-api/src/records.rs @@ -11,6 +11,7 @@ mod pending_transaction; mod token; mod transaction; mod transaction_summary; +mod wallet; pub use coin::*; pub use derivation::*; @@ -25,3 +26,4 @@ pub use pending_transaction::*; pub use token::*; pub use transaction::*; pub use transaction_summary::*; +pub use wallet::*; diff --git a/crates/sage-api/src/types/key_info.rs b/crates/sage-api/src/records/wallet.rs similarity index 64% rename from crates/sage-api/src/types/key_info.rs rename to crates/sage-api/src/records/wallet.rs index 36dd3fead..2566518b6 100644 --- a/crates/sage-api/src/types/key_info.rs +++ b/crates/sage-api/src/records/wallet.rs @@ -3,22 +3,30 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct KeyInfo { +pub struct WalletRecord { pub name: String, pub fingerprint: u32, - pub public_key: String, - pub kind: KeyKind, - pub has_secrets: bool, + #[serde(flatten)] + pub kind: WalletKind, pub network_id: String, pub emoji: Option, } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -#[serde(rename_all = "snake_case")] -pub enum KeyKind { - Bls, +#[serde(tag = "type", rename_all = "snake_case")] +pub enum WalletKind { + Bls { + public_key: String, + has_secrets: bool, + }, + Vault { + launcher_id: String, + }, + Watch { + addresses: Vec, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/sage-api/src/requests.rs b/crates/sage-api/src/requests.rs index 5546a2b23..ddab9ddc0 100644 --- a/crates/sage-api/src/requests.rs +++ b/crates/sage-api/src/requests.rs @@ -1,7 +1,7 @@ mod action_system; mod actions; mod data; -mod keys; +mod wallets; mod offers; mod settings; mod transactions; @@ -9,7 +9,7 @@ mod transactions; pub use action_system::*; pub use actions::*; pub use data::*; -pub use keys::*; +pub use wallets::*; pub use offers::*; pub use settings::*; pub use transactions::*; diff --git a/crates/sage-api/src/requests/keys.rs b/crates/sage-api/src/requests/wallets.rs similarity index 80% rename from crates/sage-api/src/requests/keys.rs rename to crates/sage-api/src/requests/wallets.rs index 573bf53c4..31ad5dc14 100644 --- a/crates/sage-api/src/requests/keys.rs +++ b/crates/sage-api/src/requests/wallets.rs @@ -1,12 +1,12 @@ use serde::{Deserialize, Serialize}; -use crate::{KeyInfo, SecretKeyInfo}; +use crate::{SecretKeyInfo, WalletRecord}; /// Login to a wallet using a fingerprint #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", + tag = "Wallets", description = "Authenticate and log into a wallet using its fingerprint. This must be called before most other endpoints." ) )] @@ -22,7 +22,7 @@ pub struct Login { /// Response from logging into a wallet #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] @@ -33,7 +33,7 @@ pub struct LoginResponse {} #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", + tag = "Wallets", description = "Log out of the current wallet session and clear authentication." ) )] @@ -45,7 +45,7 @@ pub struct Logout {} /// Response from logging out of a wallet #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] @@ -105,7 +105,7 @@ pub struct ResyncResponse {} #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", + tag = "Wallets", description = "Generate a new BIP-39 mnemonic phrase (12 or 24 words) for wallet creation." ) )] @@ -121,7 +121,7 @@ pub struct GenerateMnemonic { /// Response containing the generated mnemonic phrase #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] @@ -137,18 +137,18 @@ pub struct GenerateMnemonicResponse { pub mnemonic: String, } -/// Import a wallet key +/// Import a wallet #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", + tag = "Wallets", description = "Import a wallet using a mnemonic phrase or private key. Optionally saves secrets and automatically logs in." ) )] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct ImportKey { +pub struct ImportWallet { /// Display name for the wallet pub name: String, /// Mnemonic phrase or private key @@ -183,16 +183,56 @@ fn yes() -> bool { true } -/// Response with imported key fingerprint +/// Response with imported wallet fingerprint #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct ImportKeyResponse { - /// Fingerprint of the imported key +pub struct ImportWalletResponse { + /// Fingerprint of the imported wallet + #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] + pub fingerprint: u32, +} + +/// Import a read-only wallet using a list of addresses. Optionally logs in. +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Wallets", + description = "Import a read-only wallet using a list of addresses. Optionally logs in." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ImportAddresses { + /// Display name for the wallet + pub name: String, + /// List of addresses + pub addresses: Vec, + /// Whether to automatically login after import + #[serde(default = "yes")] + #[cfg_attr(feature = "openapi", schema(default = true))] + pub login: bool, + /// Optional emoji identifier + #[serde(default)] + #[cfg_attr(feature = "openapi", schema(nullable = true))] + pub emoji: Option, +} + +/// Response with imported wallet fingerprint +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Wallets") +)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct ImportAddressesResponse { + /// Fingerprint of the imported wallet #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] pub fingerprint: u32, } @@ -223,45 +263,45 @@ pub struct DeleteDatabase { #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct DeleteDatabaseResponse {} -/// Delete a wallet key +/// Delete a wallet #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", - description = "Permanently delete a wallet key from the system. This action cannot be undone." + tag = "Wallets", + description = "Permanently delete a wallet from the system. This action cannot be undone." ) )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct DeleteKey { +pub struct DeleteWallet { /// Wallet fingerprint to delete #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] pub fingerprint: u32, } -/// Response for key deletion +/// Response for wallet deletion #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct DeleteKeyResponse {} +pub struct DeleteWalletResponse {} -/// Rename a wallet key +/// Rename a wallet #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", - description = "Change the display name of a wallet key." + tag = "Wallets", + description = "Change the display name of a wallet." ) )] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct RenameKey { +pub struct RenameWallet { /// Wallet fingerprint #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] pub fingerprint: u32, @@ -269,21 +309,21 @@ pub struct RenameKey { pub name: String, } -/// Response for key rename +/// Response for wallet rename #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct RenameKeyResponse {} +pub struct RenameWalletResponse {} /// Set wallet emoji #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", + tag = "Wallets", description = "Set an emoji identifier/avatar for a wallet to make it easier to distinguish." ) )] @@ -302,97 +342,97 @@ pub struct SetWalletEmoji { /// Response for emoji update #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] pub struct SetWalletEmojiResponse {} -/// List all wallet keys +/// List all wallets #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", - description = "List all available wallet keys stored in the system." + tag = "Wallets", + description = "List all available wallets stored in the system." ) )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetKeys {} +pub struct GetWallets {} -/// Response with all wallet keys +/// Response with all wallets #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetKeysResponse { - /// List of wallet keys - pub keys: Vec, +pub struct GetWalletsResponse { + /// List of wallet records + pub wallets: Vec, } -/// Get a specific wallet key +/// Get a specific wallet #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", - description = "Get information about a specific wallet key by fingerprint." + tag = "Wallets", + description = "Get information about a specific wallet by fingerprint." ) )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetKey { +pub struct GetWallet { /// Wallet fingerprint (uses currently logged in if null) #[serde(default)] #[cfg_attr(feature = "openapi", schema(nullable = true, example = 1_234_567_890))] pub fingerprint: Option, } -/// Response with key information +/// Response with wallet information #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetKeyResponse { - /// Key information if found +pub struct GetWalletResponse { + /// Wallet record if found #[cfg_attr(feature = "openapi", schema(nullable = true))] - pub key: Option, + pub wallet: Option, } -/// Get wallet secret key +/// Get wallet secrets #[cfg_attr( feature = "openapi", crate::openapi_attr( - tag = "Authentication & Keys", - description = "Retrieve the secret key (mnemonic) for a wallet. Requires authentication." + tag = "Wallets", + description = "Retrieve the secrets (mnemonic/key) for a wallet. Requires authentication." ) )] #[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetSecretKey { +pub struct GetWalletSecrets { /// Wallet fingerprint #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] pub fingerprint: u32, } -/// Response with secret key information +/// Response with wallet secrets #[cfg_attr( feature = "openapi", - crate::openapi_attr(tag = "Authentication & Keys") + crate::openapi_attr(tag = "Wallets") )] #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "tauri", derive(specta::Type))] #[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] -pub struct GetSecretKeyResponse { +pub struct GetWalletSecretsResponse { /// Secret key information if authorized #[cfg_attr(feature = "openapi", schema(nullable = true))] pub secrets: Option, diff --git a/crates/sage-api/src/types.rs b/crates/sage-api/src/types.rs index b17cbe1d7..ae7dbeaac 100644 --- a/crates/sage-api/src/types.rs +++ b/crates/sage-api/src/types.rs @@ -2,12 +2,10 @@ mod address_kind; mod amount; mod asset; mod error_kind; -mod key_info; mod unit; pub use address_kind::*; pub use amount::*; pub use asset::*; pub use error_kind::*; -pub use key_info::*; pub use unit::*; diff --git a/crates/sage-api/src/types/asset.rs b/crates/sage-api/src/types/asset.rs index accf323c2..35cb26668 100644 --- a/crates/sage-api/src/types/asset.rs +++ b/crates/sage-api/src/types/asset.rs @@ -25,4 +25,5 @@ pub enum AssetKind { Nft, Did, Option, + Vault, } diff --git a/crates/sage-database/src/tables/assets/asset.rs b/crates/sage-database/src/tables/assets/asset.rs index ae214fa34..537e59f5c 100644 --- a/crates/sage-database/src/tables/assets/asset.rs +++ b/crates/sage-database/src/tables/assets/asset.rs @@ -9,6 +9,7 @@ pub enum AssetKind { Nft, Did, Option, + Vault, } impl Convert for i64 { @@ -18,6 +19,7 @@ impl Convert for i64 { 1 => AssetKind::Nft, 2 => AssetKind::Did, 3 => AssetKind::Option, + 4 => AssetKind::Vault, _ => return Err(DatabaseError::InvalidEnumVariant), }) } diff --git a/crates/sage-database/src/tables/coins.rs b/crates/sage-database/src/tables/coins.rs index 128f5ec8f..b4d91c91f 100644 --- a/crates/sage-database/src/tables/coins.rs +++ b/crates/sage-database/src/tables/coins.rs @@ -16,6 +16,7 @@ pub enum CoinKind { Did, Nft, Option, + Vault, } #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -233,6 +234,7 @@ impl Database { AssetKind::Nft => CoinKind::Nft, AssetKind::Did => CoinKind::Did, AssetKind::Option => CoinKind::Option, + AssetKind::Vault => CoinKind::Vault, })) } } @@ -487,7 +489,7 @@ async fn subscription_coin_ids(conn: impl SqliteExecutor<'_>) -> Result, coin_id: Bytes32) -> Result CoinKind::Nft, AssetKind::Did => CoinKind::Did, AssetKind::Option => CoinKind::Option, + AssetKind::Vault => CoinKind::Vault, })) } diff --git a/crates/sage-database/src/tables/p2_puzzles.rs b/crates/sage-database/src/tables/p2_puzzles.rs index 18f414d7c..02802c50e 100644 --- a/crates/sage-database/src/tables/p2_puzzles.rs +++ b/crates/sage-database/src/tables/p2_puzzles.rs @@ -1,14 +1,20 @@ -use chia_wallet_sdk::{prelude::*, types::puzzles::P2DelegatedConditionsArgs}; +use chia_wallet_sdk::{ + driver::mips_puzzle_hash, + prelude::*, + types::puzzles::{P2DelegatedConditionsArgs, SingletonMember}, +}; use sqlx::{SqliteExecutor, query}; use crate::{Convert, Database, DatabaseError, DatabaseTx, Result}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum P2PuzzleKind { - PublicKey, - Clawback, - Option, - Arbor, + PublicKey = 0, + Clawback = 1, + Option = 2, + Arbor = 3, + Vault = 4, + External = 5, } #[derive(Debug, Clone, Copy)] @@ -17,6 +23,8 @@ pub enum P2Puzzle { Clawback(Clawback), Option(Underlying), Arbor(PublicKey), + Vault(P2Vault), + External, } #[derive(Debug, Clone, Copy)] @@ -37,6 +45,11 @@ pub struct Underlying { pub strike_type: OptionType, } +#[derive(Debug, Clone, Copy)] +pub struct P2Vault { + pub launcher_id: Bytes32, +} + #[derive(Debug, Clone, Copy)] pub struct Derivation { pub derivation_index: u32, @@ -108,6 +121,12 @@ impl Database { Ok(P2Puzzle::Arbor(key)) } + P2PuzzleKind::Vault => { + let launcher_id = vault_launcher_id(&self.pool, puzzle_hash).await?; + + Ok(P2Puzzle::Vault(P2Vault { launcher_id })) + } + P2PuzzleKind::External => Ok(P2Puzzle::External), } } @@ -130,12 +149,12 @@ impl Database { } impl DatabaseTx<'_> { - pub async fn custody_p2_puzzle_hash( + pub async fn derivation_p2_puzzle_hash( &mut self, derivation_index: u32, is_hardened: bool, ) -> Result { - custody_p2_puzzle_hash(&mut *self.tx, derivation_index, is_hardened).await + derivation_p2_puzzle_hash(&mut *self.tx, derivation_index, is_hardened).await } pub async fn is_custody_p2_puzzle_hash(&mut self, puzzle_hash: Bytes32) -> Result { @@ -154,13 +173,13 @@ impl DatabaseTx<'_> { unused_derivation_index(&mut *self.tx, is_hardened).await } - pub async fn insert_custody_p2_puzzle( + pub async fn insert_derivation_p2_puzzle( &mut self, p2_puzzle_hash: Bytes32, key: PublicKey, derivation: Derivation, ) -> Result<()> { - insert_custody_p2_puzzle(&mut *self.tx, p2_puzzle_hash, key, derivation).await + insert_derivation_p2_puzzle(&mut *self.tx, p2_puzzle_hash, key, derivation).await } pub async fn insert_clawback_p2_puzzle(&mut self, clawback: ClawbackV2) -> Result<()> { @@ -174,10 +193,18 @@ impl DatabaseTx<'_> { pub async fn insert_arbor_p2_puzzle(&mut self, key: PublicKey) -> Result<()> { insert_arbor_p2_puzzle(&mut *self.tx, key).await } + + pub async fn insert_vault_p2_puzzle(&mut self, vault: P2Vault) -> Result<()> { + insert_vault_p2_puzzle(&mut *self.tx, vault).await + } + + pub async fn insert_external_p2_puzzle(&mut self, p2_puzzle_hash: Bytes32) -> Result<()> { + insert_external_p2_puzzle(&mut *self.tx, p2_puzzle_hash).await + } } async fn custody_p2_puzzle_hashes(conn: impl SqliteExecutor<'_>) -> Result> { - query!("SELECT hash FROM p2_puzzles WHERE kind IN (0, 3)") + query!("SELECT hash FROM p2_puzzles WHERE kind IN (0, 3, 4, 5)") .fetch_all(conn) .await? .into_iter() @@ -185,7 +212,7 @@ async fn custody_p2_puzzle_hashes(conn: impl SqliteExecutor<'_>) -> Result, derivation_index: u32, is_hardened: bool, @@ -212,7 +239,7 @@ async fn is_custody_p2_puzzle_hash( let puzzle_hash = puzzle_hash.as_ref(); Ok(query!( - "SELECT COUNT(*) AS count FROM p2_puzzles WHERE hash = ? AND kind IN (0, 3)", + "SELECT COUNT(*) AS count FROM p2_puzzles WHERE hash = ? AND kind IN (0, 3, 4, 5)", puzzle_hash ) .fetch_one(conn) @@ -327,7 +354,7 @@ async fn unused_derivation_index(conn: impl SqliteExecutor<'_>, is_hardened: boo .convert() } -async fn insert_custody_p2_puzzle( +async fn insert_derivation_p2_puzzle( conn: impl SqliteExecutor<'_>, p2_puzzle_hash: Bytes32, key: PublicKey, @@ -441,6 +468,46 @@ async fn insert_arbor_p2_puzzle(conn: impl SqliteExecutor<'_>, key: PublicKey) - Ok(()) } +async fn insert_vault_p2_puzzle(conn: impl SqliteExecutor<'_>, vault: P2Vault) -> Result<()> { + let member_puzzle_hash = SingletonMember::new(vault.launcher_id).curry_tree_hash(); + let p2_puzzle_hash = mips_puzzle_hash(0, vec![], member_puzzle_hash, true).to_vec(); + let launcher_id = vault.launcher_id.as_ref(); + + query!( + " + INSERT OR IGNORE INTO p2_puzzles (hash, kind) VALUES (?, 4); + + INSERT OR IGNORE INTO p2_vaults (p2_puzzle_id, vault_asset_id) + VALUES ((SELECT id FROM p2_puzzles WHERE hash = ?), (SELECT id FROM assets WHERE hash = ?)); + ", + p2_puzzle_hash, + p2_puzzle_hash, + launcher_id, + ) + .execute(conn) + .await?; + + Ok(()) +} + +async fn insert_external_p2_puzzle( + conn: impl SqliteExecutor<'_>, + p2_puzzle_hash: Bytes32, +) -> Result<()> { + let p2_puzzle_hash = p2_puzzle_hash.as_ref(); + + query!( + " + INSERT OR IGNORE INTO p2_puzzles (hash, kind) VALUES (?, 5); + ", + p2_puzzle_hash + ) + .execute(conn) + .await?; + + Ok(()) +} + async fn p2_puzzle_kind( conn: impl SqliteExecutor<'_>, p2_puzzle_hash: Bytes32, @@ -456,6 +523,8 @@ async fn p2_puzzle_kind( 1 => P2PuzzleKind::Clawback, 2 => P2PuzzleKind::Option, 3 => P2PuzzleKind::Arbor, + 4 => P2PuzzleKind::Vault, + 5 => P2PuzzleKind::External, _ => return Err(DatabaseError::InvalidEnumVariant), }) } @@ -554,6 +623,28 @@ async fn arbor_key( row.map(|row| row.key.convert()).transpose() } +async fn vault_launcher_id( + conn: impl SqliteExecutor<'_>, + p2_puzzle_hash: Bytes32, +) -> Result { + let p2_puzzle_hash = p2_puzzle_hash.as_ref(); + + query!( + " + SELECT assets.hash AS launcher_id + FROM p2_puzzles + INNER JOIN p2_vaults ON p2_vaults.p2_puzzle_id = p2_puzzles.id + INNER JOIN assets ON assets.id = p2_vaults.vault_asset_id + WHERE p2_puzzles.hash = ? + ", + p2_puzzle_hash + ) + .fetch_one(conn) + .await? + .launcher_id + .convert() +} + async fn derivation( conn: impl SqliteExecutor<'_>, public_key: PublicKey, diff --git a/crates/sage-keychain/src/key_data.rs b/crates/sage-keychain/src/key_data.rs index 07fc0fa62..33704c6ae 100644 --- a/crates/sage-keychain/src/key_data.rs +++ b/crates/sage-keychain/src/key_data.rs @@ -17,6 +17,14 @@ pub enum KeyData { entropy: bool, encrypted: Encrypted, }, + Vault { + #[serde_as(as = "Bytes")] + launcher_id: [u8; 32], + }, + Watch { + #[serde_as(as = "Vec")] + p2_puzzle_hashes: Vec<[u8; 32]>, + }, } #[serde_as] diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index 235f143a8..b29032b73 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use bip39::Mnemonic; use chia_wallet_sdk::prelude::*; -use rand::SeedableRng; +use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use crate::{ @@ -56,7 +56,7 @@ impl Keychain { Some(KeyData::Public { master_pk } | KeyData::Secret { master_pk, .. }) => { Ok(Some(PublicKey::from_bytes(master_pk)?)) } - None => Ok(None), + Some(KeyData::Vault { .. } | KeyData::Watch { .. }) | None => Ok(None), } } @@ -66,7 +66,9 @@ impl Keychain { password: &[u8], ) -> Result<(Option, Option), KeychainError> { match self.keys.get(&fingerprint) { - Some(KeyData::Public { .. }) | None => Ok((None, None)), + Some(KeyData::Public { .. } | KeyData::Vault { .. } | KeyData::Watch { .. }) | None => { + Ok((None, None)) + } Some(KeyData::Secret { entropy, encrypted, .. }) => { @@ -89,13 +91,34 @@ impl Keychain { } } + pub fn extract_vault_id(&self, fingerprint: u32) -> Option { + match self.keys.get(&fingerprint) { + Some(KeyData::Vault { launcher_id }) => Some(Bytes32::new(*launcher_id)), + Some(KeyData::Public { .. } | KeyData::Secret { .. } | KeyData::Watch { .. }) + | None => None, + } + } + + pub fn extract_watch_p2_puzzle_hashes(&self, fingerprint: u32) -> Option> { + match self.keys.get(&fingerprint) { + Some(KeyData::Watch { p2_puzzle_hashes }) => Some( + p2_puzzle_hashes + .iter() + .map(|p2_puzzle_hash| Bytes32::new(*p2_puzzle_hash)) + .collect(), + ), + Some(KeyData::Public { .. } | KeyData::Secret { .. } | KeyData::Vault { .. }) + | None => None, + } + } + pub fn has_secret_key(&self, fingerprint: u32) -> bool { let Some(key_data) = self.keys.get(&fingerprint) else { return false; }; match key_data { - KeyData::Public { .. } => false, + KeyData::Public { .. } | KeyData::Vault { .. } | KeyData::Watch { .. } => false, KeyData::Secret { .. } => true, } } @@ -175,4 +198,47 @@ impl Keychain { Ok(fingerprint) } + + pub fn add_vault(&mut self, launcher_id: &Bytes32) -> Result { + let fingerprint = u32::from_be_bytes([ + launcher_id[0], + launcher_id[1], + launcher_id[2], + launcher_id[3], + ]); + + if self.contains(fingerprint) { + return Err(KeychainError::KeyExists); + } + + self.keys.insert( + fingerprint, + KeyData::Vault { + launcher_id: launcher_id.to_bytes(), + }, + ); + + Ok(fingerprint) + } + + pub fn add_watch_p2_puzzle_hashes( + &mut self, + p2_puzzle_hashes: &[Bytes32], + ) -> Result { + let fingerprint = u32::from_be_bytes(self.rng.r#gen::<[u8; 4]>()); + + if self.contains(fingerprint) { + return Err(KeychainError::KeyExists); + } + + let p2_puzzle_hashes = p2_puzzle_hashes + .iter() + .map(|p2_puzzle_hash| p2_puzzle_hash.to_bytes()) + .collect(); + + self.keys + .insert(fingerprint, KeyData::Watch { p2_puzzle_hashes }); + + Ok(fingerprint) + } } diff --git a/crates/sage-rpc/src/openapi.rs b/crates/sage-rpc/src/openapi.rs index b1f2d8c50..616d8b3fd 100644 --- a/crates/sage-rpc/src/openapi.rs +++ b/crates/sage-rpc/src/openapi.rs @@ -68,9 +68,9 @@ pub fn generate_openapi() -> OpenApi { .schema_from::() .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() - .schema_from::() + .schema_from::() .schema_from::() .schema_from::() .schema_from::() diff --git a/crates/sage-rpc/src/tests.rs b/crates/sage-rpc/src/tests.rs index d4217435a..a32d1f109 100644 --- a/crates/sage-rpc/src/tests.rs +++ b/crates/sage-rpc/src/tests.rs @@ -19,7 +19,7 @@ use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha8Rng; use rustls::crypto::aws_lc_rs::default_provider; use sage::Sage; -use sage_api::{Amount, GetKey, GetPeers, GetSyncStatus, GetVersion, ImportKey, Login, SendXch}; +use sage_api::{Amount, GetPeers, GetSyncStatus, GetVersion, GetWallet, ImportWallet, Login, SendXch}; use sage_api_macro::impl_endpoints; use sage_wallet::{SyncCommand, SyncEvent}; use serde::{Serialize, de::DeserializeOwned}; @@ -128,7 +128,7 @@ impl TestApp { } let fingerprint = self - .import_key(ImportKey { + .import_wallet(ImportWallet { name: "Alice".to_string(), key: mnemonic.to_string(), derivation_index: 0, @@ -200,9 +200,9 @@ async fn test_initial_state() -> Result<()> { let fingerprint = app.setup_bls(0).await?; let key = app - .get_key(GetKey { fingerprint: None }) + .get_wallet(GetWallet { fingerprint: None }) .await? - .key + .wallet .expect("should be logged in"); assert_eq!(key.fingerprint, fingerprint); @@ -217,7 +217,7 @@ async fn test_initial_state() -> Result<()> { assert_eq!(status.synced_coins, 0); assert_eq!(status.total_coins, 0); - assert_eq!(status.balance.to_u64(), Some(0)); + assert_eq!(status.selectable_balance.to_u64(), Some(0)); assert_eq!(status.unhardened_derivation_index, 1000); assert_eq!(status.hardened_derivation_index, 0); assert_eq!( @@ -242,7 +242,7 @@ async fn test_send_xch() -> Result<()> { let balance = app .get_sync_status(GetSyncStatus {}) .await? - .balance + .selectable_balance .to_u64(); assert_eq!(balance, Some(1000)); @@ -263,7 +263,7 @@ async fn test_send_xch() -> Result<()> { let balance = app .get_sync_status(GetSyncStatus {}) .await? - .balance + .selectable_balance .to_u64(); assert_eq!(balance, Some(0)); @@ -274,7 +274,7 @@ async fn test_send_xch() -> Result<()> { let balance = app .get_sync_status(GetSyncStatus {}) .await? - .balance + .selectable_balance .to_u64(); assert_eq!(balance, Some(2000)); diff --git a/crates/sage-wallet/src/error.rs b/crates/sage-wallet/src/error.rs index 578206a7a..9164beee4 100644 --- a/crates/sage-wallet/src/error.rs +++ b/crates/sage-wallet/src/error.rs @@ -107,6 +107,9 @@ pub enum WalletError { #[error("Missing asset with id {0}")] MissingAsset(Bytes32), + #[error("Cannot select vault coins")] + CannotSelectVaultCoins, + #[error("Uncancellable offer")] UncancellableOffer, @@ -127,4 +130,7 @@ pub enum WalletError { #[error("Try from int error: {0}")] TryFromInt(#[from] TryFromIntError), + + #[error("Vault wallets do not support derivations")] + DerivationsNotSupported, } diff --git a/crates/sage-wallet/src/sync_manager/wallet_sync.rs b/crates/sage-wallet/src/sync_manager/wallet_sync.rs index ee8bac85f..d09517dd0 100644 --- a/crates/sage-wallet/src/sync_manager/wallet_sync.rs +++ b/crates/sage-wallet/src/sync_manager/wallet_sync.rs @@ -8,7 +8,7 @@ use tokio::{ }; use tracing::{info, warn}; -use crate::{SyncCommand, Wallet, WalletError, WalletPeer}; +use crate::{SyncCommand, Wallet, WalletError, WalletInfo, WalletPeer}; use super::{PeerState, SyncEvent}; @@ -61,35 +61,39 @@ pub async fn sync_wallet( .await?; } - loop { - let mut tx = wallet.db.tx().await?; - let derivations = auto_insert_unhardened_derivations(&wallet, &mut tx).await?; - let next_index = tx.derivation_index(false).await?; - tx.commit().await?; - - if derivations.is_empty() { - break; - } - - info!("Inserted {} derivations", derivations.len()); - - sync_sender - .send(SyncEvent::DerivationIndex { next_index }) - .await - .ok(); - - for batch in derivations.chunks(1000) { - sync_puzzle_hashes( - &wallet, - &peer, - None, - wallet.genesis_challenge, - batch, - sync_sender.clone(), - command_sender.clone(), - ) - .await?; + if matches!(wallet.info, WalletInfo::Bls { .. }) { + loop { + let mut tx = wallet.db.tx().await?; + let derivations = auto_insert_unhardened_derivations(&wallet, &mut tx).await?; + let next_index = tx.derivation_index(false).await?; + tx.commit().await?; + + if derivations.is_empty() { + break; + } + + info!("Inserted {} derivations", derivations.len()); + + sync_sender + .send(SyncEvent::DerivationIndex { next_index }) + .await + .ok(); + + for batch in derivations.chunks(1000) { + sync_puzzle_hashes( + &wallet, + &peer, + None, + wallet.genesis_challenge, + batch, + sync_sender.clone(), + command_sender.clone(), + ) + .await?; + } } + } else { + info!("Wallet is a vault, skipping automatic key derivation"); } if delta_sync { @@ -260,11 +264,12 @@ pub async fn incremental_sync( let mut new_derivations = Vec::new(); - if derive_automatically { + let next_index = if derive_automatically && matches!(wallet.info, WalletInfo::Bls { .. }) { new_derivations = auto_insert_unhardened_derivations(wallet, &mut tx).await?; - } - - let next_index = tx.derivation_index(false).await?; + Some(tx.derivation_index(false).await?) + } else { + None + }; tx.commit().await?; @@ -272,7 +277,9 @@ pub async fn incremental_sync( sync_sender.send(SyncEvent::CoinsUpdated).await.ok(); } - if !new_derivations.is_empty() { + if !new_derivations.is_empty() + && let Some(next_index) = next_index + { sync_sender .send(SyncEvent::DerivationIndex { next_index }) .await diff --git a/crates/sage-wallet/src/test.rs b/crates/sage-wallet/src/test.rs index 56be1c7a5..1c368d163 100644 --- a/crates/sage-wallet/src/test.rs +++ b/crates/sage-wallet/src/test.rs @@ -29,7 +29,7 @@ use tracing::debug; use crate::{ PeerState, SyncCommand, SyncEvent, SyncManager, SyncOptions, Timeouts, Transaction, Wallet, - insert_transaction, + WalletInfo, insert_transaction, }; static INDEX: Mutex = Mutex::const_new(0); @@ -107,7 +107,7 @@ impl TestWallet { .derive_synthetic() .public_key(); let p2_puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); - tx.insert_custody_p2_puzzle( + tx.insert_derivation_p2_puzzle( p2_puzzle_hash, synthetic_key, Derivation { @@ -137,7 +137,7 @@ impl TestWallet { let wallet = Arc::new(Wallet::new( db, fingerprint, - intermediate_pk, + WalletInfo::Bls { intermediate_pk }, genesis_challenge, AggSigConstants::new(TESTNET11_CONSTANTS.agg_sig_me_additional_data), None, diff --git a/crates/sage-wallet/src/wallet.rs b/crates/sage-wallet/src/wallet.rs index b685e41be..c46a93341 100644 --- a/crates/sage-wallet/src/wallet.rs +++ b/crates/sage-wallet/src/wallet.rs @@ -33,11 +33,18 @@ pub use options::*; use crate::WalletError; +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WalletInfo { + Bls { intermediate_pk: PublicKey }, + Vault { launcher_id: Bytes32 }, + Watch { p2_puzzle_hashes: Vec }, +} + #[derive(Debug)] pub struct Wallet { pub db: Database, pub fingerprint: u32, - pub intermediate_pk: PublicKey, + pub info: WalletInfo, pub genesis_challenge: Bytes32, pub agg_sig_constants: AggSigConstants, pub change_p2_puzzle_hash: Option, @@ -47,7 +54,7 @@ impl Wallet { pub fn new( db: Database, fingerprint: u32, - intermediate_pk: PublicKey, + info: WalletInfo, genesis_challenge: Bytes32, agg_sig_constants: AggSigConstants, change_p2_puzzle_hash: Option, @@ -55,7 +62,7 @@ impl Wallet { Self { db, fingerprint, - intermediate_pk, + info, genesis_challenge, agg_sig_constants, change_p2_puzzle_hash, @@ -163,6 +170,7 @@ impl Wallet { spends.add(option); } + Some(CoinKind::Vault) => return Err(WalletError::CannotSelectVaultCoins), None => return Err(WalletError::MissingCoin(coin_id)), } } @@ -274,6 +282,9 @@ impl Wallet { spends.add(option); } + Some(AssetKind::Vault) => { + return Err(WalletError::CannotSelectVaultCoins); + } None => { if required_amount == 0 && !deltas.is_needed(&id) { continue; @@ -380,6 +391,8 @@ impl Wallet { spend.finish().into_iter().collect(), ), )?, + P2Puzzle::Vault(_) => todo!(), + P2Puzzle::External => return Err(DriverError::MissingKey.into()), } } SpendKind::Settlement(spend) => SettlementLayer diff --git a/crates/sage-wallet/src/wallet/derivations.rs b/crates/sage-wallet/src/wallet/derivations.rs index 2ae714edc..6c6933e39 100644 --- a/crates/sage-wallet/src/wallet/derivations.rs +++ b/crates/sage-wallet/src/wallet/derivations.rs @@ -5,11 +5,13 @@ use chia_wallet_sdk::{ bls::DerivableKey, puzzle_types::{DeriveSynthetic, standard::StandardArgs}, }, + driver::mips_puzzle_hash, prelude::*, + types::puzzles::SingletonMember, }; use sage_database::{DatabaseTx, Derivation}; -use crate::WalletError; +use crate::{WalletError, WalletInfo}; use super::Wallet; @@ -20,17 +22,18 @@ impl Wallet { tx: &mut DatabaseTx<'_>, range: Range, ) -> Result, WalletError> { + let WalletInfo::Bls { intermediate_pk } = &self.info else { + return Err(WalletError::DerivationsNotSupported); + }; + let mut puzzle_hashes = Vec::new(); for index in range { - let synthetic_key = self - .intermediate_pk - .derive_unhardened(index) - .derive_synthetic(); + let synthetic_key = intermediate_pk.derive_unhardened(index).derive_synthetic(); let p2_puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); - tx.insert_custody_p2_puzzle( + tx.insert_derivation_p2_puzzle( p2_puzzle_hash, synthetic_key, Derivation { @@ -72,7 +75,7 @@ impl Wallet { let mut p2_puzzle_hashes = Vec::new(); for index in range { - let p2_puzzle_hash = tx.custody_p2_puzzle_hash(index, hardened).await?; + let p2_puzzle_hash = tx.derivation_p2_puzzle_hash(index, hardened).await?; p2_puzzle_hashes.push(p2_puzzle_hash); } @@ -85,6 +88,17 @@ impl Wallet { if let Some(change_p2_puzzle_hash) = self.change_p2_puzzle_hash { return Ok(change_p2_puzzle_hash); } - Ok(self.p2_puzzle_hashes(1, false, true).await?[0]) + + match &self.info { + WalletInfo::Bls { .. } => Ok(self.p2_puzzle_hashes(1, false, true).await?[0]), + WalletInfo::Vault { launcher_id } => Ok(mips_puzzle_hash( + 0, + vec![], + SingletonMember::new(*launcher_id).curry_tree_hash(), + true, + ) + .into()), + WalletInfo::Watch { p2_puzzle_hashes } => Ok(p2_puzzle_hashes[0]), + } } } diff --git a/crates/sage-wallet/src/wallet_peer.rs b/crates/sage-wallet/src/wallet_peer.rs index ca975f57c..684908c66 100644 --- a/crates/sage-wallet/src/wallet_peer.rs +++ b/crates/sage-wallet/src/wallet_peer.rs @@ -299,7 +299,7 @@ impl WalletPeer { pub async fn block_timestamp(&self, height: u32) -> Result<(Bytes32, u64), WalletError> { let header_block = timeout( - Duration::from_secs(5), + Duration::from_secs(15), self.peer .request_infallible::(RequestBlockHeader::new(height)), ) diff --git a/crates/sage/src/endpoints.rs b/crates/sage/src/endpoints.rs index 3d6b86a89..24db19471 100644 --- a/crates/sage/src/endpoints.rs +++ b/crates/sage/src/endpoints.rs @@ -1,7 +1,7 @@ mod action_system; mod actions; mod data; -mod keys; +mod wallets; mod offers; mod settings; mod themes; diff --git a/crates/sage/src/endpoints/actions.rs b/crates/sage/src/endpoints/actions.rs index 3e333ade2..03a3ece60 100644 --- a/crates/sage/src/endpoints/actions.rs +++ b/crates/sage/src/endpoints/actions.rs @@ -13,7 +13,7 @@ use sage_api::{ }; use sage_assets::DexieCat; use sage_database::{Asset, AssetKind, Derivation}; -use sage_wallet::SyncCommand; +use sage_wallet::{SyncCommand, WalletError, WalletInfo}; use crate::{ Error, Result, Sage, parse_asset_id, parse_collection_id, parse_did_id, parse_nft_id, @@ -191,6 +191,10 @@ impl Sage { ) -> Result { let wallet = self.wallet()?; + if !matches!(wallet.info, WalletInfo::Bls { .. }) { + return Err(Error::Wallet(WalletError::DerivationsNotSupported)); + } + let hardened = req.hardened.is_none_or(|hardened| hardened); let unhardened = req.unhardened.is_none_or(|unhardened| unhardened); @@ -216,7 +220,7 @@ impl Sage { let p2_puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); - tx.insert_custody_p2_puzzle( + tx.insert_derivation_p2_puzzle( p2_puzzle_hash, synthetic_key, Derivation { diff --git a/crates/sage/src/endpoints/wallet_connect.rs b/crates/sage/src/endpoints/wallet_connect.rs index 24c2aedde..a07619a94 100644 --- a/crates/sage/src/endpoints/wallet_connect.rs +++ b/crates/sage/src/endpoints/wallet_connect.rs @@ -7,6 +7,7 @@ use chia_wallet_sdk::{ }, driver::P2DelegatedConditionsLayer, prelude::*, + types::puzzles::{DelegatedPuzzleFeederArgs, IndexWrapperArgs, SingletonMember}, }; use sage_api::wallet_connect::{ self, AssetCoinType, FilterUnlockedCoins, FilterUnlockedCoinsResponse, GetAssetCoins, @@ -108,6 +109,13 @@ impl Sage { P2Puzzle::Arbor(key) => { P2DelegatedConditionsLayer::new(key).construct_puzzle(&mut ctx)? } + P2Puzzle::Vault(p2_vault) => { + let member = ctx.curry(SingletonMember::new(p2_vault.launcher_id))?; + let delegated_puzzle_feeder = + ctx.curry(DelegatedPuzzleFeederArgs::new(member))?; + ctx.curry(IndexWrapperArgs::new(0, delegated_puzzle_feeder))? + } + P2Puzzle::External => NodePtr::NIL, // TODO: What should we do here? }; let (puzzle, proof) = match req.kind { diff --git a/crates/sage/src/endpoints/keys.rs b/crates/sage/src/endpoints/wallets.rs similarity index 67% rename from crates/sage/src/endpoints/keys.rs rename to crates/sage/src/endpoints/wallets.rs index 7f7908195..249f9c668 100644 --- a/crates/sage/src/endpoints/keys.rs +++ b/crates/sage/src/endpoints/wallets.rs @@ -10,15 +10,17 @@ use chia_wallet_sdk::{ puzzle_types::{DeriveSynthetic, standard::StandardArgs}, }, prelude::*, + utils::Bech32Error, }; use rand::{Rng, SeedableRng}; use rand_chacha::ChaCha20Rng; use sage_api::{ - DeleteDatabase, DeleteDatabaseResponse, DeleteKey, DeleteKeyResponse, GenerateMnemonic, - GenerateMnemonicResponse, GetKey, GetKeyResponse, GetKeys, GetKeysResponse, GetSecretKey, - GetSecretKeyResponse, ImportKey, ImportKeyResponse, KeyInfo, KeyKind, Login, LoginResponse, - Logout, LogoutResponse, RenameKey, RenameKeyResponse, Resync, ResyncResponse, SecretKeyInfo, - SetWalletEmoji, SetWalletEmojiResponse, + DeleteDatabase, DeleteDatabaseResponse, DeleteWallet, DeleteWalletResponse, GenerateMnemonic, + GenerateMnemonicResponse, GetWallet, GetWalletResponse, GetWalletSecrets, + GetWalletSecretsResponse, GetWallets, GetWalletsResponse, ImportAddresses, + ImportAddressesResponse, ImportWallet, ImportWalletResponse, Login, LoginResponse, Logout, + LogoutResponse, RenameWallet, RenameWalletResponse, Resync, ResyncResponse, SecretKeyInfo, + SetWalletEmoji, SetWalletEmojiResponse, WalletKind, WalletRecord, }; use sage_config::Wallet; use sage_database::{Database, Derivation}; @@ -84,7 +86,9 @@ impl Sage { } if req.delete_addresses { - query!("DELETE FROM p2_puzzles").execute(&pool).await?; + query!("DELETE FROM p2_puzzles WHERE kind IN (0, 1, 2)") + .execute(&pool) + .await?; } if req.delete_blocks { @@ -121,7 +125,7 @@ impl Sage { }) } - pub async fn import_key(&mut self, req: ImportKey) -> Result { + pub async fn import_wallet(&mut self, req: ImportWallet) -> Result { let mut key_hex = req.key.as_str(); if key_hex.starts_with("0x") || key_hex.starts_with("0X") { @@ -207,7 +211,7 @@ impl Sage { .derive_unhardened(index) .derive_synthetic(); let p2_puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); - tx.insert_custody_p2_puzzle( + tx.insert_derivation_p2_puzzle( p2_puzzle_hash, synthetic_key, Derivation { @@ -230,7 +234,7 @@ impl Sage { .derive_synthetic() .public_key(); let p2_puzzle_hash = StandardArgs::curry_tree_hash(synthetic_key).into(); - tx.insert_custody_p2_puzzle( + tx.insert_derivation_p2_puzzle( p2_puzzle_hash, synthetic_key, Derivation { @@ -250,7 +254,54 @@ impl Sage { self.switch_wallet().await?; } - Ok(ImportKeyResponse { fingerprint }) + Ok(ImportWalletResponse { fingerprint }) + } + + pub async fn import_addresses( + &mut self, + req: ImportAddresses, + ) -> Result { + let p2_puzzle_hashes = req + .addresses + .into_iter() + .map(|address| self.parse_address(address)) + .collect::>>()?; + + if p2_puzzle_hashes.is_empty() { + return Err(Error::EmptyAddressesList); + } + + let fingerprint = self + .keychain + .add_watch_p2_puzzle_hashes(&p2_puzzle_hashes)?; + + self.wallet_config.wallets.push(Wallet { + name: req.name, + fingerprint, + emoji: req.emoji, + ..Default::default() + }); + self.config.global.fingerprint = Some(fingerprint); + + self.save_keychain()?; + self.save_config()?; + + let pool = self.connect_to_database(fingerprint).await?; + let db = Database::new(pool); + + let mut tx = db.tx().await?; + + for p2_puzzle_hash in p2_puzzle_hashes { + tx.insert_external_p2_puzzle(p2_puzzle_hash).await?; + } + + tx.commit().await?; + + if req.login { + self.switch_wallet().await?; + } + + Ok(ImportAddressesResponse { fingerprint }) } pub fn delete_database(&mut self, req: DeleteDatabase) -> Result { @@ -267,7 +318,7 @@ impl Sage { Ok(DeleteDatabaseResponse {}) } - pub fn delete_key(&mut self, req: DeleteKey) -> Result { + pub fn delete_wallet(&mut self, req: DeleteWallet) -> Result { self.keychain.remove(req.fingerprint); self.wallet_config @@ -286,10 +337,10 @@ impl Sage { fs::remove_dir_all(path)?; } - Ok(DeleteKeyResponse {}) + Ok(DeleteWalletResponse {}) } - pub fn rename_key(&mut self, req: RenameKey) -> Result { + pub fn rename_wallet(&mut self, req: RenameWallet) -> Result { let Some(wallet) = self .wallet_config .wallets @@ -302,7 +353,7 @@ impl Sage { wallet.name = req.name; self.save_config()?; - Ok(RenameKeyResponse {}) + Ok(RenameWalletResponse {}) } pub fn set_wallet_emoji(&mut self, req: SetWalletEmoji) -> Result { @@ -321,41 +372,33 @@ impl Sage { Ok(SetWalletEmojiResponse {}) } - pub fn get_key(&self, req: GetKey) -> Result { + pub fn get_wallet(&self, req: GetWallet) -> Result { let fingerprint = req.fingerprint.or(self.config.global.fingerprint); let Some(fingerprint) = fingerprint else { - return Ok(GetKeyResponse { key: None }); + return Ok(GetWalletResponse { wallet: None }); }; - let wallet_config = self.wallet_config().cloned().unwrap_or_default(); - - let network_id = wallet_config.network.unwrap_or_else(|| self.network_id()); - - let Some(master_pk) = self.keychain.extract_public_key(fingerprint)? else { - return Ok(GetKeyResponse { key: None }); - }; + let wallet_config = self + .wallet_config + .wallets + .iter() + .find(|wallet| wallet.fingerprint == fingerprint) + .cloned() + .ok_or(Error::UnknownFingerprint)?; - Ok(GetKeyResponse { - key: Some(KeyInfo { - name: wallet_config.name, - fingerprint, - public_key: hex::encode(master_pk.to_bytes()), - kind: KeyKind::Bls, - has_secrets: self.keychain.has_secret_key(fingerprint), - network_id, - emoji: wallet_config.emoji, - }), + Ok(GetWalletResponse { + wallet: self.wallet_record(&wallet_config)?, }) } - pub fn get_secret_key(&self, req: GetSecretKey) -> Result { + pub fn get_wallet_secrets(&self, req: GetWalletSecrets) -> Result { let (mnemonic, Some(secret_key)) = self.keychain.extract_secrets(req.fingerprint, b"")? else { - return Ok(GetSecretKeyResponse { secrets: None }); + return Ok(GetWalletSecretsResponse { secrets: None }); }; - Ok(GetSecretKeyResponse { + Ok(GetWalletSecretsResponse { secrets: Some(SecretKeyInfo { mnemonic: mnemonic.map(|m| m.to_string()), secret_key: hex::encode(secret_key.to_bytes()), @@ -363,25 +406,66 @@ impl Sage { }) } - pub fn get_keys(&self, _req: GetKeys) -> Result { - let mut keys = Vec::new(); + pub fn get_wallets(&self, _req: GetWallets) -> Result { + let mut wallets = Vec::new(); for wallet in &self.wallet_config.wallets { - let Some(master_pk) = self.keychain.extract_public_key(wallet.fingerprint)? else { - continue; - }; + if let Some(record) = self.wallet_record(wallet)? { + wallets.push(record); + } + } - keys.push(KeyInfo { - name: wallet.name.clone(), - fingerprint: wallet.fingerprint, - public_key: hex::encode(master_pk.to_bytes()), - kind: KeyKind::Bls, - has_secrets: self.keychain.has_secret_key(wallet.fingerprint), - network_id: wallet.network.clone().unwrap_or_else(|| self.network_id()), - emoji: wallet.emoji.clone(), - }); + Ok(GetWalletsResponse { wallets }) + } + + fn wallet_record(&self, wallet: &Wallet) -> Result> { + let name = wallet.name.clone(); + let fingerprint = wallet.fingerprint; + let network_id = wallet.network.clone().unwrap_or_else(|| self.network_id()); + let emoji = wallet.emoji.clone(); + + if let Some(master_pk) = self.keychain.extract_public_key(fingerprint)? { + return Ok(Some(WalletRecord { + name, + fingerprint, + kind: WalletKind::Bls { + public_key: hex::encode(master_pk.to_bytes()), + has_secrets: self.keychain.has_secret_key(fingerprint), + }, + network_id, + emoji, + })); + } + + if let Some(launcher_id) = self.keychain.extract_vault_id(fingerprint) { + return Ok(Some(WalletRecord { + name, + fingerprint, + kind: WalletKind::Vault { + launcher_id: launcher_id.to_string(), + }, + network_id, + emoji, + })); + } + + if let Some(p2_puzzle_hashes) = self.keychain.extract_watch_p2_puzzle_hashes(fingerprint) { + let addresses = p2_puzzle_hashes + .iter() + .map(|p2_puzzle_hash| { + Address::new(*p2_puzzle_hash, self.network().prefix()).encode() + }) + .collect::, Bech32Error>>()?; + + return Ok(Some(WalletRecord { + name, + fingerprint, + kind: WalletKind::Watch { addresses }, + network_id, + emoji, + })); } - Ok(GetKeysResponse { keys }) + Ok(None) } } diff --git a/crates/sage/src/error.rs b/crates/sage/src/error.rs index 043f7e0eb..0febc3e3c 100644 --- a/crates/sage/src/error.rs +++ b/crates/sage/src/error.rs @@ -227,6 +227,9 @@ pub enum Error { #[error("Missing asset id")] MissingAssetId, + #[error("An empty list of addresses was provided")] + EmptyAddressesList, + #[error("Database version too old")] DatabaseVersionTooOld, @@ -310,7 +313,8 @@ impl Error { | Self::MissingAssetId | Self::InvalidGroup | Self::InvalidThemeJson - | Self::MissingThemeData => ErrorKind::Api, + | Self::MissingThemeData + | Self::EmptyAddressesList => ErrorKind::Api, } } } diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index eca15d84a..53525c120 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -19,7 +19,9 @@ use sage_config::{ }; use sage_database::Database; use sage_keychain::Keychain; -use sage_wallet::{PeerState, SyncCommand, SyncEvent, SyncManager, SyncOptions, Timeouts, Wallet}; +use sage_wallet::{ + PeerState, SyncCommand, SyncEvent, SyncManager, SyncOptions, Timeouts, Wallet, WalletInfo, +}; use sqlx::{ ConnectOptions, SqlitePool, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}, @@ -290,12 +292,20 @@ impl Sage { return Ok(()); }; - let Some(master_pk) = self.keychain.extract_public_key(fingerprint)? else { + let info = if let Some(master_pk) = self.keychain.extract_public_key(fingerprint)? { + WalletInfo::Bls { + intermediate_pk: master_to_wallet_unhardened_intermediate(&master_pk), + } + } else if let Some(launcher_id) = self.keychain.extract_vault_id(fingerprint) { + WalletInfo::Vault { launcher_id } + } else if let Some(p2_puzzle_hashes) = + self.keychain.extract_watch_p2_puzzle_hashes(fingerprint) + { + WalletInfo::Watch { p2_puzzle_hashes } + } else { return Err(Error::UnknownFingerprint); }; - let intermediate_pk = master_to_wallet_unhardened_intermediate(&master_pk); - let pool = self.connect_to_database(fingerprint).await?; let db = Database::new(pool); @@ -307,7 +317,7 @@ impl Sage { let wallet = Arc::new(Wallet::new( db.clone(), fingerprint, - intermediate_pk, + info, self.network().genesis_challenge, AggSigConstants::new(self.network().agg_sig_me()), wallet_config diff --git a/crates/sage/src/utils/conversions.rs b/crates/sage/src/utils/conversions.rs index a5f4ccd65..1d720c39e 100644 --- a/crates/sage/src/utils/conversions.rs +++ b/crates/sage/src/utils/conversions.rs @@ -55,6 +55,7 @@ pub fn encode_asset_id(hash: Bytes32, kind: AssetKind) -> Result> AssetKind::Nft => Address::new(hash, "nft".to_string()).encode()?, AssetKind::Did => Address::new(hash, "did:chia:".to_string()).encode()?, AssetKind::Option => Address::new(hash, "option".to_string()).encode()?, + AssetKind::Vault => Address::new(hash, "vault".to_string()).encode()?, }) }) } @@ -65,5 +66,6 @@ pub fn encode_asset_kind(kind: AssetKind) -> sage_api::AssetKind { AssetKind::Nft => sage_api::AssetKind::Nft, AssetKind::Did => sage_api::AssetKind::Did, AssetKind::Option => sage_api::AssetKind::Option, + AssetKind::Vault => sage_api::AssetKind::Vault, } } diff --git a/migrations/0006_vaults.sql b/migrations/0006_vaults.sql new file mode 100644 index 000000000..ed20b4f2d --- /dev/null +++ b/migrations/0006_vaults.sql @@ -0,0 +1,46 @@ +/* + * Vaults are an asset with kind = 4 + */ +CREATE TABLE vaults ( + id INTEGER NOT NULL PRIMARY KEY, + asset_id INTEGER NOT NULL UNIQUE, + FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE +); + +/* + * P2 vault is a p2 puzzle with kind = 4 + */ + CREATE TABLE p2_vaults ( + id INTEGER NOT NULL PRIMARY KEY, + p2_puzzle_id INTEGER NOT NULL UNIQUE, + vault_asset_id INTEGER NOT NULL, + FOREIGN KEY (p2_puzzle_id) REFERENCES p2_puzzles(id) ON DELETE CASCADE, + FOREIGN KEY (vault_asset_id) REFERENCES assets(id) ON DELETE CASCADE +); + +/* + * We're starting with single signer vault support with recovery fro now + */ +CREATE TABLE vault_configs ( + id INTEGER NOT NULL PRIMARY KEY, + custody_hash BLOB NOT NULL, + custody_key_id INTEGER NOT NULL, + recovery_key_id INTEGER NOT NULL, + recovery_timelock INTEGER NOT NULL, + FOREIGN KEY (custody_key_id) REFERENCES vault_keys(id), + FOREIGN KEY (recovery_key_id) REFERENCES vault_keys(id) +); + +/* + * A single key that can be used to sign for a vault. + * The kind represents the type of key: + * BLS = 0 + * Secp256r1 = 1 + */ +CREATE TABLE vault_keys ( + id INTEGER NOT NULL PRIMARY KEY, + kind INTEGER NOT NULL, + public_key BLOB NOT NULL, + fast_forwardable BOOLEAN NOT NULL, + CONSTRAINT unique_key UNIQUE (kind, public_key) +); diff --git a/package.json b/package.json index 7d0f9fc84..fd2abbb0e 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "cmdk": "^1.1.1", "emoji-mart": "^5.6.0", "framer-motion": "^12.33.0", - "lucide-react": "^0.445.0", + "lucide-react": "^0.575.0", "pretty-bytes": "^7.1.0", "qr-code-styling": "^1.9.2", "react": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9aef00af..0dc499c4a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,8 +144,8 @@ importers: specifier: ^12.33.0 version: 12.33.0(@emotion/is-prop-valid@1.3.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) lucide-react: - specifier: ^0.445.0 - version: 0.445.0(react@18.3.1) + specifier: ^0.575.0 + version: 0.575.0(react@18.3.1) pretty-bytes: specifier: ^7.1.0 version: 7.1.0 @@ -2902,10 +2902,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@0.445.0: - resolution: {integrity: sha512-YrLf3aAHvmd4dZ8ot+mMdNFrFpJD7YRwQ2pUcBhgqbmxtrMP4xDzIorcj+8y+6kpuXBF4JB0NOCTUWIYetJjgA==} + lucide-react@0.575.0: + resolution: {integrity: sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==} peerDependencies: - react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} @@ -6733,7 +6733,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@0.445.0(react@18.3.1): + lucide-react@0.575.0(react@18.3.1): dependencies: react: 18.3.1 diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fa140bf6e..8b06e50ab 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -161,7 +161,7 @@ pub async fn switch_wallet(state: State<'_, AppState>) -> Result<()> { #[command] #[specta] -pub async fn move_key(state: State<'_, AppState>, fingerprint: u32, index: u32) -> Result<()> { +pub async fn move_wallet(state: State<'_, AppState>, fingerprint: u32, index: u32) -> Result<()> { let mut state = state.lock().await; let old_index = state diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..7c88d45fa 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,14 +28,15 @@ pub fn run() { commands::logout, commands::resync, commands::generate_mnemonic, - commands::import_key, - commands::delete_key, + commands::import_wallet, + commands::import_addresses, + commands::delete_wallet, commands::delete_database, - commands::rename_key, - commands::get_keys, + commands::rename_wallet, + commands::get_wallets, commands::set_wallet_emoji, - commands::get_key, - commands::get_secret_key, + commands::get_wallet, + commands::get_wallet_secrets, commands::send_xch, commands::bulk_send_xch, commands::combine, @@ -137,7 +138,7 @@ pub fn run() { commands::get_rpc_run_on_startup, commands::set_rpc_run_on_startup, commands::switch_wallet, - commands::move_key, + commands::move_wallet, commands::download_cni_offercode, commands::get_logs, commands::is_asset_owned, diff --git a/src/App.tsx b/src/App.tsx index 6fefeeff2..aa7b1d79c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,7 +35,9 @@ import CreateWallet from './pages/CreateWallet'; import { DidList } from './pages/DidList'; import ImportWallet from './pages/ImportWallet'; import IssueToken from './pages/IssueToken'; +import Keys from './pages/Keys'; import Login from './pages/Login'; +import MintVault from './pages/MintVault'; import { MakeOffer } from './pages/MakeOffer'; import MintNft from './pages/MintNft'; import { MintOption } from './pages/MintOption'; @@ -46,6 +48,7 @@ import { Offers } from './pages/Offers'; import Option from './pages/Option'; import { OptionList } from './pages/OptionList'; import PeerList from './pages/PeerList'; +import RecoverVault from './pages/RecoverVault'; import QRScanner from './pages/QrScanner'; import { SavedOffer } from './pages/SavedOffer'; import Send from './pages/Send'; @@ -57,6 +60,7 @@ import { TokenList } from './pages/TokenList'; import Transaction from './pages/Transaction'; import { Transactions } from './pages/Transactions'; import Wallet from './pages/Wallet'; +import WatchAddress from './pages/WatchAddress'; // Theme-aware toast container component function ThemeAwareToastContainer() { @@ -93,6 +97,10 @@ const router = createHashRouter( } /> } /> } /> + } /> + } /> + } /> + } /> }> } /> } /> diff --git a/src/bindings.ts b/src/bindings.ts index 67f25c5ed..63ec44fe6 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -20,29 +20,32 @@ async resync(req: Resync) : Promise { async generateMnemonic(req: GenerateMnemonic) : Promise { return await TAURI_INVOKE("generate_mnemonic", { req }); }, -async importKey(req: ImportKey) : Promise { - return await TAURI_INVOKE("import_key", { req }); +async importWallet(req: ImportWallet) : Promise { + return await TAURI_INVOKE("import_wallet", { req }); }, -async deleteKey(req: DeleteKey) : Promise { - return await TAURI_INVOKE("delete_key", { req }); +async importAddresses(req: ImportAddresses) : Promise { + return await TAURI_INVOKE("import_addresses", { req }); +}, +async deleteWallet(req: DeleteWallet) : Promise { + return await TAURI_INVOKE("delete_wallet", { req }); }, async deleteDatabase(req: DeleteDatabase) : Promise { return await TAURI_INVOKE("delete_database", { req }); }, -async renameKey(req: RenameKey) : Promise { - return await TAURI_INVOKE("rename_key", { req }); +async renameWallet(req: RenameWallet) : Promise { + return await TAURI_INVOKE("rename_wallet", { req }); }, -async getKeys(req: GetKeys) : Promise { - return await TAURI_INVOKE("get_keys", { req }); +async getWallets(req: GetWallets) : Promise { + return await TAURI_INVOKE("get_wallets", { req }); }, async setWalletEmoji(req: SetWalletEmoji) : Promise { return await TAURI_INVOKE("set_wallet_emoji", { req }); }, -async getKey(req: GetKey) : Promise { - return await TAURI_INVOKE("get_key", { req }); +async getWallet(req: GetWallet) : Promise { + return await TAURI_INVOKE("get_wallet", { req }); }, -async getSecretKey(req: GetSecretKey) : Promise { - return await TAURI_INVOKE("get_secret_key", { req }); +async getWalletSecrets(req: GetWalletSecrets) : Promise { + return await TAURI_INVOKE("get_wallet_secrets", { req }); }, async sendXch(req: SendXch) : Promise { return await TAURI_INVOKE("send_xch", { req }); @@ -347,8 +350,8 @@ async setRpcRunOnStartup(runOnStartup: boolean) : Promise { async switchWallet() : Promise { return await TAURI_INVOKE("switch_wallet"); }, -async moveKey(fingerprint: number, index: number) : Promise { - return await TAURI_INVOKE("move_key", { fingerprint, index }); +async moveWallet(fingerprint: number, index: number) : Promise { + return await TAURI_INVOKE("move_wallet", { fingerprint, index }); }, async downloadCniOffercode(code: string) : Promise { return await TAURI_INVOKE("download_cni_offercode", { code }); @@ -416,7 +419,7 @@ export type Asset = { asset_id: string | null; name: string | null; ticker: stri * Type of asset coin */ export type AssetCoinType = "cat" | "did" | "nft" -export type AssetKind = "token" | "nft" | "did" | "option" +export type AssetKind = "token" | "nft" | "did" | "option" | "vault" /** * Assign NFTs to a DID */ @@ -767,18 +770,6 @@ network: string } * Response for database deletion */ export type DeleteDatabaseResponse = Record -/** - * Delete a wallet key - */ -export type DeleteKey = { -/** - * Wallet fingerprint to delete - */ -fingerprint: number } -/** - * Response for key deletion - */ -export type DeleteKeyResponse = Record /** * Delete an offer */ @@ -800,6 +791,18 @@ export type DeleteUserTheme = { */ nft_id: string } export type DeleteUserThemeResponse = Record +/** + * Delete a wallet + */ +export type DeleteWallet = { +/** + * Wallet fingerprint to delete + */ +fingerprint: number } +/** + * Response for wallet deletion + */ +export type DeleteWalletResponse = Record export type DerivationRecord = { index: number; public_key: string; address: string } export type DidRecord = { launcher_id: string; name: string | null; visible: boolean; coin_id: string; address: string; amount: Amount; recovery_hash: string | null; created_height: number | null } export type EmptyResponse = Record @@ -1066,34 +1069,6 @@ export type GetDidsResponse = { * List of DIDs */ dids: DidRecord[] } -/** - * Get a specific wallet key - */ -export type GetKey = { -/** - * Wallet fingerprint (uses currently logged in if null) - */ -fingerprint?: number | null } -/** - * Response with key information - */ -export type GetKeyResponse = { -/** - * Key information if found - */ -key: KeyInfo | null } -/** - * List all wallet keys - */ -export type GetKeys = Record -/** - * Response with all wallet keys - */ -export type GetKeysResponse = { -/** - * List of wallet keys - */ -keys: KeyInfo[] } /** * Get minter DIDs with pagination */ @@ -1418,22 +1393,6 @@ export type GetPendingTransactionsResponse = { * List of pending transactions */ transactions: PendingTransactionRecord[] } -/** - * Get wallet secret key - */ -export type GetSecretKey = { -/** - * Wallet fingerprint - */ -fingerprint: number } -/** - * Response with secret key information - */ -export type GetSecretKeyResponse = { -/** - * Secret key information if authorized - */ -secrets: SecretKeyInfo | null } /** * Get the count of spendable coins */ @@ -1600,6 +1559,50 @@ export type GetVersionResponse = { * Semantic version string */ version: string } +/** + * Get a specific wallet + */ +export type GetWallet = { +/** + * Wallet fingerprint (uses currently logged in if null) + */ +fingerprint?: number | null } +/** + * Response with wallet information + */ +export type GetWalletResponse = { +/** + * Wallet record if found + */ +wallet: WalletRecord | null } +/** + * Get wallet secrets + */ +export type GetWalletSecrets = { +/** + * Wallet fingerprint + */ +fingerprint: number } +/** + * Response with wallet secrets + */ +export type GetWalletSecretsResponse = { +/** + * Secret key information if authorized + */ +secrets: SecretKeyInfo | null } +/** + * List all wallets + */ +export type GetWallets = Record +/** + * Response with all wallets + */ +export type GetWalletsResponse = { +/** + * List of wallet records + */ +wallets: WalletRecord[] } export type Id = /** * The XCH asset @@ -1614,9 +1617,53 @@ export type Id = */ { type: "new"; index: number } /** - * Import a wallet key + * Import a read-only wallet using a list of addresses. Optionally logs in. */ -export type ImportKey = { +export type ImportAddresses = { +/** + * Display name for the wallet + */ +name: string; +/** + * List of addresses + */ +addresses: string[]; +/** + * Whether to automatically login after import + */ +login?: boolean; +/** + * Optional emoji identifier + */ +emoji?: string | null } +/** + * Response with imported wallet fingerprint + */ +export type ImportAddressesResponse = { +/** + * Fingerprint of the imported wallet + */ +fingerprint: number } +/** + * Import an offer + */ +export type ImportOffer = { +/** + * Offer string to import + */ +offer: string } +/** + * Response with imported offer ID + */ +export type ImportOfferResponse = { +/** + * ID of the imported offer + */ +offer_id: string } +/** + * Import a wallet + */ +export type ImportWallet = { /** * Display name for the wallet */ @@ -1650,29 +1697,13 @@ login?: boolean; */ emoji?: string | null } /** - * Response with imported key fingerprint + * Response with imported wallet fingerprint */ -export type ImportKeyResponse = { +export type ImportWalletResponse = { /** - * Fingerprint of the imported key + * Fingerprint of the imported wallet */ fingerprint: number } -/** - * Import an offer - */ -export type ImportOffer = { -/** - * Offer string to import - */ -offer: string } -/** - * Response with imported offer ID - */ -export type ImportOfferResponse = { -/** - * ID of the imported offer - */ -offer_id: string } /** * Increase the derivation index to generate more addresses */ @@ -1734,8 +1765,6 @@ fee: Amount; * Whether to automatically submit the transaction */ auto_submit?: boolean } -export type KeyInfo = { name: string; fingerprint: number; public_key: string; kind: KeyKind; has_secrets: boolean; network_id: string; emoji: string | null } -export type KeyKind = "bls" /** * Lineage proof for CAT coins */ @@ -2088,9 +2117,9 @@ ip: string; */ ban: boolean } /** - * Rename a wallet key + * Rename a wallet */ -export type RenameKey = { +export type RenameWallet = { /** * Wallet fingerprint */ @@ -2100,9 +2129,9 @@ fingerprint: number; */ name: string } /** - * Response for key rename + * Response for wallet rename */ -export type RenameKeyResponse = Record +export type RenameWalletResponse = Record /** * Resynchronize wallet data with the blockchain */ @@ -2746,6 +2775,7 @@ offer: OfferSummary; status: OfferRecordStatus } export type Wallet = { name: string; fingerprint: number; network?: string | null; delta_sync: boolean | null; emoji?: string | null; change_address?: string | null } export type WalletDefaults = { delta_sync: boolean } +export type WalletRecord = ({ type: "bls"; public_key: string; has_secrets: boolean } | { type: "vault"; launcher_id: string } | { type: "watch"; addresses: string[] }) & { name: string; fingerprint: number; network_id: string; emoji: string | null } /** tauri-specta globals **/ diff --git a/src/components/DurationInput.tsx b/src/components/DurationInput.tsx new file mode 100644 index 000000000..4034f8309 --- /dev/null +++ b/src/components/DurationInput.tsx @@ -0,0 +1,70 @@ +import { IntegerInput } from '@/components/ui/masked-input'; +import { Trans } from '@lingui/react/macro'; + +export interface Duration { + days: string; + hours: string; + minutes: string; +} + +interface DurationInputProps { + value: Duration; + onChange: (value: Duration) => void; +} + +export function DurationInput({ value, onChange }: DurationInputProps) { + return ( +
+
+ { + onChange({ ...value, days: values.value }); + }} + /> +
+ + Days + +
+
+ +
+ { + onChange({ ...value, hours: values.value }); + }} + /> +
+ + Hours + +
+
+ +
+ { + onChange({ ...value, minutes: values.value }); + }} + /> +
+ + Minutes + +
+
+
+ ); +} diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 9298f620f..dd3ea1d3c 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { KeyInfo } from '@/bindings'; +import { WalletRecord } from '@/bindings'; import { Tooltip, TooltipContent, @@ -52,7 +52,7 @@ const SIDEBAR_COLLAPSED_STORAGE_KEY = 'sage-wallet-sidebar-collapsed'; type LayoutProps = PropsWithChildren & { transparentBackground?: boolean; - wallet?: KeyInfo; + wallet?: WalletRecord; }; export function FullLayout(props: LayoutProps) { diff --git a/src/components/MnemonicDisplay.tsx b/src/components/MnemonicDisplay.tsx new file mode 100644 index 000000000..6828c8ef3 --- /dev/null +++ b/src/components/MnemonicDisplay.tsx @@ -0,0 +1,67 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { t } from '@lingui/core/macro'; +import { Trans } from '@lingui/react/macro'; +import { writeText } from '@tauri-apps/plugin-clipboard-manager'; +import { CopyIcon, RefreshCwIcon } from 'lucide-react'; +import { useCallback } from 'react'; +import { toast } from 'react-toastify'; + +interface MnemonicDisplayProps { + mnemonic: string | undefined; + label?: React.ReactNode; + onRegenerate?: () => void; +} + +export function MnemonicDisplay({ + mnemonic, + label, + onRegenerate, +}: MnemonicDisplayProps) { + const copyMnemonic = useCallback(() => { + if (!mnemonic) return; + writeText(mnemonic); + toast.success(t`Mnemonic copied to clipboard`); + }, [mnemonic]); + + return ( +
+
+ +
+ {onRegenerate && ( + + )} + +
+
+
+ {mnemonic?.split(' ').map((word, i) => ( + + {word} + + ))} +
+
+ ); +} diff --git a/src/components/WalletCard.tsx b/src/components/WalletCard.tsx index 5cdcb7925..49484f36e 100644 --- a/src/components/WalletCard.tsx +++ b/src/components/WalletCard.tsx @@ -38,6 +38,7 @@ import { RefreshCcw, SnowflakeIcon, TrashIcon, + VaultIcon, WalletIcon, } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -45,23 +46,23 @@ import { useNavigate } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Spoiler } from 'spoiled'; import { useTheme } from 'theme-o-rama'; -import { commands, KeyInfo, SecretKeyInfo } from '../bindings'; +import { commands, SecretKeyInfo, WalletRecord } from '../bindings'; import { CustomError } from '../contexts/ErrorContext'; import { useWallet } from '../contexts/WalletContext'; import { loginAndUpdateState, logoutAndUpdateState } from '../state'; interface WalletCardProps { draggable?: boolean; - info: KeyInfo; - keys: KeyInfo[]; - setKeys: (keys: KeyInfo[]) => void; + info: WalletRecord; + wallets: WalletRecord[]; + setWallets: (wallets: WalletRecord[]) => void; } export function WalletCard({ draggable, info, - keys, - setKeys, + wallets, + setWallets, }: WalletCardProps) { const navigate = useNavigate(); const { addError } = useErrors(); @@ -81,9 +82,9 @@ export function WalletCard({ const deleteSelf = async () => { if (await promptIfEnabled()) { await commands - .deleteKey({ fingerprint: info.fingerprint }) + .deleteWallet({ fingerprint: info.fingerprint }) .then(() => - setKeys(keys.filter((key) => key.fingerprint !== info.fingerprint)), + setWallets(wallets.filter((wallet) => wallet.fingerprint !== info.fingerprint)), ) .catch(addError); } @@ -95,13 +96,13 @@ export function WalletCard({ if (!newName) return; commands - .renameKey({ fingerprint: info.fingerprint, name: newName }) + .renameWallet({ fingerprint: info.fingerprint, name: newName }) .then(() => - setKeys( - keys.map((key) => - key.fingerprint === info.fingerprint - ? { ...key, name: newName } - : key, + setWallets( + wallets.map((wallet) => + wallet.fingerprint === info.fingerprint + ? { ...wallet, name: newName } + : wallet, ), ), ) @@ -115,9 +116,9 @@ export function WalletCard({ commands .setWalletEmoji({ fingerprint: info.fingerprint, emoji }) .then(() => - setKeys( - keys.map((key) => - key.fingerprint === info.fingerprint ? { ...key, emoji } : key, + setWallets( + wallets.map((wallet) => + wallet.fingerprint === info.fingerprint ? { ...wallet, emoji } : wallet, ), ), ) @@ -150,8 +151,8 @@ export function WalletCard({ try { await loginAndUpdateState(info.fingerprint); - const data = await commands.getKey({}); - setWallet(data.key); + const data = await commands.getWallet({}); + setWallet(data.wallet); navigate('/wallet'); } catch (error: unknown) { if ( @@ -175,7 +176,7 @@ export function WalletCard({ } commands - .getSecretKey({ fingerprint: info.fingerprint }) + .getWalletSecrets({ fingerprint: info.fingerprint }) .then((data) => data.secrets !== null && setSecrets(data.secrets)) .catch(addError); })(); @@ -341,21 +342,39 @@ export function WalletCard({
{info.fingerprint} - {info.has_secrets ? ( -
- - - Hot - -
- ) : ( -
- - - Cold - -
- )} +
+ {info.type === 'bls' ? ( + info.has_secrets ? ( + <> + + + Hot + + + ) : ( + <> + + + Cold + + + ) + ) : info.type === 'vault' ? ( + <> + + + Vault + + + ) : ( + <> + + + Watch + + + )} +
@@ -474,7 +493,7 @@ export function WalletCard({ Public Key

- {info.public_key} + {info.type === 'bls' && info.public_key}

{secrets && ( diff --git a/src/components/WalletSwitcher.tsx b/src/components/WalletSwitcher.tsx index da1add4b4..25578ed11 100644 --- a/src/components/WalletSwitcher.tsx +++ b/src/components/WalletSwitcher.tsx @@ -1,4 +1,4 @@ -import { commands, KeyInfo } from '@/bindings'; +import { commands, WalletRecord } from '@/bindings'; import { MigrationDialog } from '@/components/dialogs/MigrationDialog'; import { DropdownMenu, @@ -15,6 +15,8 @@ import { import { CustomError } from '@/contexts/ErrorContext'; import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; +import iconDark from '@/icon-dark.png'; +import iconLight from '@/icon-light.png'; import { clearState, loginAndUpdateState, logoutAndUpdateState } from '@/state'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; @@ -23,12 +25,10 @@ import { ChevronDown, WalletIcon } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTheme } from 'theme-o-rama'; -import iconDark from '@/icon-dark.png'; -import iconLight from '@/icon-light.png'; interface WalletSwitcherProps { isCollapsed?: boolean; - wallet?: KeyInfo; + wallet?: WalletRecord; } export function WalletSwitcher({ isCollapsed, wallet }: WalletSwitcherProps) { @@ -36,20 +36,22 @@ export function WalletSwitcher({ isCollapsed, wallet }: WalletSwitcherProps) { const { currentTheme } = useTheme(); const { setWallet, setIsSwitching, isSwitching } = useWallet(); const { addError } = useErrors(); - const [wallets, setWallets] = useState([]); + const [wallets, setWallets] = useState([]); const [loading, setLoading] = useState(true); const [isOpen, setIsOpen] = useState(false); const [isHovering, setIsHovering] = useState(false); const [isMigrationDialogOpen, setIsMigrationDialogOpen] = useState(false); - const [migrationWallet, setMigrationWallet] = useState(null); + const [migrationWallet, setMigrationWallet] = useState( + null, + ); const timeoutRef = useRef(null); const isMobile = platform() === 'ios' || platform() === 'android'; useEffect(() => { const fetchWallets = async () => { try { - const data = await commands.getKeys({}); - setWallets([...data.keys].sort((a, b) => a.name.localeCompare(b.name))); + const data = await commands.getWallets({}); + setWallets([...data.wallets].sort((a, b) => a.name.localeCompare(b.name))); } catch (error) { addError(error as CustomError); } finally { @@ -81,9 +83,9 @@ export function WalletSwitcher({ isCollapsed, wallet }: WalletSwitcherProps) { await new Promise((resolve) => setTimeout(resolve, 300)); await loginAndUpdateState(fingerprint); - const data = await commands.getKey({}); + const data = await commands.getWallet({}); - setWallet(data.key); + setWallet(data.wallet); await new Promise((resolve) => setTimeout(resolve, 50)); diff --git a/src/contexts/WalletContext.tsx b/src/contexts/WalletContext.tsx index 3e0da02ff..580448451 100644 --- a/src/contexts/WalletContext.tsx +++ b/src/contexts/WalletContext.tsx @@ -1,12 +1,12 @@ -import { KeyInfo, commands } from '@/bindings'; +import { WalletRecord, commands } from '@/bindings'; import { CustomError } from '@/contexts/ErrorContext'; import { useErrors } from '@/hooks/useErrors'; import { fetchState, initializeWalletState } from '@/state'; import { createContext, useContext, useEffect, useState } from 'react'; interface WalletContextType { - wallet: KeyInfo | null; - setWallet: (wallet: KeyInfo | null) => void; + wallet: WalletRecord | null; + setWallet: (wallet: WalletRecord | null) => void; isSwitching: boolean; setIsSwitching: (isSwitching: boolean) => void; } @@ -16,7 +16,7 @@ export const WalletContext = createContext( ); export function WalletProvider({ children }: { children: React.ReactNode }) { - const [wallet, setWallet] = useState(null); + const [wallet, setWallet] = useState(null); const [isSwitching, setIsSwitching] = useState(false); const { addError } = useErrors(); @@ -24,8 +24,8 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const init = async () => { try { initializeWalletState(setWallet); - const data = await commands.getKey({}); - setWallet(data.key); + const data = await commands.getWallet({}); + setWallet(data.wallet); await fetchState(); } catch (error) { const customError = error as CustomError; diff --git a/src/pages/CreateWallet.tsx b/src/pages/CreateWallet.tsx index f56b973be..8b2e9d6a1 100644 --- a/src/pages/CreateWallet.tsx +++ b/src/pages/CreateWallet.tsx @@ -1,7 +1,7 @@ import { EmojiPicker } from '@/components/EmojiPicker'; import Header from '@/components/Header'; +import { MnemonicDisplay } from '@/components/MnemonicDisplay'; import SafeAreaView from '@/components/SafeAreaView'; -import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, @@ -28,15 +28,12 @@ import { FormMessage, } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { useWallet } from '@/contexts/WalletContext'; import { useErrors } from '@/hooks/useErrors'; import { zodResolver } from '@hookform/resolvers/zod'; import { t } from '@lingui/core/macro'; import { Trans } from '@lingui/react/macro'; -import { writeText } from '@tauri-apps/plugin-clipboard-manager'; -import { CopyIcon, RefreshCwIcon } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; @@ -52,7 +49,7 @@ export default function CreateWallet() { const submit = (values: z.infer) => { commands - .importKey({ + .importWallet({ name: values.walletName, key: values.mnemonic, save_secrets: values.saveMnemonic, @@ -61,8 +58,8 @@ export default function CreateWallet() { .catch(addError) .then(async () => { await fetchState(); - const data = await commands.getKey({}); - setWallet(data.key); + const data = await commands.getWallet({}); + setWallet(data.wallet); navigate('/wallet'); }); }; @@ -109,12 +106,6 @@ function CreateForm(props: { loadMnemonic(); }, [loadMnemonic]); - const mnemonic = form.watch('mnemonic'); - const copyMnemonic = useCallback(() => { - if (!mnemonic) return; - writeText(mnemonic); - }, [mnemonic]); - const [isConfirmOpen, setIsConfirmOpen] = useState(false); const confirmAndSubmit = (values: z.infer) => { @@ -154,7 +145,7 @@ function CreateForm(props: { Wallet Name - + @@ -249,43 +240,10 @@ function CreateForm(props: { />
-
- -
- - -
-
-
- {form - .watch('mnemonic') - ?.split(' ') - .map((word) => ( - - {word} - - ))} -
+