From 156820ecb9405421efed2a6785991394391ebfbe Mon Sep 17 00:00:00 2001 From: Don Kackman Date: Sat, 28 Feb 2026 16:24:43 -0600 Subject: [PATCH 1/3] send to my wallets --- crates/sage-api/endpoints.json | 1 + crates/sage-api/src/requests/keys.rs | 30 +++++++++ crates/sage/src/endpoints/keys.rs | 67 +++++++++++++++++++- src-tauri/src/lib.rs | 1 + src/bindings.ts | 19 ++++++ src/components/TransferDialog.tsx | 12 +++- src/components/WalletAddressPicker.tsx | 87 ++++++++++++++++++++++++++ src/pages/Send.tsx | 16 ++++- 8 files changed, 224 insertions(+), 9 deletions(-) create mode 100644 src/components/WalletAddressPicker.tsx 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..0954e8459 100644 --- a/crates/sage-api/src/requests/keys.rs +++ b/crates/sage-api/src/requests/keys.rs @@ -398,6 +398,36 @@ 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, Copy, 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, +} + +/// 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..e156c0310 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,65 @@ 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_id = wallet_cfg + .and_then(|w| w.network.clone()) + .unwrap_or_else(|| self.config.network.default_network.clone()); + + let network = self + .network_list + .by_name(&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, 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, hardened: bool) -> Result> { + let pool = self.connect_to_database(fingerprint).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/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..2fa559da3 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,22 @@ 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 } +/** + * 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 }); + 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 ? (