diff --git a/Cargo.lock b/Cargo.lock index e778b99..1c67c3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -494,7 +494,7 @@ dependencies = [ [[package]] name = "bpro" version = "0.5.0" -source = "git+https://github.com/pandora-prime/bpro?branch=v0.5#9d151d4ba11701a9e399ef93dea74040fd773ba6" +source = "git+https://github.com/pandora-prime/bpro?branch=v0.5#b23cdd1a7e86ef4c5e0e9d8cabe16d8b2cb62f97" dependencies = [ "amplify 3.14.2", "bitcoin 0.29.2", @@ -1634,7 +1634,7 @@ dependencies = [ [[package]] name = "mycitadel-desktop" -version = "1.3.0" +version = "2.0.0" dependencies = [ "amplify 3.14.2", "bitcoin 0.29.2", @@ -2070,13 +2070,13 @@ dependencies = [ [[package]] name = "rgb-std" version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dc78835a03d99c91c1f190df971589ea195a4dce933cf00ba5efe163c082413" +source = "git+https://github.com/RGB-WG/rgb-wallet?branch=develop#ea4f5920598325579ed8a572fd5da91a4f03e356" dependencies = [ "amplify 4.0.0", "baid58", "base64 0.21.2", "bp-core", + "chrono", "commit_verify", "getrandom", "rgb-core", @@ -2810,9 +2810,9 @@ dependencies = [ [[package]] name = "urlencoding" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "version-compare" diff --git a/Cargo.toml b/Cargo.toml index 2f49ac5..88186e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mycitadel-desktop" -version = "1.3.0" +version = "2.0.0" authors = ["Dr Maxim Orlovsky "] description = "Bitcoin, Lightning and RGB wallet; part of MyCitadel software suite." repository = "https://github.com/mycitadel/mycitadel-desktop" @@ -68,3 +68,4 @@ serde = ["serde_with", "serde_yaml", [patch.crates-io] bpro = { git = "https://github.com/pandora-prime/bpro", branch = "v0.5" } +rgb-std = { git = "https://github.com/RGB-WG/rgb-wallet", branch = "develop" } diff --git a/src/model/format.rs b/src/model/format.rs new file mode 100644 index 0000000..2938f88 --- /dev/null +++ b/src/model/format.rs @@ -0,0 +1,28 @@ +// MyCitadel desktop wallet: bitcoin & RGB wallet based on GTK framework. +// +// Written in 2022 by +// Dr. Maxim Orlovsky +// +// Copyright (C) 2022 by Pandora Prime SA, Switzerland. +// +// This software is distributed without any warranty. You should have received +// a copy of the AGPL-3.0 License along with this software. If not, see +// . + +use bpro::{OnchainStatus, OnchainTxid}; + +pub trait FormatDate { + fn format_date(&self) -> String; +} + +impl FormatDate for OnchainTxid { + fn format_date(&self) -> String { + match self.status { + OnchainStatus::Blockchain(height) => self + .date_time() + .map(|dt| dt.format("%F %H:%M").to_string()) + .unwrap_or_else(|| format!("{height}")), + OnchainStatus::Mempool => s!("mempool"), + } + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index 2859820..6525ff8 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -9,6 +9,8 @@ // a copy of the AGPL-3.0 License along with this software. If not, see // . +mod format; mod ui; +pub use format::FormatDate; pub use ui::{Notification, UI}; diff --git a/src/view/wallet/asset_row/view_model.rs b/src/view/wallet/asset_row/view_model.rs index 56dbbc3..6813acd 100644 --- a/src/view/wallet/asset_row/view_model.rs +++ b/src/view/wallet/asset_row/view_model.rs @@ -15,14 +15,19 @@ use glib::subclass::prelude::*; use gtk::prelude::*; use gtk::subclass::prelude::ListModelImpl; use gtk::{gio, glib}; +use rgbstd::contract::ContractId; +use rgbstd::stl::{DivisibleAssetSpec, Timestamp}; // The actual data structure that stores our values. This is not accessible // directly from the outside. #[derive(Default)] pub struct AssetInner { - name: RefCell, - amount: RefCell, ticker: RefCell, + name: RefCell, + details: RefCell, + issue: RefCell, + amount: RefCell, + precision: RefCell, contract: RefCell, } @@ -53,10 +58,35 @@ impl ObjectImpl for AssetInner { glib::ParamFlags::READWRITE, ), glib::ParamSpecString::new( + "details", + "Details", + "Details", + None, // Default value + glib::ParamFlags::READWRITE, + ), + glib::ParamSpecString::new( + "issue", + "Issue", + "Issue", + None, // Default value + glib::ParamFlags::READWRITE, + ), + glib::ParamSpecUInt64::new( "amount", "Amount", "Amount", - None, + 0, + u64::MAX, + 0, + glib::ParamFlags::READWRITE, + ), + glib::ParamSpecUChar::new( + "precision", + "Precision", + "Precision", + 0, + u8::MAX, + 0, glib::ParamFlags::READWRITE, ), glib::ParamSpecString::new( @@ -87,12 +117,30 @@ impl ObjectImpl for AssetInner { .expect("type conformity checked by `Object::set_property`"); self.name.replace(name); } + "details" => { + let details = value + .get() + .expect("type conformity checked by `Object::set_property`"); + self.details.replace(details); + } + "issue" => { + let issue = value + .get() + .expect("type conformity checked by `Object::set_property`"); + self.issue.replace(issue); + } "amount" => { let amount = value .get() .expect("type conformity checked by `Object::set_property`"); self.amount.replace(amount); } + "precision" => { + let amount = value + .get() + .expect("type conformity checked by `Object::set_property`"); + self.precision.replace(amount); + } "ticker" => { let ticker = value .get() @@ -112,7 +160,10 @@ impl ObjectImpl for AssetInner { fn property(&self, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { match pspec.name() { "name" => self.name.borrow().to_value(), + "details" => self.details.borrow().to_value(), + "issue" => self.issue.borrow().to_value(), "amount" => self.amount.borrow().to_value(), + "precision" => self.precision.borrow().to_value(), "ticker" => self.ticker.borrow().to_value(), "contract" => self.contract.borrow().to_value(), _ => unimplemented!(), @@ -125,18 +176,50 @@ glib::wrapper! { } impl AssetInfo { + pub fn btc(testnet: bool, amount: u64) -> AssetInfo { + let (btc, bitcoin) = match testnet { + true => ("tBTC", "Test bitcoin"), + false => ("BTC", "Bitcoin"), + }; + AssetInfo::with_raw(bitcoin, btc, "", "", amount, 8, "-") + } + pub fn with( + spec: DivisibleAssetSpec, + issue: Timestamp, + amount: u64, + contract_id: ContractId, + ) -> AssetInfo { + let issue = issue + .to_local() + .map(|local| local.format("%F %H:%M").to_string()) + .unwrap_or_else(|| s!("invalid")); + Self::with_raw( + spec.name(), + spec.ticker(), + spec.details().unwrap_or_default(), + &issue, + amount, + spec.precision as u8, + &contract_id.to_string(), + ) + } + + fn with_raw( name: &str, ticker: &str, + details: &str, + issue: &str, amount: u64, precision: u8, contract_name: &str, ) -> AssetInfo { - let precision = precision as u32; - let amount = amount as f64 / 10_i32.pow(precision) as f64; glib::Object::new(&[ ("name", &name), - ("amount", &format!("{}", amount)), + ("details", &details), + ("issue", &issue), + ("amount", &amount), + ("precision", &precision), ("ticker", &ticker), ("contract", &contract_name), ]) @@ -144,11 +227,17 @@ impl AssetInfo { pub fn name(&self) -> String { self.property::("name") } + pub fn details(&self) -> String { self.property::("details") } + + pub fn issue(&self) -> String { self.property::("issue") } + pub fn ticker(&self) -> String { self.property::("ticker") } pub fn contract_name(&self) -> String { self.property::("contract") } - pub fn amount(&self) -> String { self.property::("amount") } + pub fn amount(&self) -> u64 { self.property::("amount") } + + pub fn precision(&self) -> u8 { self.property::("precision") } } #[derive(Debug, Default)] diff --git a/src/view/wallet/asset_row/widget.rs b/src/view/wallet/asset_row/widget.rs index 44ab2ae..c8ebbf9 100644 --- a/src/view/wallet/asset_row/widget.rs +++ b/src/view/wallet/asset_row/widget.rs @@ -57,6 +57,17 @@ impl RowWidgets { asset .bind_property("amount", &self.amount_lbl, "label") + .transform_to(|binding, value: u64| { + let precision = binding.source().unwrap().property::("precision"); + let pow = 10u64.pow(precision as u32); + let int = value / pow; + let mut fract = (value - int * pow) as f64 / pow as f64; + if fract > 0.0 && fract < 0.01 { + fract = 0.01; + } + let fract = format!("{:.2}", fract); + Some(format!("{int}.{}", fract.trim_start_matches("0."))) + }) .flags(flags) .build(); } diff --git a/src/view/wallet/component.rs b/src/view/wallet/component.rs index e108e17..45c8fd0 100644 --- a/src/view/wallet/component.rs +++ b/src/view/wallet/component.rs @@ -33,6 +33,7 @@ use wallet::lex_order::lex_order::LexOrder; use super::pay::beneficiary_row::Beneficiary; use super::pay::FeeRate; use super::{pay, ElectrumState, Msg, ViewModel, Widgets}; +use crate::view::wallet::payto; use crate::view::{error_dlg, launch, settings, NotificationBoxExt}; use crate::worker::{electrum, exchange, ElectrumWorker, ExchangeWorker}; @@ -40,6 +41,7 @@ pub struct Component { model: ViewModel, widgets: Widgets, pay_widgets: pay::Widgets, + payto_widgets: payto::Widgets, exchange_channel: Channel, exchange_worker: ExchangeWorker, @@ -280,12 +282,8 @@ impl Component { .update_electrum_state(ElectrumState::RetrievingHistory(no as usize * 2 + 1)); let wallet = self.model.wallet_mut(); wallet.update_utxos(batch); - self.widgets.update_utxos(&wallet.utxos()); - self.widgets.update_state( - wallet.state(), - wallet.tx_count(), - self.model.exchange_rate, - ); + self.widgets.update_outpoints(&mut self.model); + self.widgets.update_balance(&mut self.model); } electrum::Msg::TxBatch(batch, progress) => { self.widgets @@ -300,11 +298,9 @@ impl Component { self.tx_buffer.clear(); self.save(); - let exchange_rate = self.model.exchange_rate; + self.widgets.update_balance(&mut self.model); let wallet = self.model.wallet_mut(); self.widgets.update_history(&wallet.history()); - self.widgets - .update_state(wallet.state(), wallet.tx_count(), exchange_rate); self.widgets.update_addresses(&wallet.address_info(true)); self.widgets.update_electrum_state(ElectrumState::Complete( self.model.as_settings().electrum().sec, @@ -369,6 +365,11 @@ impl Update for Component { self.save(); } } + Msg::ChangeAsset(index) => { + let success = self.model.change_asset(index); + debug_assert!(success, "invalid index selection"); + self.widgets.update_ui(&mut self.model); + } Msg::Close => self.close(), Msg::About => { self.launcher_stream @@ -376,6 +377,7 @@ impl Update for Component { .map(|stream| stream.emit(launch::Msg::About)); } Msg::Pay(msg) => self.update_pay(msg), + Msg::PayTo(msg) => self.update_payto(msg), Msg::Settings => self.settings.emit(settings::Msg::View( self.model.to_settings(), self.model.path().clone(), @@ -539,6 +541,47 @@ impl Component { } } +impl Component { + fn update_payto(&mut self, event: payto::Msg) { + match event { + payto::Msg::Show => { + self.payto_widgets.init_ui(&self.model); + self.payto_widgets.show(); + } + payto::Msg::Response(ResponseType::Ok) => { + let (psbt, change_index) = match self.sync_pay() { + Some(data) => data, + None => return, + }; + self.payto_widgets.hide(); + self.launcher_stream.as_ref().map(|stream| { + stream.emit(launch::Msg::CreatePsbt( + psbt, + self.model.as_settings().network(), + )) + }); + // Update latest change index in wallet settings by sending message to the wallet + // component + if self + .model + .wallet_mut() + .update_next_change_index(change_index) + { + self.save(); + } + } + payto::Msg::Response(ResponseType::Cancel) => { + self.payto_widgets.hide(); + } + payto::Msg::Response(ResponseType::Other(1000)) => { + self.payto_widgets.hide(); + self.update_pay(pay::Msg::Show); + } + _ => {} // Changes which update wallet tx + } + } +} + impl Widget for Component { // Specify the type of the root widget. type Root = ApplicationWindow; @@ -546,9 +589,9 @@ impl Widget for Component { // Return the root widget. fn root(&self) -> Self::Root { self.widgets.to_root() } - fn view(relm: &Relm, model: Self::Model) -> Self { + fn view(relm: &Relm, mut model: Self::Model) -> Self { let glade_src = include_str!("wallet.glade"); - let widgets = Widgets::from_string(glade_src).expect("glade file broken"); + let mut widgets = Widgets::from_string(glade_src).expect("glade file broken"); let settings = init::(()).expect("error in settings component"); settings.emit(settings::Msg::SetWallet(relm.stream().clone())); @@ -567,15 +610,20 @@ impl Widget for Component { widgets.connect(relm); widgets.init_ui(&model); + widgets.update_ui(&mut model); widgets.show(); let glade_src = include_str!("pay/pay.glade"); let pay_widgets = pay::Widgets::from_string(glade_src).expect("glade file broken"); - pay_widgets.connect(relm); pay_widgets.bind_beneficiary_model(relm, &model); pay_widgets.init_ui(&model); + let glade_src = include_str!("payto/payto.glade"); + let payto_widgets = payto::Widgets::from_string(glade_src).expect("glade file broken"); + payto_widgets.connect(relm); + payto_widgets.init_ui(&model); + electrum_worker.sync(); // TODO: remove the panic and allow user to fix resolver settings @@ -587,6 +635,7 @@ impl Widget for Component { model, widgets, pay_widgets, + payto_widgets, settings, exchange_channel, diff --git a/src/view/wallet/mod.rs b/src/view/wallet/mod.rs index 96c94f8..7a3bd45 100644 --- a/src/view/wallet/mod.rs +++ b/src/view/wallet/mod.rs @@ -12,6 +12,7 @@ mod asset_row; mod component; mod pay; +mod payto; mod view_model; mod widget; @@ -41,8 +42,10 @@ pub enum Msg { Settings, Update(Vec, BTreeSet, ElectrumServer), Pay(pay::Msg), + PayTo(payto::Msg), Fiat(Fiat), Refresh, + ChangeAsset(u32), InvoiceAmountToggle(bool), InvoiceIndexToggle(bool), InvoiceAmount(f64), diff --git a/src/view/wallet/payto/mod.rs b/src/view/wallet/payto/mod.rs new file mode 100644 index 0000000..f6bda42 --- /dev/null +++ b/src/view/wallet/payto/mod.rs @@ -0,0 +1,22 @@ +// MyCitadel desktop wallet: bitcoin & RGB wallet based on GTK framework. +// +// Written in 2022 by +// Dr. Maxim Orlovsky +// +// Copyright (C) 2022 by Pandora Prime SA, Switzerland. +// +// This software is distributed without any warranty. You should have received +// a copy of the AGPL-3.0 License along with this software. If not, see +// . + +mod widget; + +use gtk::ResponseType; +pub use widget::Widgets; + +#[derive(Msg)] +pub enum Msg { + Show, + Advanced, + Response(ResponseType), +} diff --git a/src/view/wallet/payto/payto.glade b/src/view/wallet/payto/payto.glade new file mode 100644 index 0000000..604695c --- /dev/null +++ b/src/view/wallet/payto/payto.glade @@ -0,0 +1,258 @@ + + + + + + False + center + dialog + + cancel_btn + compose_btn + batch_btn + + + + False + vertical + + + False + end + + + + + + False + False + 0 + + + + + + True + False + 13 + 13 + 13 + 13 + 6 + 6 + + + True + False + start + baseline + Address or invoice: + + + 0 + 0 + + + + + True + True + True + 66 + 66 + True + edit-paste-symbolic + GTK_INPUT_HINT_NO_SPELLCHECK | GTK_INPUT_HINT_NO_EMOJI | GTK_INPUT_HINT_NONE + + + 0 + 1 + + + + + True + False + 6 + + + True + True + 10 + True + edit-paste-symbolic + number + GTK_INPUT_HINT_NO_SPELLCHECK | GTK_INPUT_HINT_NO_EMOJI | GTK_INPUT_HINT_NONE + + + False + True + 0 + + + + + MAX + True + True + True + + + False + True + 1 + + + + + True + False + start + BTC + + + False + True + 2 + + + + + 1 + 1 + + + + + True + False + start + baseline + Amount to pay: + + + 1 + 0 + + + + + False + True + 0 + + + + + True + False + warning + + + False + 6 + end + + + + + + False + False + 0 + + + + + False + 16 + + + True + False + dialog-warning-symbolic + + + False + True + 0 + + + + + True + False + Invalid invoice + + + False + True + 1 + + + + + False + False + 0 + + + + + False + True + 2 + + + + + + + True + False + Pay to + False + + + Cancel + True + True + True + + + + + Compose + True + True + True + True + + + + end + 1 + + + + + Advanced... + True + True + True + True + + + + end + 2 + + + + + + diff --git a/src/view/wallet/payto/widget.rs b/src/view/wallet/payto/widget.rs new file mode 100644 index 0000000..3938717 --- /dev/null +++ b/src/view/wallet/payto/widget.rs @@ -0,0 +1,69 @@ +// MyCitadel desktop wallet: bitcoin & RGB wallet based on GTK framework. +// +// Written in 2022 by +// Dr. Maxim Orlovsky +// +// Copyright (C) 2022 by Pandora Prime SA, Switzerland. +// +// This software is distributed without any warranty. You should have received +// a copy of the AGPL-3.0 License along with this software. If not, see +// . + +use gladis::Gladis; +use gtk::prelude::*; +use gtk::{Button, Dialog, Entry, HeaderBar, InfoBar, Label, ResponseType, ToggleButton}; +use relm::Relm; + +use super::Msg; +use crate::view::wallet; + +// Create the structure that holds the widgets used in the view. +#[derive(Clone, Gladis)] +pub struct Widgets { + dialog: Dialog, + header_bar: HeaderBar, + + info_bar: InfoBar, + info_lbl: Label, + + cancel_btn: Button, + compose_btn: Button, + batch_btn: Button, + + invoice_fld: Entry, + amount_fld: Entry, + max_btn: ToggleButton, + asset_lbl: Label, +} + +impl Widgets { + pub fn init_ui(&self, model: &wallet::ViewModel) { + self.header_bar.set_subtitle(Some(&format!( + "{:.08} BTC available", + model.wallet().state().balance as f64 / 100_000_000.0 + ))); + } + + pub fn show(&self) { self.dialog.show() } + pub fn hide(&self) { self.dialog.hide() } + + pub fn to_root(&self) -> Dialog { self.dialog.clone() } + pub fn as_root(&self) -> &Dialog { &self.dialog } + + pub fn connect(&self, relm: &Relm) { + connect!( + relm, + self.dialog, + connect_response(_, resp), + wallet::Msg::PayTo(Msg::Response(resp)) + ); + self.dialog.set_response_sensitive(ResponseType::Ok, true); + + connect!( + relm, + self.dialog, + connect_delete_event(_, _), + return (None, Inhibit(true)) + ); + } +} diff --git a/src/view/wallet/view_model.rs b/src/view/wallet/view_model.rs index 70a640b..2533ec0 100644 --- a/src/view/wallet/view_model.rs +++ b/src/view/wallet/view_model.rs @@ -15,10 +15,13 @@ use std::path::PathBuf; use std::str::FromStr; use bpro::{file, DescriptorError, ElectrumServer, FileDocument, Signer, Wallet, WalletSettings}; +use gtk::glib::ObjectExt; +use gtk::prelude::ListModelExt; use rgb::BlockchainResolver; use rgbstd::containers::{Bindle, BindleParseError, Contract}; use rgbstd::contract::ContractId; use rgbstd::interface::rgb20::Rgb20; +use rgbstd::interface::FungibleAllocation; use rgbstd::persistence::{Inventory, InventoryError}; use rgbstd::validation; use wallet::descriptors::DescriptorClass; @@ -41,6 +44,8 @@ pub struct ViewModel { path: PathBuf, + asset: Option, + #[getter(as_mut)] beneficiaries: BeneficiaryModel, @@ -81,13 +86,11 @@ pub enum RgbImportError { impl ViewModel { pub fn with(mut wallet: Wallet, path: PathBuf) -> ViewModel { - let (btc, bitcoin) = match wallet.as_settings().network().is_testnet() { - true => ("tBTC", "Test bitcoin"), - false => ("BTC", "Bitcoin"), - }; - let btc_asset = AssetInfo::with(bitcoin, btc, wallet.state().balance, 8, "-"); let asset_model = AssetModel::new(); - asset_model.append(&btc_asset); + asset_model.append(&AssetInfo::btc( + *wallet.as_settings().testnet(), + wallet.state().balance, + )); if let Some(rgb_controller) = wallet.rgb_mut() { for iface in rgb_controller .contracts_with_iface("RGB20") @@ -96,11 +99,10 @@ impl ViewModel { let iface = Rgb20::from(iface); let spec = iface.spec(); asset_model.append(&AssetInfo::with( - spec.name(), - spec.ticker(), - 0, - spec.precision.into(), - &iface.contract_id().to_string(), + spec, + iface.created(), + iface.balance(&mut wallet), + iface.contract_id(), )); } } @@ -109,6 +111,7 @@ impl ViewModel { fee_rate: wallet.ephemerals().fees.0 * 100_000_000.0, // TODO: Update on window opening wallet, path, + asset: None, beneficiaries: BeneficiaryModel::new(), invoice: none!(), exchange: Exchange::Kraken, @@ -126,6 +129,16 @@ impl ViewModel { pub fn as_invoice(&self) -> &InvoiceModel { &self.invoice } pub fn as_invoice_mut(&mut self) -> &mut InvoiceModel { &mut self.invoice } + pub fn asset_info(&mut self) -> AssetInfo { + match self.asset { + None => AssetInfo::btc( + *self.wallet.as_settings().testnet(), + self.wallet.state().balance, + ), + Some(contract_id) => self.asset_info_for(contract_id), + } + } + pub fn set_fee_rate(&mut self, fee_rate: f32) { self.fee_rate = fee_rate; } pub fn update_descriptor( @@ -146,37 +159,73 @@ impl ViewModel { }) } + pub fn change_asset(&mut self, index: u32) -> bool { + let Some(asset) = self.asset_model().item(index) else { + return false; + }; + let id = asset.property::("contract"); + match id.as_str() { + "-" => self.asset = None, + id => { + let id = ContractId::from_str(id).expect("invalid RGB contract"); + self.asset = Some(id); + } + } + true + } + pub fn import_rgb_contract( &mut self, text: String, resolver: &mut BlockchainResolver, ) -> Result { - let contract = Bindle::::from_str(&text)?; - let id = contract.id(); - let rgb = self .wallet .rgb_mut() .expect("calling RGB-specific method on non-RGB-enabled wallet"); + let contract = Bindle::::from_str(&text)?; + let id = contract.id(); + let contract = contract.unbindle().validate(resolver).map_err(|c| { RgbImportError::InvalidContract(c.validation_status().expect("validated").clone()) })?; let status = rgb.import_contract(contract, resolver)?; + let info = self.asset_info_for(id); + self.asset_model.append(&info); + + Ok(status) + } + + fn asset_for(&mut self, id: ContractId) -> Rgb20 { + let rgb = self + .wallet + .rgb_mut() + .expect("calling RGB-specific method on non-RGB-enabled wallet"); + let iface = rgb .contract_iface_named(id, "RGB20") - .map_err(|_| RgbImportError::NotRgb20)?; - let iface = Rgb20::from(iface); - let spec = iface.spec(); - self.asset_model.append(&AssetInfo::with( - spec.name(), - spec.ticker(), - 0, - spec.precision.into(), - &iface.contract_id().to_string(), - )); + .expect("Not an RGB20 contract"); + Rgb20::from(iface) + } - Ok(status) + pub fn asset_allocations(&mut self) -> Vec { + let Some(id) = self.asset else { + return vec![]; + }; + let iface = self.asset_for(id); + iface.allocations(&self.wallet).into_inner() + } + + fn asset_info_for(&mut self, id: ContractId) -> AssetInfo { + let iface = self.asset_for(id); + let spec = iface.spec(); + AssetInfo::with( + spec, + iface.created(), + iface.balance(&self.wallet), + iface.contract_id(), + ) } } diff --git a/src/view/wallet/wallet.glade b/src/view/wallet/wallet.glade index c274272..50995e1 100644 --- a/src/view/wallet/wallet.glade +++ b/src/view/wallet/wallet.glade @@ -1,5 +1,5 @@ - + @@ -322,7 +322,6 @@ Use custom address no: - True True False True @@ -350,7 +349,6 @@ - True False True 0 @@ -373,7 +371,7 @@ - + True False BTC @@ -643,7 +641,7 @@ - + True False @@ -658,13 +656,14 @@ True False - center + end 6 6 + False 12 1 - + True False start @@ -687,7 +686,7 @@ start baseline - + True False baseline @@ -739,7 +738,7 @@ - + True False baseline @@ -764,7 +763,7 @@ - + True False @@ -774,7 +773,7 @@ - + True False start @@ -855,7 +854,7 @@ - + True False @@ -866,7 +865,7 @@ - + True False start @@ -920,7 +919,7 @@ - + True False start @@ -940,6 +939,8 @@ True False + end + False 6 @@ -979,7 +980,7 @@ - + True False start @@ -1078,7 +1079,7 @@ - + True True True diff --git a/src/view/wallet/widget.rs b/src/view/wallet/widget.rs index 8d6f464..94463ef 100644 --- a/src/view/wallet/widget.rs +++ b/src/view/wallet/widget.rs @@ -13,7 +13,8 @@ use std::collections::BTreeSet; use std::ffi::OsStr; use bpro::{ - AddressSummary, ElectrumSec, ElectrumServer, HistoryEntry, OnchainStatus, UtxoTxid, WalletState, + AddressSummary, ElectrumSec, ElectrumServer, HistoryEntry, OnchainStatus, OnchainTxid, + UtxoTxid, WalletState, }; use chrono::{DateTime, NaiveDateTime, Utc}; use electrum_client::HeaderNotification; @@ -22,15 +23,17 @@ use gtk::gdk_pixbuf::Pixbuf; use gtk::prelude::*; use gtk::{ gdk, Adjustment, ApplicationWindow, Button, CheckButton, Entry, HeaderBar, Image, Label, - ListBox, ListStore, Menu, MenuItem, Popover, RadioMenuItem, SortColumn, SortType, SpinButton, - Spinner, Statusbar, TextBuffer, TreeView, + ListBox, ListStore, Menu, MenuItem, Notebook, Popover, RadioMenuItem, Separator, SortColumn, + SortType, SpinButton, Spinner, Statusbar, TextBuffer, TreeView, }; use relm::Relm; +use rgb::contract::SealWitness; +use rgbstd::interface::FungibleAllocation; use wallet::hd::SegmentIndexes; use super::asset_row::{self, AssetModel}; -use super::{pay, ElectrumState, Msg, ViewModel}; -use crate::model::UI as UIColorTrait; +use super::{payto, ElectrumState, Msg, ViewModel}; +use crate::model::{FormatDate, UI as UIColorTrait}; use crate::view::{launch, APP_ICON, APP_ICON_TOOL}; use crate::worker::exchange::{Exchange, Fiat}; @@ -82,11 +85,22 @@ pub struct Widgets { contract_box: gtk::Box, contract_entry: Entry, + b_lbl: Label, + s_lbl: Label, balance_btc_lbl: Label, balance_sat_lbl: Label, balance_fiat_lbl: Label, balance_cents_lbl: Label, + fiat_box: gtk::Box, + price_box: gtk::Box, + value_lbl: Label, + fiat_lbl: Label, + price_lbl: Label, + sep0: Separator, + sep1: Separator, + sep2: Separator, + exchange_lbl: Label, fiat_usd: RadioMenuItem, fiat_eur: RadioMenuItem, @@ -98,6 +112,7 @@ pub struct Widgets { refresh_spin: Spinner, refresh_img: Image, + notebook: Notebook, history_store: ListStore, utxo_store: ListStore, address_store: ListStore, @@ -165,7 +180,7 @@ impl Widgets { relm, self.pay_btn, connect_clicked(_), - Msg::Pay(pay::Msg::Show) + Msg::PayTo(payto::Msg::Show) ); connect!(relm, self.refresh_btn, connect_clicked(_), Msg::Refresh); connect!(relm, self.redefine_mi, connect_activate(_), Msg::Duplicate); @@ -179,6 +194,13 @@ impl Widgets { ); connect!(relm, self.about_mi, connect_activate(_), Msg::About); + connect!( + relm, + self.asset_list, + connect_row_activated(_, row), + Msg::ChangeAsset(row.index() as u32) + ); + let menu = self.history_menu.clone(); self.history_list .connect_button_release_event(move |me, event| { @@ -385,7 +407,7 @@ impl Widgets { ); } - pub fn init_ui(&self, model: &ViewModel) { + pub fn init_ui(&mut self, model: &ViewModel) { let settings = model.as_settings(); self.assets_box.set_visible(settings.is_rgb()); @@ -409,10 +431,6 @@ impl Widgets { self.fiat_eur.set_active(model.fiat == Fiat::EUR); self.fiat_chf.set_active(model.fiat == Fiat::CHF); - if *settings.testnet() { - self.ticker_lbl.set_text("tBTC"); - self.asset_lbl.set_text("Test bitcoins"); - } if !settings.is_rgb() { self.contract_box.set_visible(false); } @@ -429,6 +447,32 @@ impl Widgets { self.update_invoice(model); } + pub fn update_ui(&mut self, model: &mut ViewModel) { + let info = model.asset_info(); + self.ticker_lbl.set_text(&info.ticker()); + self.asset_lbl.set_text(&info.name()); + self.contract_entry.set_text(&info.contract_name()); + + let is_asset = model.asset().is_some(); + self.contract_box.set_visible(is_asset); + self.value_lbl.set_visible(!is_asset); + self.b_lbl.set_visible(!is_asset); + self.s_lbl.set_visible(!is_asset); + self.fiat_box.set_visible(!is_asset); + self.fiat_lbl.set_visible(!is_asset); + self.price_box.set_visible(!is_asset); + self.price_lbl.set_visible(!is_asset); + self.sep0.set_visible(!is_asset); + self.sep1.set_visible(!is_asset); + self.sep2.set_visible(!is_asset); + + self.notebook.set_page(if is_asset { 1 } else { 0 }); + self.notebook.set_show_tabs(!is_asset); + + self.update_outpoints(model); + self.update_balance(model); + } + fn bind_asset_model(&self, model: &AssetModel) { self.asset_list .bind_model(Some(model), move |item| asset_row::RowWidgets::init(item)); @@ -560,23 +604,69 @@ impl Widgets { } } + pub fn update_outpoints(&mut self, model: &mut ViewModel) { + match model.asset() { + None => { + self.update_utxos(model.wallet().utxos()); + } + Some(_) => { + let info = model.asset_info(); + let allocations = model.asset_allocations(); + let rgb = model.wallet().rgb().unwrap(); + self.update_allocations( + allocations, + info.precision(), + &info.issue(), + rgb.witness_txes(), + ); + } + } + } + + pub fn update_allocations( + &mut self, + allocations: Vec, + precision: u8, + issue: &str, + witness_txes: &BTreeSet, + ) { + let pow = 10u64.pow(precision as u32); + self.utxo_store.clear(); + for allocation in allocations { + let int = allocation.value / pow; + let fract = allocation.value - int * pow; + let date = match allocation.witness { + SealWitness::Genesis => issue.to_string(), + SealWitness::Present(txid) => witness_txes + .iter() + .find(|info| info.txid.as_ref() == txid.as_ref().as_slice()) + .map(OnchainTxid::format_date) + .unwrap_or_else(|| s!("unknown")), + SealWitness::Extension => s!("issue"), + }; + self.utxo_store.insert_with_values(None, &[ + (0, &""), + (1, &allocation.owner.to_string()), + ( + 2, + &format!("{int}.{fract}") + .trim_end_matches('0') + .trim_end_matches('.'), + ), + (3, &date), + (4, &0u32), + ]); + } + } + pub fn update_utxos(&mut self, utxos: &BTreeSet) { self.utxo_store.clear(); for item in utxos { - let btc = format_btc_value(item.value); - let date = match item.onchain.status { - OnchainStatus::Blockchain(height) => item - .onchain - .date_time() - .map(|dt| dt.format("%F %H:%M").to_string()) - .unwrap_or_else(|| format!("{height}")), - OnchainStatus::Mempool => s!("mempool"), - }; self.utxo_store.insert_with_values(None, &[ (0, &item.addr_src.address.to_string()), (1, &format!("{}:{}", item.onchain.txid, item.vout)), - (2, &btc), - (3, &date), + (2, &format_btc_value(item.value)), + (3, &item.onchain.format_date()), (4, &item.onchain.status.into_u32()), ]); } @@ -610,12 +700,34 @@ impl Widgets { } } - pub fn update_state(&self, state: WalletState, _tx_count: usize, exchange_rate: f64) { - self.balance_lbl - .set_text(&format!("{} sat", state.balance.to_string())); - self.balance_btc_lbl - .set_text(&format!("{}.", state.balance_btc() as u64)); - self.balance_sat_lbl.set_text(&state.balance.to_string()); + pub fn update_balance(&self, model: &mut ViewModel) { + let wallet = model.wallet(); + let state = wallet.state(); + let exchange_rate = model.exchange_rate; + + let asset = model.asset_info(); + let precision: u8 = asset.property("precision"); + let balance: u64 = asset.property("amount"); + let pow = 10u64.pow(precision as u32); + let int = balance / pow; + let fract = balance - int * pow; + let remain = format!("{fract}").trim_end_matches('0').to_string(); + let zeros = precision as usize - remain.len(); + + let main = if int == 0 { + self.balance_btc_lbl + .set_text(&format!("0.{:01$}", "", zeros)); + remain + } else if fract != 0 { + self.balance_btc_lbl.set_text(""); + format!("{}.{}", int, remain) + } else { + self.balance_btc_lbl.set_text(""); + format!("{}", int) + }; + self.balance_sat_lbl.set_text(&main); + + self.balance_lbl.set_text(&format!("{} sat", state.balance)); /* self.volume_btc_lbl