Skip to content

Commit

Permalink
Wallet watch-only accounts (kaspanet#59)
Browse files Browse the repository at this point in the history
* Prints extended public keys on export mnemonic command (feature of go kaspawallet).

* Watch-only account implementation for bip32 and multisig kind of accounts with new command account import watchonly.

* Refactor code for less variables.

* Patch import type for command.

* CLI Support for watch only accounts in select account with args function.

* Function sig_op_count equals implemented.

* Helper function in wallet to format XPUB according to network. Converter NetworkId for Prefix. BIP32-Watch Account renamed from WatchOnly, multisig feature removed. Multisig account import as watch-only command added.

* cli watch-only header in list and select.

* Resolve merge.
  • Loading branch information
1bananagirl authored Jun 29, 2024
1 parent db213fe commit 819bfca
Show file tree
Hide file tree
Showing 24 changed files with 885 additions and 22 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use crate::modules::node::Node;
use crate::notifier::{Notification, Notifier};
use crate::result::Result;
use kaspa_daemon::{DaemonEvent, DaemonKind, Daemons};
use kaspa_wallet_core::account::Account;
use kaspa_wallet_core::rpc::DynRpcApi;
use kaspa_wallet_core::storage::{IdT, PrvKeyDataInfo};
use kaspa_wrpc_client::KaspaRpcClient;
Expand Down Expand Up @@ -567,6 +568,16 @@ impl KaspaCli {
list_by_key.push((key.clone(), prv_key_accounts));
}

let mut watch_accounts = Vec::<(usize, Arc<dyn Account>)>::new();
let mut unfiltered_accounts = self.wallet.accounts(None, &guard).await?;

while let Some(account) = unfiltered_accounts.try_next().await? {
if account.feature().is_some() {
watch_accounts.push((flat_list.len(), account.clone()));
flat_list.push(account.clone());
}
}

if flat_list.is_empty() {
return Err(Error::NoAccounts);
} else if autoselect && flat_list.len() == 1 {
Expand All @@ -586,6 +597,16 @@ impl KaspaCli {
})
});

if !watch_accounts.is_empty() {
tprintln!(self, "• watch-only");
}

watch_accounts.iter().for_each(|(seq, account)| {
let seq = style(seq.to_string()).cyan();
let ls_string = account.get_list_string().unwrap_or_else(|err| panic!("{err}"));
tprintln!(self, " {seq}: {ls_string}");
});

tprintln!(self);

let range = if flat_list.len() > 1 { format!("[{}..{}] ", 0, flat_list.len() - 1) } else { "".to_string() };
Expand Down Expand Up @@ -676,6 +697,19 @@ impl KaspaCli {
tprintln!(self, " {}", style(receive_address.to_string()).blue());
}
}

let mut unfiltered_accounts = self.wallet.accounts(None, &guard).await?;
let mut feature_header_printed = false;
while let Some(account) = unfiltered_accounts.try_next().await? {
if let Some(feature) = account.feature() {
if !feature_header_printed {
tprintln!(self, "{}", style("• watch-only").dim());
feature_header_printed = true;
}
tprintln!(self, " • {}", account.get_list_string().unwrap());
tprintln!(self, " • {}", style(feature).cyan());
}
}
tprintln!(self);

