Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/sage-api/endpoints.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
33 changes: 33 additions & 0 deletions crates/sage-api/src/requests/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,39 @@ pub struct GetSecretKeyResponse {
pub secrets: Option<SecretKeyInfo>,
}

/// 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",
Expand Down
78 changes: 75 additions & 3 deletions crates/sage/src/endpoints/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -384,4 +384,76 @@ impl Sage {

Ok(GetKeysResponse { keys })
}

pub async fn get_wallet_address(
&self,
req: GetWalletAddress,
) -> Result<GetWalletAddressResponse> {
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<Option<Bytes32>> {
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())
}
}
3 changes: 3 additions & 0 deletions crates/sage/src/sage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,10 @@ impl Sage {

pub async fn connect_to_database(&self, fingerprint: u32) -> Result<SqlitePool> {
let path = self.wallet_db_path(fingerprint)?;
self.connect_to_pool(path).await
}

pub async fn connect_to_pool(&self, path: PathBuf) -> Result<SqlitePool> {
let pool = SqlitePoolOptions::new()
.connect_with(
SqliteConnectOptions::from_str(&format!("sqlite://{}?mode=rwc", path.display()))?
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions src/bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ async setWalletEmoji(req: SetWalletEmoji) : Promise<SetWalletEmojiResponse> {
async getKey(req: GetKey) : Promise<GetKeyResponse> {
return await TAURI_INVOKE("get_key", { req });
},
async getWalletAddress(req: GetWalletAddress) : Promise<GetWalletAddressResponse> {
return await TAURI_INVOKE("get_wallet_address", { req });
},
async getSecretKey(req: GetSecretKey) : Promise<GetSecretKeyResponse> {
return await TAURI_INVOKE("get_secret_key", { req });
},
Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions src/components/TransferDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,9 +83,14 @@ export function TransferDialog({
name='address'
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Address</Trans>
</FormLabel>
<div className='flex items-center justify-between'>
<FormLabel>
<Trans>Address</Trans>
</FormLabel>
<WalletAddressPicker
onSelect={(address) => form.setValue('address', address)}
/>
</div>
<FormControl>
<PasteInput
{...field}
Expand Down
90 changes: 90 additions & 0 deletions src/components/WalletAddressPicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { commands, KeyInfo } from '@/bindings';
import { useWallet } from '@/contexts/WalletContext';
import { useErrors } from '@/hooks/useErrors';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { WalletIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './ui/dropdown-menu';

interface WalletAddressPickerProps {
onSelect: (address: string) => void;
}

export function WalletAddressPicker({ onSelect }: WalletAddressPickerProps) {
const { wallet } = useWallet();
const { addError } = useErrors();
const [otherWallets, setOtherWallets] = useState<KeyInfo[]>([]);
const [loadingFingerprint, setLoadingFingerprint] = useState<number | null>(
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<typeof addError>[0]);
} finally {
setLoadingFingerprint(null);
}
};

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type='button'
variant='ghost'
size='sm'
className='h-auto py-0.5 px-1.5 text-xs text-muted-foreground hover:text-foreground gap-1'
aria-label={t`Insert address from another wallet`}
>
<WalletIcon className='h-3 w-3' aria-hidden='true' />
<Trans>My wallets</Trans>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='start'>
{otherWallets.map((w) => (
<DropdownMenuItem
key={w.fingerprint}
disabled={loadingFingerprint === w.fingerprint}
onSelect={() => handleSelect(w.fingerprint)}
>
{w.emoji && <span aria-hidden='true'>{w.emoji}</span>}
<span>{w.name}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
16 changes: 13 additions & 3 deletions src/pages/Send.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -254,9 +255,18 @@ export default function Send() {
name='address'
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Address</Trans>
</FormLabel>
<div className='flex items-center justify-between'>
<FormLabel>
<Trans>Address</Trans>
</FormLabel>
{!bulk && (
<WalletAddressPicker
onSelect={(address) =>
form.setValue('address', address)
}
/>
)}
</div>
<FormControl>
{bulk ? (
<Textarea
Expand Down
Loading