Add invitation process to add wallet flow

This commit is contained in:
edouardparis 2024-08-07 12:15:36 +02:00
parent f7dde3d6ec
commit 5e4c04fa80
9 changed files with 609 additions and 71 deletions

View File

@ -119,6 +119,9 @@ pub trait Daemon: Debug {
&self,
labels: &HashMap<LabelItem, Option<String>>,
) -> 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).

View File

@ -30,6 +30,7 @@ pub enum Message {
Installed(Result<PathBuf, Error>),
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<api::Wallet>), Error>),
Connected(Result<context::RemoteBackend, Error>),
}
#[derive(Debug, Clone)]
pub enum ImportRemoteWallet {
RemoteWallets(Result<Vec<api::Wallet>, Error>),
ImportDescriptor(String),
ConfirmDescriptor,
ImportInvitationToken(String),
FetchInvitation,
InvitationFetched(Result<api::WalletInvitation, Error>),
AcceptInvitation,
InvitationAccepted(Result<api::Wallet, Error>),
}
#[derive(Debug, Clone)]

View File

@ -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(),

View File

@ -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<api::Wallet>,
remote_backend_is_selected: bool,
},
}
@ -65,6 +67,9 @@ impl From<ChooseBackend> for Box<dyn Step> {
}
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<Message> {
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<api::Wallet>), Error> {
) -> Result<context::RemoteBackend, Error> {
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<String>,
invitation: Option<api::WalletInvitation>,
imported_descriptor: form::Value<String>,
descriptor: Option<LianaDescriptor>,
error: Option<String>,
backend: Option<context::RemoteBackend>,
wallets: Vec<api::Wallet>,
}
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<Message> {
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<Message> {
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<Message> {
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<ImportRemoteWallet> for Box<dyn Step> {
fn from(s: ImportRemoteWallet) -> Box<dyn Step> {
Box::new(s)
}
}

View File

@ -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<Message> {

View File

@ -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;

View File

@ -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<String>,
invitation_wallet: Option<&'a str>,
imported_descriptor: &'a form::Value<String>,
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()
}

View File

@ -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<String, String>,
@ -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,

View File

@ -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<U: IntoUrl>(&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<api::WalletInvitation, DaemonError> {
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 {