Ok(())
Expand Down
6 changes: 6 additions & 0 deletions cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pub enum Error {
#[error("wallet secret is required")]
WalletSecretRequired,

#[error("watch-only wallet kpub is required")]
WalletBip32WatchXpubRequired,

#[error("wallet secrets do not match")]
WalletSecretMatch,

Expand All @@ -84,6 +87,9 @@ pub enum Error {
#[error("key data not found")]
KeyDataNotFound,

#[error("no key data to export for watch-only account")]
WatchOnlyAccountNoKeyData,

#[error("no accounts found, please create an account to continue")]
NoAccounts,

Expand Down
31 changes: 30 additions & 1 deletion cli/src/modules/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ impl Account {
"account import mnemonic multisig [additional keys]",
"Import mnemonic and additional keys for a multisig account",
),
("account import bip32-watch", "Import a extended public key for a watch-only bip32 account"),
("account import multisig-watch", "Import extended public keys for a watch-only multisig account"),
],
None,
)?;
Expand Down Expand Up @@ -175,9 +177,36 @@ impl Account {

return Ok(());
}
"bip32-watch" => {
let account_name = if argv.is_empty() {
None
} else {
let name = argv.remove(0);
let name = name.trim().to_string();
Some(name)
};

let account_name = account_name.as_deref();
wizards::account::bip32_watch(&ctx, account_name).await?;
}
"multisig-watch" => {
let account_name = if argv.is_empty() {
None
} else {
let name = argv.remove(0);
let name = name.trim().to_string();

Some(name)
};

let account_name = account_name.as_deref();
wizards::account::multisig_watch(&ctx, account_name).await?;

return Ok(());
}
_ => {
tprintln!(ctx, "unknown account import type: '{import_kind}'");
tprintln!(ctx, "supported import types are: 'mnemonic' or 'legacy-data'\r\n");
tprintln!(ctx, "supported import types are: 'mnemonic', 'legacy-data' or 'multisig-watch'\r\n");
return Ok(());
}
}
Expand Down
12 changes: 12 additions & 0 deletions cli/src/modules/details.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ impl Details {
tprintln!(ctx.term(), "{:>4}{}", "", style(address.to_string()).blue());
});

if let Some(xpub_keys) = account.xpub_keys() {
if account.feature().is_some() {
if let Some(feature) = account.feature() {
tprintln!(ctx.term(), "Feature: {}", style(feature).cyan());
}
tprintln!(ctx.term(), "Extended public keys:");
xpub_keys.iter().for_each(|xpub| {
tprintln!(ctx.term(), "{:>4}{}", "", style(ctx.wallet().network_format_xpub(xpub)).dim());
});
}
}

Ok(())
}
}
43 changes: 31 additions & 12 deletions cli/src/modules/export.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::imports::*;
use kaspa_wallet_core::account::{multisig::MultiSig, Account, MULTISIG_ACCOUNT_KIND};
use kaspa_wallet_core::account::{multisig::MultiSig, Account, BIP32_ACCOUNT_KIND, MULTISIG_ACCOUNT_KIND};

