diff --git a/crates/sage-api/endpoints.json b/crates/sage-api/endpoints.json index 16263fdd9..99a4a15bf 100644 --- a/crates/sage-api/endpoints.json +++ b/crates/sage-api/endpoints.json @@ -9,6 +9,7 @@ "rename_key": false, "set_wallet_emoji": false, "get_key": false, + "get_wallet_address": true, "get_secret_key": false, "get_keys": false, "get_sync_status": true, diff --git a/crates/sage-api/src/requests/keys.rs b/crates/sage-api/src/requests/keys.rs index 573bf53c4..a8f3d78b0 100644 --- a/crates/sage-api/src/requests/keys.rs +++ b/crates/sage-api/src/requests/keys.rs @@ -398,6 +398,39 @@ pub struct GetSecretKeyResponse { pub secrets: Option, } +/// Get the receive address for any wallet without switching sessions +#[cfg_attr( + feature = "openapi", + crate::openapi_attr( + tag = "Authentication & Keys", + description = "Get the current receive address for any wallet by fingerprint without switching the active session." + ) +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetWalletAddress { + /// Wallet fingerprint + #[cfg_attr(feature = "openapi", schema(example = 1_234_567_890))] + pub fingerprint: u32, + /// Network ID to look up the address on (e.g. "mainnet", "testnet11") + #[cfg_attr(feature = "openapi", schema(example = "mainnet"))] + pub network_id: String, +} + +/// Response with the wallet's receive address +#[cfg_attr( + feature = "openapi", + crate::openapi_attr(tag = "Authentication & Keys") +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "tauri", derive(specta::Type))] +#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))] +pub struct GetWalletAddressResponse { + /// The wallet's current receive address + pub address: String, +} + /// List all custom theme NFTs #[cfg_attr( feature = "openapi", diff --git a/crates/sage/src/endpoints/keys.rs b/crates/sage/src/endpoints/keys.rs index 7f7908195..f71c4e665 100644 --- a/crates/sage/src/endpoints/keys.rs +++ b/crates/sage/src/endpoints/keys.rs @@ -16,9 +16,9 @@ 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, + GetSecretKeyResponse, GetWalletAddress, GetWalletAddressResponse, ImportKey, ImportKeyResponse, + KeyInfo, KeyKind, Login, LoginResponse, Logout, LogoutResponse, RenameKey, RenameKeyResponse, + Resync, ResyncResponse, SecretKeyInfo, SetWalletEmoji, SetWalletEmojiResponse, }; use sage_config::Wallet; use sage_database::{Database, Derivation}; @@ -384,4 +384,76 @@ impl Sage { Ok(GetKeysResponse { keys }) } + + pub async fn get_wallet_address( + &self, + req: GetWalletAddress, + ) -> Result { + let Some(master_pk) = self.keychain.extract_public_key(req.fingerprint)? else { + return Err(Error::UnknownFingerprint); + }; + + // Return the change_address override directly if one is configured + let wallet_cfg = self + .wallet_config + .wallets + .iter() + .find(|w| w.fingerprint == req.fingerprint); + + if let Some(cfg) = wallet_cfg { + if let Some(change_address) = &cfg.change_address { + return Ok(GetWalletAddressResponse { + address: change_address.clone(), + }); + } + } + + let network = self + .network_list + .by_name(&req.network_id) + .ok_or(Error::UnknownFingerprint)?; + + let prefix = network.prefix(); + let intermediate_pk = master_to_wallet_unhardened_intermediate(&master_pk); + + // Try to read the current receive address from the wallet's DB + let p2_puzzle_hash = self + .address_from_db(req.fingerprint, &req.network_id, false) + .await + .ok() + .flatten(); + + // Fall back to deriving index 0 from the master public key + let p2_puzzle_hash = p2_puzzle_hash.unwrap_or_else(|| { + let synthetic_key = intermediate_pk.derive_unhardened(0).derive_synthetic(); + StandardArgs::curry_tree_hash(synthetic_key).into() + }); + + let address = Address::new(p2_puzzle_hash, prefix).encode()?; + + Ok(GetWalletAddressResponse { address }) + } + + async fn address_from_db( + &self, + fingerprint: u32, + network_id: &str, + hardened: bool, + ) -> Result> { + let db_path = self + .path + .join("wallets") + .join(fingerprint.to_string()) + .join(format!("{network_id}.sqlite")); + + if !db_path.try_exists().unwrap_or(false) { + return Ok(None); + } + + let pool = self.connect_to_pool(db_path).await?; + let db = Database::new(pool); + let mut tx = db.tx().await?; + let index = tx.unused_derivation_index(hardened).await?; + Ok(tx.custody_p2_puzzle_hash(index, hardened).await.ok()) + } } diff --git a/crates/sage/src/sage.rs b/crates/sage/src/sage.rs index eca15d84a..9f664c4e8 100644 --- a/crates/sage/src/sage.rs +++ b/crates/sage/src/sage.rs @@ -436,7 +436,10 @@ impl Sage { pub async fn connect_to_database(&self, fingerprint: u32) -> Result { let path = self.wallet_db_path(fingerprint)?; + self.connect_to_pool(path).await + } + pub async fn connect_to_pool(&self, path: PathBuf) -> Result { let pool = SqlitePoolOptions::new() .connect_with( SqliteConnectOptions::from_str(&format!("sqlite://{}?mode=rwc", path.display()))? diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a4f2bd12a..840337c7f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -35,6 +35,7 @@ pub fn run() { commands::get_keys, commands::set_wallet_emoji, commands::get_key, + commands::get_wallet_address, commands::get_secret_key, commands::send_xch, commands::bulk_send_xch, diff --git a/src/bindings.ts b/src/bindings.ts index 67f25c5ed..c0cd87789 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -41,6 +41,9 @@ async setWalletEmoji(req: SetWalletEmoji) : Promise { async getKey(req: GetKey) : Promise { return await TAURI_INVOKE("get_key", { req }); }, +async getWalletAddress(req: GetWalletAddress) : Promise { + return await TAURI_INVOKE("get_wallet_address", { req }); +}, async getSecretKey(req: GetSecretKey) : Promise { return await TAURI_INVOKE("get_secret_key", { req }); }, @@ -1600,6 +1603,26 @@ export type GetVersionResponse = { * Semantic version string */ version: string } +/** + * Get the receive address for any wallet without switching sessions + */ +export type GetWalletAddress = { +/** + * Wallet fingerprint + */ +fingerprint: number; +/** + * Network ID to look up the address on (e.g. "mainnet", "testnet11") + */ +network_id: string } +/** + * Response with the wallet's receive address + */ +export type GetWalletAddressResponse = { +/** + * The wallet's current receive address + */ +address: string } export type Id = /** * The XCH asset diff --git a/src/components/TransferDialog.tsx b/src/components/TransferDialog.tsx index f5e76842b..473f3c2c7 100644 --- a/src/components/TransferDialog.tsx +++ b/src/components/TransferDialog.tsx @@ -9,6 +9,7 @@ import { PropsWithChildren } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { PasteInput } from './PasteInput'; +import { WalletAddressPicker } from './WalletAddressPicker'; import { Button } from './ui/button'; import { Dialog, @@ -82,9 +83,14 @@ export function TransferDialog({ name='address' render={({ field }) => ( - - Address - +
+ + Address + + form.setValue('address', address)} + /> +
void; +} + +export function WalletAddressPicker({ onSelect }: WalletAddressPickerProps) { + const { wallet } = useWallet(); + const { addError } = useErrors(); + const [otherWallets, setOtherWallets] = useState([]); + const [loadingFingerprint, setLoadingFingerprint] = useState( + null, + ); + + useEffect(() => { + if (!wallet) return; + + commands + .getKeys({}) + .then(({ keys }) => { + setOtherWallets( + keys.filter( + (k) => + k.fingerprint !== wallet.fingerprint && + k.network_id === wallet.network_id, + ), + ); + }) + .catch(addError); + }, [wallet, addError]); + + if (otherWallets.length === 0) return null; + + const handleSelect = async (fingerprint: number) => { + setLoadingFingerprint(fingerprint); + try { + const { address } = await commands.getWalletAddress({ + fingerprint, + network_id: wallet!.network_id, + }); + onSelect(address); + } catch (e) { + addError(e as Parameters[0]); + } finally { + setLoadingFingerprint(null); + } + }; + + return ( + + + + + + {otherWallets.map((w) => ( + handleSelect(w.fingerprint)} + > + {w.emoji && } + {w.name} + + ))} + + + ); +} diff --git a/src/pages/Send.tsx b/src/pages/Send.tsx index 27c7a1657..9290c235f 100644 --- a/src/pages/Send.tsx +++ b/src/pages/Send.tsx @@ -1,4 +1,5 @@ import ConfirmationDialog from '@/components/ConfirmationDialog'; +import { WalletAddressPicker } from '@/components/WalletAddressPicker'; import { TokenConfirmation } from '@/components/confirmations/TokenConfirmation'; import Container from '@/components/Container'; import Header from '@/components/Header'; @@ -254,9 +255,18 @@ export default function Send() { name='address' render={({ field }) => ( - - Address - +
+ + Address + + {!bulk && ( + + form.setValue('address', address) + } + /> + )} +
{bulk ? (