From 5e4c04fa809aada59ad67f0ef34a3b4d75e00d6b Mon Sep 17 00:00:00 2001 From: edouardparis Date: Wed, 7 Aug 2024 12:15:36 +0200 Subject: [PATCH] Add invitation process to add wallet flow --- gui/src/daemon/mod.rs | 3 + gui/src/installer/message.rs | 15 +- gui/src/installer/mod.rs | 5 +- gui/src/installer/step/backend.rs | 265 ++++++++++++++++++++-- gui/src/installer/step/descriptor.rs | 4 + gui/src/installer/step/mod.rs | 2 +- gui/src/installer/view.rs | 281 ++++++++++++++++++++---- gui/src/lianalite/client/backend/api.rs | 20 ++ gui/src/lianalite/client/backend/mod.rs | 85 ++++++- 9 files changed, 609 insertions(+), 71 deletions(-) diff --git a/gui/src/daemon/mod.rs b/gui/src/daemon/mod.rs index 6d4e70c5..863a82ef 100644 --- a/gui/src/daemon/mod.rs +++ b/gui/src/daemon/mod.rs @@ -119,6 +119,9 @@ pub trait Daemon: Debug { &self, labels: &HashMap>, ) -> Result<(), DaemonError>; + async fn send_wallet_invitation(&self, _email: &str) -> Result<(), DaemonError> { + Ok(()) + } // List spend transactions, optionally filtered to the specified `txids`. // Set `txids` to `None` for no filter (passing an empty slice returns no transactions). diff --git a/gui/src/installer/message.rs b/gui/src/installer/message.rs index 84169fb9..45744183 100644 --- a/gui/src/installer/message.rs +++ b/gui/src/installer/message.rs @@ -30,6 +30,7 @@ pub enum Message { Installed(Result), CreateTaprootDescriptor(bool), SelectBackend(SelectBackend), + ImportRemoteWallet(ImportRemoteWallet), SelectBitcoindType(SelectBitcoindTypeMsg), InternalBitcoind(InternalBitcoindMsg), DefineBitcoind(DefineBitcoind), @@ -53,7 +54,19 @@ pub enum SelectBackend { // Commands messages OTPRequested(Result<(AuthClient, String), Error>), OTPResent(Result<(), Error>), - Connected(Result<(context::RemoteBackend, Option), Error>), + Connected(Result), +} + +#[derive(Debug, Clone)] +pub enum ImportRemoteWallet { + RemoteWallets(Result, Error>), + ImportDescriptor(String), + ConfirmDescriptor, + ImportInvitationToken(String), + FetchInvitation, + InvitationFetched(Result), + AcceptInvitation, + InvitationAccepted(Result), } #[derive(Debug, Clone)] diff --git a/gui/src/installer/mod.rs b/gui/src/installer/mod.rs index d3ceedb8..cd475f1f 100644 --- a/gui/src/installer/mod.rs +++ b/gui/src/installer/mod.rs @@ -40,8 +40,8 @@ use crate::{ pub use message::Message; use step::{ BackupDescriptor, BackupMnemonic, ChooseBackend, DefineBitcoind, DefineDescriptor, Final, - ImportDescriptor, InternalBitcoindStep, RecoverMnemonic, RegisterDescriptor, - SelectBitcoindTypeStep, ShareXpubs, Step, Welcome, + ImportDescriptor, ImportRemoteWallet, InternalBitcoindStep, RecoverMnemonic, + RegisterDescriptor, SelectBitcoindTypeStep, ShareXpubs, Step, Welcome, }; pub struct Installer { @@ -188,6 +188,7 @@ impl Installer { self.steps = vec![ Welcome::default().into(), ChooseBackend::new(self.network).into(), + ImportRemoteWallet::new(self.network).into(), ImportDescriptor::new(self.network).into(), RecoverMnemonic::default().into(), RegisterDescriptor::new_import_wallet().into(), diff --git a/gui/src/installer/step/backend.rs b/gui/src/installer/step/backend.rs index 1bafcc6a..5b2ed387 100644 --- a/gui/src/installer/step/backend.rs +++ b/gui/src/installer/step/backend.rs @@ -1,9 +1,12 @@ +use std::str::FromStr; + use iced::Command; -use liana::miniscript::bitcoin::Network; +use liana::{descriptors::LianaDescriptor, miniscript::bitcoin::Network}; use liana_ui::{component::form, widget::Element}; use crate::{ + daemon::DaemonError, hw::HardwareWallets, installer::{ context::{self, Context, RemoteBackend}, @@ -31,7 +34,6 @@ pub enum ConnectionStep { Connected { email: String, remote_backend: context::RemoteBackend, - wallet: Option, remote_backend_is_selected: bool, }, } @@ -65,6 +67,9 @@ impl From for Box { } impl Step for ChooseBackend { + fn skip(&self, _ctx: &Context) -> bool { + self.network != Network::Bitcoin && self.network != Network::Signet + } fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command { if matches!( message, @@ -196,18 +201,17 @@ impl Step for ChooseBackend { Message::SelectBackend(message::SelectBackend::Connected(res)) => { self.processing = false; match res { - Ok((remote_backend, wallet)) => { + Ok(remote_backend) => { self.step = ConnectionStep::Connected { email: email.clone(), remote_backend, - wallet, remote_backend_is_selected: false, }; } Err(e) => { if let Error::Auth(AuthError { http_status, .. }) = e { if http_status == Some(403) { - self.auth_error = Some("Token is expired or is invalid") + self.auth_error = Some("Token has expired or is invalid") } else { self.connection_error = Some(e); } @@ -278,10 +282,9 @@ impl Step for ChooseBackend { self.connection_error.as_ref(), self.auth_error, ), - ConnectionStep::Connected { email, wallet, .. } => view::connection_step_connected( + ConnectionStep::Connected { email, .. } => view::connection_step_connected( email, self.processing, - wallet.as_ref().map(|w| w.name.as_str()), self.connection_error.as_ref(), self.auth_error, ), @@ -294,14 +297,250 @@ pub async fn connect( auth: AuthClient, token: String, backend_api_url: String, -) -> Result<(context::RemoteBackend, Option), Error> { +) -> Result { let access = auth.verify_otp(token.trim_end()).await?; let client = BackendClient::connect(auth, backend_api_url, access.clone()).await?; + Ok(RemoteBackend::WithoutWallet(client)) +} - if !client.list_wallets().await?.is_empty() { - let (wallet_client, wallet) = client.connect_first().await?; - Ok((RemoteBackend::WithWallet(wallet_client), Some(wallet))) - } else { - Ok((RemoteBackend::WithoutWallet(client), None)) +pub struct ImportRemoteWallet { + network: Network, + invitation_token: form::Value, + invitation: Option, + imported_descriptor: form::Value, + descriptor: Option, + error: Option, + backend: Option, + wallets: Vec, +} + +impl ImportRemoteWallet { + pub fn new(network: Network) -> Self { + Self { + network, + invitation_token: form::Value::default(), + invitation: None, + imported_descriptor: form::Value::default(), + descriptor: None, + error: None, + backend: None, + wallets: Vec::new(), + } + } +} + +impl Step for ImportRemoteWallet { + fn skip(&self, ctx: &Context) -> bool { + ctx.remote_backend.is_none() + } + fn load_context(&mut self, ctx: &Context) { + self.backend.clone_from(&ctx.remote_backend); + } + fn load(&self) -> Command { + let backend = self + .backend + .clone() + .expect("Must be one otherwise the step is skipped"); + Command::perform( + async move { + let wallets = match backend { + context::RemoteBackend::WithoutWallet(backend) => { + backend.list_wallets().await? + } + context::RemoteBackend::WithWallet(backend) => { + backend.inner_client().list_wallets().await? + } + }; + + Ok(wallets) + }, + |res| Message::ImportRemoteWallet(message::ImportRemoteWallet::RemoteWallets(res)), + ) + } + // form value is set as valid each time it is edited. + // Verification of the values is happening when the user click on Next button. + fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command { + match message { + Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportDescriptor(desc)) => { + self.imported_descriptor.value = desc; + if !self.imported_descriptor.value.is_empty() { + if let Ok(desc) = LianaDescriptor::from_str(&self.imported_descriptor.value) { + if self.network == Network::Bitcoin { + self.imported_descriptor.valid = desc.all_xpubs_net_is(self.network); + } else { + self.imported_descriptor.valid = + desc.all_xpubs_net_is(Network::Testnet); + } + } else { + self.imported_descriptor.valid = false; + } + } else { + self.imported_descriptor.valid = false; + } + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::ConfirmDescriptor) => { + if let Ok(desc) = LianaDescriptor::from_str(&self.imported_descriptor.value) { + if self.network == Network::Bitcoin { + self.imported_descriptor.valid = desc.all_xpubs_net_is(self.network); + } else { + self.imported_descriptor.valid = desc.all_xpubs_net_is(Network::Testnet); + } + if self.imported_descriptor.valid { + let backend = self.backend.take(); + if let Some(context::RemoteBackend::WithWallet(backend)) = backend { + self.backend = + Some(context::RemoteBackend::WithoutWallet(backend.into_inner())); + } else { + self.backend = backend; + } + self.descriptor = Some(desc); + return Command::perform(async {}, |_| Message::Next); + } + } else { + self.imported_descriptor.valid = false; + } + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::RemoteWallets(res)) => { + match res { + Ok(wallets) => self.wallets = wallets, + Err(e) => self.error = Some(e.to_string()), + } + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::ImportInvitationToken( + token, + )) => { + self.invitation_token.value = token; + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::FetchInvitation) => { + let backend = self + .backend + .clone() + .map(|b| match b { + context::RemoteBackend::WithoutWallet(b) => b, + context::RemoteBackend::WithWallet(b) => b.into_inner(), + }) + .expect("Must be a remote backend at this point"); + let token = self.invitation_token.value.clone(); + self.error = None; + return Command::perform( + async move { + let invitation = backend.get_wallet_invitation(&token).await?; + Ok(invitation) + }, + |res| { + Message::ImportRemoteWallet(message::ImportRemoteWallet::InvitationFetched( + res, + )) + }, + ); + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::InvitationFetched(res)) => { + match res { + Err(_) => self.invitation_token.valid = false, + Ok(invitation) => self.invitation = Some(invitation), + } + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::AcceptInvitation) => { + let backend = self + .backend + .clone() + .map(|b| match b { + context::RemoteBackend::WithoutWallet(b) => b, + context::RemoteBackend::WithWallet(b) => b.into_inner(), + }) + .expect("Must be a remote backend at this point"); + let invitation = self.invitation.clone().expect("Invitation was fetched"); + self.error = None; + return Command::perform( + async move { + backend.accept_wallet_invitation(&invitation.id).await?; + let wallets = backend.list_wallets().await?; + wallets + .into_iter() + .find(|w| w.id == invitation.wallet_id) + .ok_or( + DaemonError::Unexpected( + "Wallet of accepted invitation not found".to_string(), + ) + .into(), + ) + }, + |res| { + Message::ImportRemoteWallet( + message::ImportRemoteWallet::InvitationAccepted(res), + ) + }, + ); + } + Message::ImportRemoteWallet(message::ImportRemoteWallet::InvitationAccepted(res)) => { + match res { + Err(e) => self.error = Some(e.to_string()), + Ok(wallet) => { + self.invitation = None; + self.invitation_token = form::Value::default(); + self.wallets.push(wallet); + } + } + } + Message::Select(i) => { + if let Some(wallet) = self.wallets.get(i).cloned() { + if let Some(backend) = self.backend.take() { + self.backend = Some(match backend { + context::RemoteBackend::WithoutWallet(backend) => { + context::RemoteBackend::WithWallet( + backend.connect_wallet(wallet.clone()).0, + ) + } + context::RemoteBackend::WithWallet(backend) => { + context::RemoteBackend::WithWallet( + backend.into_inner().connect_wallet(wallet.clone()).0, + ) + } + }); + // ensure that no descriptor is imported. + self.imported_descriptor = form::Value::default(); + self.descriptor = Some(wallet.descriptor); + return Command::perform(async {}, |_| Message::Next); + } + } + } + _ => {} + } + + Command::none() + } + + fn apply(&mut self, ctx: &mut Context) -> bool { + // Set to true in order to force the registration process to be shown to user. + ctx.hw_is_used = true; + ctx.descriptor.clone_from(&self.descriptor); + ctx.remote_backend.clone_from(&self.backend); + + true + } + + fn view<'a>( + &'a self, + _hws: &'a HardwareWallets, + progress: (usize, usize), + email: Option<&'a str>, + ) -> Element { + view::import_wallet_or_descriptor( + progress, + email, + &self.invitation_token, + self.invitation + .as_ref() + .map(|invit| invit.wallet_name.as_str()), + &self.imported_descriptor, + self.error.as_ref(), + self.wallets.iter().map(|w| &w.name).collect(), + ) + } +} + +impl From for Box { + fn from(s: ImportRemoteWallet) -> Box { + Box::new(s) } } diff --git a/gui/src/installer/step/descriptor.rs b/gui/src/installer/step/descriptor.rs index 029e9036..0411725f 100644 --- a/gui/src/installer/step/descriptor.rs +++ b/gui/src/installer/step/descriptor.rs @@ -1143,6 +1143,10 @@ impl ImportDescriptor { } impl Step for ImportDescriptor { + // ImportRemoteWallet is used instead + fn skip(&self, ctx: &Context) -> bool { + ctx.remote_backend.is_some() + } // form value is set as valid each time it is edited. // Verification of the values is happening when the user click on Next button. fn update(&mut self, _hws: &mut HardwareWallets, message: Message) -> Command { diff --git a/gui/src/installer/step/mod.rs b/gui/src/installer/step/mod.rs index acb15e0d..08ade031 100644 --- a/gui/src/installer/step/mod.rs +++ b/gui/src/installer/step/mod.rs @@ -10,7 +10,7 @@ pub use bitcoind::{ pub use descriptor::{BackupDescriptor, DefineDescriptor, ImportDescriptor, RegisterDescriptor}; -pub use backend::ChooseBackend; +pub use backend::{ChooseBackend, ImportRemoteWallet}; pub use mnemonic::{BackupMnemonic, RecoverMnemonic}; pub use share_xpubs::ShareXpubs; diff --git a/gui/src/installer/view.rs b/gui/src/installer/view.rs index b5b1049a..7fa53d40 100644 --- a/gui/src/installer/view.rs +++ b/gui/src/installer/view.rs @@ -16,7 +16,7 @@ use liana_ui::{ color, component::{ button, card, collapse, form, hw, separation, - text::{h3, p1_regular, text, Text}, + text::{h3, h4_bold, h5_regular, p1_regular, text, Text}, tooltip, }, icon, image, theme, @@ -376,6 +376,220 @@ pub fn recovery_path_view( .into() } +pub fn import_wallet_or_descriptor<'a>( + progress: (usize, usize), + email: Option<&'a str>, + invitation: &'a form::Value, + invitation_wallet: Option<&'a str>, + imported_descriptor: &'a form::Value, + error: Option<&'a String>, + wallets: Vec<&'a String>, +) -> Element<'a, Message> { + let mut col_wallets = Column::new() + .spacing(20) + .push(h4_bold("Choose the wallet to import")); + let no_wallets = wallets.is_empty(); + for (i, wallet) in wallets.into_iter().enumerate() { + col_wallets = col_wallets.push( + Button::new(h5_regular(wallet).width(Length::Fill)) + .padding(10) + .on_press(Message::Select(i)), + ); + } + let card_wallets: Element<'a, Message> = if no_wallets { + h4_bold("You have no current wallets").into() + } else { + card::simple(col_wallets).into() + }; + + let col_invitation_token = collapse::Collapse::new( + || { + Button::new( + Column::new() + .spacing(5) + .push(h4_bold("Join a shared wallet").style(color::WHITE)) + .push( + text("If you received an invitation to join a shared wallet") + .style(color::GREY_3), + ), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + || { + Button::new( + Column::new() + .spacing(5) + .push(h4_bold("Join a shared wallet").style(color::WHITE)) + .push( + text("If you received an invitation to join a shared wallet") + .style(color::GREY_3), + ), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + if let Some(wallet) = invitation_wallet { + Element::<'a, Message>::from( + Column::new() + .push(Space::with_height(0)) + .push( + Row::new() + .spacing(5) + .push(text("Accept invitation for wallet:")) + .push(text(wallet).bold()), + ) + .push( + Row::new().push(Space::with_width(Length::Fill)).push( + button::primary(None, "Accept") + .width(Length::Fixed(200.0)) + .on_press(Message::ImportRemoteWallet( + message::ImportRemoteWallet::AcceptInvitation, + )), + ), + ) + .spacing(20), + ) + } else { + Element::<'a, Message>::from( + Container::new( + Column::new() + .push(Space::with_height(0)) + .push( + Column::new() + .push(text("Paste invitation:").bold()) + .push( + form::Form::new_trimmed("Invitation", invitation, |msg| { + Message::ImportRemoteWallet( + message::ImportRemoteWallet::ImportInvitationToken( + msg, + ), + ) + }) + .warning("Invitation token is invalid or expired") + .size(text::P1_SIZE) + .padding(10), + ) + .spacing(10), + ) + .push( + Row::new().push(Space::with_width(Length::Fill)).push( + button::primary(None, "Next") + .width(Length::Fixed(200.0)) + .on_press_maybe(if !invitation.value.is_empty() { + Some(Message::ImportRemoteWallet( + message::ImportRemoteWallet::FetchInvitation, + )) + } else { + None + }), + ), + ) + .spacing(20), + ) + .padding(15), + ) + } + }, + ); + + let col_descriptor = collapse::Collapse::new( + || { + Button::new( + Column::new() + .spacing(5) + .push(h4_bold("Import a wallet from descriptor").style(color::WHITE)) + .push( + text("The remote backend will rescan the blockchain to find your coins") + .style(color::GREY_3), + ), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + || { + Button::new( + Column::new() + .spacing(5) + .push(h4_bold("Import a wallet from descriptor").style(color::WHITE)) + .push( + text("The remote backend will rescan the blockchain to find your coins") + .style(color::GREY_3), + ), + ) + .padding(15) + .width(Length::Fill) + .style(theme::Button::TransparentBorder) + }, + move || { + Element::<'a, Message>::from( + Container::new( + Column::new() + .push(Space::with_height(0)) + .push( + Column::new() + .push(text("Descriptor:").bold()) + .push( + form::Form::new_trimmed( + "Descriptor", + imported_descriptor, + |msg| { + Message::ImportRemoteWallet( + message::ImportRemoteWallet::ImportDescriptor(msg), + ) + }, + ) + .warning( + "Either descriptor is invalid or incompatible with network", + ) + .size(text::P1_SIZE) + .padding(10), + ) + .spacing(10), + ) + .push( + Row::new().push(Space::with_width(Length::Fill)).push( + button::primary(None, "Next") + .width(Length::Fixed(200.0)) + .on_press_maybe( + if imported_descriptor.value.is_empty() + || !imported_descriptor.valid + { + None + } else { + Some(Message::ImportRemoteWallet( + message::ImportRemoteWallet::ConfirmDescriptor, + )) + }, + ), + ), + ) + .spacing(20), + ) + .padding(15), + ) + }, + ); + + layout( + progress, + email, + "Add wallet", + Column::new() + .spacing(50) + .push_maybe(error.map(|e| card::error("Something wrong happened", e.to_string()))) + .push(card_wallets) + .push(card::simple(col_invitation_token).padding(0)) + .push(card::simple(col_descriptor).padding(0)), + true, + Some(Message::Previous), + ) +} + pub fn import_descriptor<'a>( progress: (usize, usize), email: Option<&'a str>, @@ -2052,7 +2266,7 @@ pub fn connection_step_enter_otp<'a>( Column::new() .spacing(20) .push(text(email).style(color::GREEN)) - .push(text("An authentication was send to you mail")) + .push(text("An authentication token has been emailed to you")) .push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE))) .push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE))) .push( @@ -2084,7 +2298,6 @@ pub fn connection_step_enter_otp<'a>( pub fn connection_step_connected<'a>( email: &'a str, processing: bool, - wallet_name: Option<&str>, connection_error: Option<&Error>, auth_error: Option<&'static str>, ) -> Element<'a, Message> { @@ -2093,51 +2306,23 @@ pub fn connection_step_connected<'a>( .push(text(email).style(color::GREEN)) .push_maybe(connection_error.map(|e| text(e.to_string()).style(color::ORANGE))) .push_maybe(auth_error.map(|e| text(e.to_string()).style(color::ORANGE))) - .push(if let Some(name) = wallet_name { - Container::new( - Column::new() - .spacing(20) - .push(text(format!("Wallet {} already exists", name))) - .push( - Row::new() - .spacing(10) - .push( - button::primary(Some(icon::previous_icon()), "Change Email") - .on_press(Message::SelectBackend( - message::SelectBackend::EditEmail, - )), - ) - .push( - button::primary(None, "Continue with existing wallet") - .on_press_maybe(if processing { - None - } else { - Some(Message::SelectBackend( - message::SelectBackend::ContinueWithRemoteBackend, - )) - }), - ), - ), - ) - } else { - Container::new( - Row::new() - .spacing(10) - .push( - button::primary(Some(icon::previous_icon()), "Change Email") - .on_press(Message::SelectBackend(message::SelectBackend::EditEmail)), - ) - .push( - button::primary(None, "Continue").on_press_maybe(if processing { - None - } else { - Some(Message::SelectBackend( - message::SelectBackend::ContinueWithRemoteBackend, - )) - }), - ), - ) - }) + .push(Container::new( + Row::new() + .spacing(10) + .push( + button::primary(Some(icon::previous_icon()), "Change Email") + .on_press(Message::SelectBackend(message::SelectBackend::EditEmail)), + ) + .push( + button::primary(None, "Continue").on_press_maybe(if processing { + None + } else { + Some(Message::SelectBackend( + message::SelectBackend::ContinueWithRemoteBackend, + )) + }), + ), + )) .into() } diff --git a/gui/src/lianalite/client/backend/api.rs b/gui/src/lianalite/client/backend/api.rs index 7a9fcfd4..04efb5e3 100644 --- a/gui/src/lianalite/client/backend/api.rs +++ b/gui/src/lianalite/client/backend/api.rs @@ -142,6 +142,21 @@ pub struct FingerprintAlias { pub alias: String, } +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WalletInvitationStatus { + Pending, + Accepted, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WalletInvitation { + pub id: String, + pub wallet_name: String, + pub wallet_id: String, + pub status: WalletInvitationStatus, +} + #[derive(Deserialize)] pub struct WalletLabels { pub labels: HashMap, @@ -320,6 +335,11 @@ pub mod payload { pub descriptor: &'a LianaDescriptor, } + #[derive(Serialize)] + pub struct CreateWalletInvitation<'a> { + pub email: &'a str, + } + #[derive(Serialize)] pub struct ImportPsbt { pub psbt: String, diff --git a/gui/src/lianalite/client/backend/mod.rs b/gui/src/lianalite/client/backend/mod.rs index b79a3e87..1dd23105 100644 --- a/gui/src/lianalite/client/backend/mod.rs +++ b/gui/src/lianalite/client/backend/mod.rs @@ -103,15 +103,19 @@ impl BackendClient { pub async fn connect_first(self) -> Result<(BackendWalletClient, api::Wallet), DaemonError> { let wallets = self.list_wallets().await?; let first = wallets.first().cloned().ok_or(DaemonError::NoAnswer)?; - Ok(( + Ok(self.connect_wallet(first)) + } + + pub fn connect_wallet(self, wallet: api::Wallet) -> (BackendWalletClient, api::Wallet) { + ( BackendWalletClient { inner: self, curve: secp256k1::Secp256k1::verification_only(), - wallet_uuid: first.id.clone(), - wallet_desc: first.descriptor.to_owned(), + wallet_uuid: wallet.id.clone(), + wallet_desc: wallet.descriptor.to_owned(), }, - first, - )) + wallet, + ) } async fn request(&self, method: Method, url: U) -> RequestBuilder { @@ -170,7 +174,7 @@ impl BackendClient { .find(|w| w.id == wallet_uuid) .ok_or(DaemonError::Http( Some(404), - "No wallet exists for this uui".to_string(), + "No wallet exists for this uuid".to_string(), ))?; let ledger_kinds = [ async_hwi::DeviceKind::Ledger.to_string(), @@ -248,6 +252,47 @@ impl BackendClient { Ok(()) } + + pub async fn get_wallet_invitation( + &self, + invitation_id: &str, + ) -> Result { + let response = self + .request( + Method::GET, + &format!("{}/v1/invitations/{}", self.url, invitation_id), + ) + .await + .send() + .await?; + if !response.status().is_success() { + return Err(DaemonError::Http( + Some(response.status().into()), + response.text().await?, + )); + } + + Ok(response.json().await?) + } + + pub async fn accept_wallet_invitation(&self, invitation_id: &str) -> Result<(), DaemonError> { + let response = self + .request( + Method::POST, + &format!("{}/v1/invitations/{}/accept", self.url, invitation_id), + ) + .await + .send() + .await?; + if !response.status().is_success() { + return Err(DaemonError::Http( + Some(response.status().into()), + response.text().await?, + )); + } + + Ok(()) + } } #[derive(Debug, Clone)] @@ -263,6 +308,10 @@ impl BackendWalletClient { &self.inner } + pub fn into_inner(self) -> BackendClient { + self.inner + } + pub fn user_id(&self) -> &str { &self.inner.user_id } @@ -1024,6 +1073,30 @@ impl Daemon for BackendWalletClient { .update_wallet_metadata(&self.wallet_uuid, fingerprint_aliases, hws) .await } + + async fn send_wallet_invitation(&self, email: &str) -> Result<(), DaemonError> { + let response = self + .inner + .request( + Method::POST, + &format!( + "{}/v1/wallets/{}/invitations", + self.inner.url, self.wallet_uuid + ), + ) + .await + .json(&api::payload::CreateWalletInvitation { email }) + .send() + .await?; + if !response.status().is_success() { + return Err(DaemonError::Http( + Some(response.status().into()), + response.text().await?, + )); + } + + Ok(()) + } } fn history_tx_from_api(value: api::Transaction, network: Network) -> HistoryTransaction {