#[derive(Default, Handler)]
#[help("Export transactions, a wallet or a private key")]
Expand Down Expand Up @@ -32,8 +32,8 @@ impl Export {

async fn export_multisig_account(ctx: Arc<KaspaCli>, account: Arc<MultiSig>) -> Result<()> {
match &account.prv_key_data_ids() {
None => Err(Error::KeyDataNotFound),
Some(v) if v.is_empty() => Err(Error::KeyDataNotFound),
None => Err(Error::WatchOnlyAccountNoKeyData),
Some(v) if v.is_empty() => Err(Error::WatchOnlyAccountNoKeyData),
Some(prv_key_data_ids) => {
let wallet_secret = Secret::new(ctx.term().ask(true, "Enter wallet password: ").await?.trim().as_bytes().to_vec());
if wallet_secret.as_ref().is_empty() {
Expand All @@ -45,26 +45,38 @@ async fn export_multisig_account(ctx: Arc<KaspaCli>, account: Arc<MultiSig>) ->

let prv_key_data_store = ctx.store().as_prv_key_data_store()?;
let mut generated_xpub_keys = Vec::with_capacity(prv_key_data_ids.len());

for (id, prv_key_data_id) in prv_key_data_ids.iter().enumerate() {
let prv_key_data = prv_key_data_store.load_key_data(&wallet_secret, prv_key_data_id).await?.unwrap();
let mnemonic = prv_key_data.as_mnemonic(None).unwrap().unwrap();

let xpub_key: kaspa_bip32::ExtendedPublicKey<kaspa_bip32::secp256k1::PublicKey> =
prv_key_data.create_xpub(None, MULTISIG_ACCOUNT_KIND.into(), 0).await?; // todo it can be done concurrently

tprintln!(ctx, "");
tprintln!(ctx, "extended public key {}:", id + 1);
tprintln!(ctx, "");
tprintln!(ctx, "{}", ctx.wallet().network_format_xpub(&xpub_key));
tprintln!(ctx, "");

tprintln!(ctx, "mnemonic {}:", id + 1);
tprintln!(ctx, "");
tprintln!(ctx, "{}", mnemonic.phrase());
tprintln!(ctx, "");

let xpub_key = prv_key_data.create_xpub(None, MULTISIG_ACCOUNT_KIND.into(), 0).await?; // todo it can be done concurrently
generated_xpub_keys.push(xpub_key);
}

let additional = account.xpub_keys().iter().filter(|xpub| !generated_xpub_keys.contains(xpub));
additional.enumerate().for_each(|(idx, xpub)| {
if idx == 0 {
tprintln!(ctx, "additional xpubs: ");
}
tprintln!(ctx, "{xpub}");
});
let test = account.xpub_keys();

if let Some(keys) = test {
let additional = keys.iter().filter(|item| !generated_xpub_keys.contains(item));
additional.enumerate().for_each(|(idx, xpub)| {
if idx == 0 {
tprintln!(ctx, "additional xpubs: ");
}
tprintln!(ctx, "{}", ctx.wallet().network_format_xpub(xpub));
});
}
Ok(())
}
}
Expand Down Expand Up @@ -94,6 +106,13 @@ async fn export_single_key_account(ctx: Arc<KaspaCli>, account: Arc<dyn Account>
let prv_key_data = keydata.payload.decrypt(payment_secret.as_ref())?;
let mnemonic = prv_key_data.as_ref().as_mnemonic()?;

let xpub_key = keydata.create_xpub(None, BIP32_ACCOUNT_KIND.into(), 0).await?; // todo it can be done concurrently

tprintln!(ctx, "extended public key:");
tprintln!(ctx, "");
tprintln!(ctx, "{}", ctx.wallet().network_format_xpub(&xpub_key));
tprintln!(ctx, "");

match mnemonic {
None => {
tprintln!(ctx, "mnemonic is not available for this private key");
Expand Down
59 changes: 59 additions & 0 deletions cli/src/wizards/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,62 @@ async fn create_multisig(ctx: &Arc<KaspaCli>, account_name: Option<String>, mnem
wallet.select(Some(&account)).await?;
Ok(())
}

pub(crate) async fn bip32_watch(ctx: &Arc<KaspaCli>, name: Option<&str>) -> Result<()> {
let term = ctx.term();
let wallet = ctx.wallet();

let name = if let Some(name) = name {
Some(name.to_string())
} else {
Some(term.ask(false, "Please enter account name (optional, press <enter> to skip): ").await?.trim().to_string())
};

let mut xpub_keys = Vec::with_capacity(1);
let xpub_key = term.ask(false, "Enter extended public key: ").await?;
xpub_keys.push(xpub_key.trim().to_owned());

let wallet_secret = Secret::new(term.ask(true, "Enter wallet password: ").await?.trim().as_bytes().to_vec());
if wallet_secret.as_ref().is_empty() {
return Err(Error::WalletSecretRequired);
}

let account_create_args_bip32_watch = AccountCreateArgsBip32Watch::new(name, xpub_keys);
let account = wallet.create_account_bip32_watch(&wallet_secret, account_create_args_bip32_watch).await?;

tprintln!(ctx, "\naccount created: {}\n", account.get_list_string()?);
wallet.select(Some(&account)).await?;
Ok(())
}

pub(crate) async fn multisig_watch(ctx: &Arc<KaspaCli>, name: Option<&str>) -> Result<()> {
let term = ctx.term();

let account_name = if let Some(name) = name {
Some(name.to_string())
} else {
Some(term.ask(false, "Please enter account name (optional, press <enter> to skip): ").await?.trim().to_string())
};

let term = ctx.term();
let wallet = ctx.wallet();
let (wallet_secret, _) = ctx.ask_wallet_secret(None).await?;
let minimum_signatures: u16 = term.ask(false, "Enter the minimum number of signatures required: ").await?.parse()?;

let prv_key_data_args = Vec::with_capacity(0);

let answer = term.ask(false, "Enter the number of extended public keys: ").await?.trim().to_string(); //.parse()?;
let xpub_keys_len: usize = if answer.is_empty() { 0 } else { answer.parse()? };

let mut xpub_keys = Vec::with_capacity(xpub_keys_len);
for i in 1..=xpub_keys_len {
let xpub_key = term.ask(false, &format!("Enter extended public {i} key: ")).await?;
xpub_keys.push(xpub_key.trim().to_owned());
}
let account =
wallet.create_account_multisig(&wallet_secret, prv_key_data_args, xpub_keys, account_name, minimum_signatures).await?;

tprintln!(ctx, "\naccount created: {}\n", account.get_list_string()?);
wallet.select(Some(&account)).await?;
Ok(())
}
3 changes: 2 additions & 1 deletion wallet/bip32/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ thiserror.workspace = true
wasm-bindgen.workspace = true
workflow-wasm.workspace = true
zeroize.workspace = true
kaspa-consensus-core.workspace = true

[dev-dependencies]
faster-hex.workspace = true
faster-hex.workspace = true
13 changes: 13 additions & 0 deletions wallet/bip32/src/prefix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use core::{
fmt::{self, Debug, Display},
str,
};
use kaspa_consensus_core::network::{NetworkId, NetworkType};

/// BIP32 extended key prefixes a.k.a. "versions" (e.g. `xpub`, `xprv`)
///
Expand Down Expand Up @@ -234,6 +235,18 @@ impl TryFrom<&str> for Prefix {
}
}

impl From<NetworkId> for Prefix {
fn from(value: NetworkId) -> Self {
let network_type = value.network_type();
match network_type {
NetworkType::Mainnet => Prefix::KPUB,
NetworkType::Devnet => Prefix::KTUB,
NetworkType::Simnet => Prefix::KTUB,
NetworkType::Testnet => Prefix::KTUB,
}
}
}

#[cfg(test)]
mod tests {
use super::Prefix;
Expand Down
1 change: 1 addition & 0 deletions wallet/core/src/account/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ impl FromStr for AccountKind {
"bip32" => Ok(BIP32_ACCOUNT_KIND.into()),
"multisig" => Ok(MULTISIG_ACCOUNT_KIND.into()),
"keypair" => Ok(KEYPAIR_ACCOUNT_KIND.into()),
"bip32watch" => Ok(BIP32_WATCH_ACCOUNT_KIND.into()),
_ => Err(Error::InvalidAccountKind),
}
}
Expand Down
8 changes: 8 additions & 0 deletions wallet/core/src/account/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,14 @@ pub trait Account: AnySync + Send + Sync + 'static {
self.context().settings.name.clone()
}

fn feature(&self) -> Option<String> {
None
}

fn xpub_keys(&self) -> Option<&ExtendedPublicKeys> {
None
}

fn name_or_id(&self) -> String {
if let Some(name) = self.name() {
if name.is_empty() {
Expand Down
4 changes: 4 additions & 0 deletions wallet/core/src/account/variants/bip32.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,10 @@ impl Account for Bip32 {
BIP32_ACCOUNT_KIND.into()
}

// fn xpub_keys(&self) -> Option<&ExtendedPublicKeys> {
// None
// }

fn prv_key_data_id(&self) -> Result<&PrvKeyDataId> {
Ok(&self.prv_key_data_id)
}
Expand Down
Loading

0 comments on commit 819bfca

Please sign in to comment.