From 7a18068aebbac01ec6b748af8cff2c87d4e10d70 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Mon, 16 Feb 2026 17:18:00 -0500 Subject: [PATCH 1/9] Initial vaults --- crates/sage-database/src/tables/p2_puzzles.rs | 82 +++++++++++++++++-- crates/sage-keychain/src/key_data.rs | 4 + crates/sage-keychain/src/keychain.rs | 35 +++++++- crates/sage-wallet/src/error.rs | 3 + .../src/sync_manager/wallet_sync.rs | 75 +++++++++-------- crates/sage-wallet/src/test.rs | 6 +- crates/sage-wallet/src/wallet.rs | 13 ++- crates/sage-wallet/src/wallet/derivations.rs | 15 ++-- crates/sage/src/endpoints/actions.rs | 8 +- crates/sage/src/endpoints/keys.rs | 8 +- crates/sage/src/endpoints/wallet_connect.rs | 7 ++ crates/sage/src/sage.rs | 16 ++-- migrations/0006_vaults.sql | 46 +++++++++++ 13 files changed, 249 insertions(+), 69 deletions(-) create mode 100644 migrations/0006_vaults.sql diff --git a/crates/sage-database/src/tables/p2_puzzles.rs b/crates/sage-database/src/tables/p2_puzzles.rs index 18f414d7c..4499f9bc7 100644 --- a/crates/sage-database/src/tables/p2_puzzles.rs +++ b/crates/sage-database/src/tables/p2_puzzles.rs @@ -1,4 +1,8 @@ -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}; @@ -9,6 +13,7 @@ pub enum P2PuzzleKind { Clawback, Option, Arbor, + Vault, } #[derive(Debug, Clone, Copy)] @@ -17,6 +22,7 @@ pub enum P2Puzzle { Clawback(Clawback), Option(Underlying), Arbor(PublicKey), + Vault(P2Vault), } #[derive(Debug, Clone, Copy)] @@ -37,6 +43,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 +119,11 @@ 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 })) + } } } @@ -130,12 +146,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 +170,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 +190,14 @@ 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 + } } 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)") .fetch_all(conn) .await? .into_iter() @@ -185,7 +205,7 @@ async fn custody_p2_puzzle_hashes(conn: impl SqliteExecutor<'_>) -> Result, derivation_index: u32, is_hardened: bool, @@ -212,7 +232,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)", puzzle_hash ) .fetch_one(conn) @@ -327,7 +347,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 +461,28 @@ 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 p2_puzzle_kind( conn: impl SqliteExecutor<'_>, p2_puzzle_hash: Bytes32, @@ -554,6 +596,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..7ec62cf87 100644 --- a/crates/sage-keychain/src/key_data.rs +++ b/crates/sage-keychain/src/key_data.rs @@ -17,6 +17,10 @@ pub enum KeyData { entropy: bool, encrypted: Encrypted, }, + Vault { + #[serde_as(as = "Bytes")] + launcher_id: [u8; 32], + }, } #[serde_as] diff --git a/crates/sage-keychain/src/keychain.rs b/crates/sage-keychain/src/keychain.rs index 235f143a8..fbb80ddef 100644 --- a/crates/sage-keychain/src/keychain.rs +++ b/crates/sage-keychain/src/keychain.rs @@ -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 { .. }) | None => Ok(None), } } @@ -66,7 +66,7 @@ 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 { .. }) | None => Ok((None, None)), Some(KeyData::Secret { entropy, encrypted, .. }) => { @@ -89,13 +89,20 @@ 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 { .. }) | 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 { .. } => false, KeyData::Secret { .. } => true, } } @@ -175,4 +182,26 @@ 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) + } } diff --git a/crates/sage-wallet/src/error.rs b/crates/sage-wallet/src/error.rs index 578206a7a..e5c6e258e 100644 --- a/crates/sage-wallet/src/error.rs +++ b/crates/sage-wallet/src/error.rs @@ -127,4 +127,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 8a3085b41..3abe70602 100644 --- a/crates/sage-wallet/src/wallet.rs +++ b/crates/sage-wallet/src/wallet.rs @@ -33,11 +33,17 @@ pub use options::*; use crate::WalletError; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum WalletInfo { + Bls { intermediate_pk: PublicKey }, + Vault { launcher_id: Bytes32 }, +} + #[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 +53,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 +61,7 @@ impl Wallet { Self { db, fingerprint, - intermediate_pk, + info, genesis_challenge, agg_sig_constants, change_p2_puzzle_hash, @@ -380,6 +386,7 @@ impl Wallet { spend.finish().into_iter().collect(), ), )?, + P2Puzzle::Vault(_) => todo!(), } } SpendKind::Settlement(spend) => SettlementLayer diff --git a/crates/sage-wallet/src/wallet/derivations.rs b/crates/sage-wallet/src/wallet/derivations.rs index 2ae714edc..28d8828ff 100644 --- a/crates/sage-wallet/src/wallet/derivations.rs +++ b/crates/sage-wallet/src/wallet/derivations.rs @@ -9,7 +9,7 @@ use chia_wallet_sdk::{ }; use sage_database::{DatabaseTx, Derivation}; -use crate::WalletError; +use crate::{WalletError, WalletInfo}; use super::Wallet; @@ -20,17 +20,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 +73,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); } 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/keys.rs b/crates/sage/src/endpoints/keys.rs index 3b59b5154..8fae920c5 100644 --- a/crates/sage/src/endpoints/keys.rs +++ b/crates/sage/src/endpoints/keys.rs @@ -84,7 +84,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 { @@ -184,7 +186,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 { @@ -207,7 +209,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 { diff --git a/crates/sage/src/endpoints/wallet_connect.rs b/crates/sage/src/endpoints/wallet_connect.rs index 24c2aedde..1272ef674 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,12 @@ 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))? + } }; let (puzzle, proof) = match req.kind { diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index 8fa579743..852ae4c2d 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}, @@ -264,12 +266,16 @@ 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 { 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); @@ -281,7 +287,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/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) +); From 363e3bb134aff2597ecb9cc861cc365989fa3968 Mon Sep 17 00:00:00 2001 From: Rigidity Date: Tue, 24 Feb 2026 18:34:50 -0500 Subject: [PATCH 2/9] Add CLAUDE.md and login updates --- CLAUDE.md | 98 +++++++++++++++++++++++++++++++++++++++++++++ package.json | 2 +- pnpm-lock.yaml | 12 +++--- src/pages/Login.tsx | 80 ++++++++++++++++++++++++++++++++---- 4 files changed, 178 insertions(+), 14 deletions(-) create mode 100644 CLAUDE.md 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/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/pages/Login.tsx b/src/pages/Login.tsx index ee03caec1..74770947a 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -1,6 +1,15 @@ import SafeAreaView from '@/components/SafeAreaView'; import { WalletCard } from '@/components/WalletCard'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Skeleton } from '@/components/ui/skeleton'; import { useErrors } from '@/hooks/useErrors'; import { @@ -22,7 +31,14 @@ import { } from '@dnd-kit/sortable'; import { Trans } from '@lingui/react/macro'; import { platform } from '@tauri-apps/plugin-os'; -import { CogIcon } from 'lucide-react'; +import { + ClockPlusIcon, + CogIcon, + EyeIcon, + UserRoundKeyIcon, + UserRoundPlusIcon, + VaultIcon, +} from 'lucide-react'; import type { MouseEvent, TouchEvent } from 'react'; import { ForwardedRef, forwardRef, useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; @@ -117,12 +133,62 @@ export default function Login() { >