Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67bae582a4
|
||
|
|
9757f907d6
|
||
|
|
caff8a6be6
|
||
|
|
20f171be96
|
||
|
|
102f659900
|
||
|
|
700850c75e
|
@@ -95,3 +95,32 @@ jobs:
|
||||
# `cargo test` regenerates ui/src/api/generated via ts-rs. A diff here
|
||||
# means an entities change was committed without its TypeScript.
|
||||
run: git diff --exit-code -- ui/src/api/generated
|
||||
|
||||
- name: debug binary
|
||||
# The app as a bare binary, so a change can be tried on a machine
|
||||
# before any release is cut (#87). The dev profile, which reuses
|
||||
# everything `cargo test` just compiled, so this costs seconds, not
|
||||
# the release profile's minutes; `tauri build` (not plain `cargo
|
||||
# build`) so the webview the gate built is embedded rather than
|
||||
# loaded from a dev server. It wants the GTK/WebKitGTK libraries the
|
||||
# rpm depends on. Last, so a red gate uploads nothing to mistake for
|
||||
# tested.
|
||||
working-directory: crates/wallet-app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pnpm dlx @tauri-apps/cli@2.11.4 build --debug --no-bundle
|
||||
mkdir -p ../../artifact
|
||||
install -m 0755 ../../target/debug/blackbeard-wallet \
|
||||
"../../artifact/blackbeard-wallet-${GITHUB_SHA::7}-x86_64-linux-gnu-debug"
|
||||
# Debug info is four fifths of the file (498 MB against 93 MB
|
||||
# stripped) and nothing a test on another machine uses.
|
||||
strip --strip-debug ../../artifact/blackbeard-wallet-*
|
||||
(cd ../../artifact && sha256sum * > SHA256SUMS)
|
||||
ls -l ../../artifact
|
||||
|
||||
- name: upload the binary
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: blackbeard-wallet-${{ github.sha }}-x86_64-linux-gnu-debug
|
||||
path: artifact/
|
||||
retention-days: 7
|
||||
|
||||
@@ -13,6 +13,8 @@ use wallet_core::{ChainAdapter, SignedBy, TransferIntent, TxStatus};
|
||||
use wallet_data::substrate::SubstrateAdapter;
|
||||
use wallet_entities::Settings;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use tauri::State;
|
||||
use wallet_core::backup::BackupChallenge;
|
||||
use wallet_core::session::SessionError;
|
||||
@@ -23,9 +25,10 @@ use wallet_data::mnemonic;
|
||||
use wallet_data::substrate::{ChainManager, account_info};
|
||||
use wallet_entities::{
|
||||
AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, CashoutDestination,
|
||||
ChainId, ChainProfile, ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage,
|
||||
IntentsSwapQuote, NearAccountSummary, OpenWalletInfo, PendingReversible, PreparedTransferInfo,
|
||||
SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, UnlockOutcome, WormholeSummary,
|
||||
ChainId, ChainProfile, ChainStatus, Dashboard, DashboardChain, DashboardHolding,
|
||||
HighSecurityInfo, HistoryCursor, HistoryPage, IntentsSwapQuote, NearAccountSummary,
|
||||
OpenWalletInfo, PendingReversible, PreparedTransferInfo, SessionAccountInfo, SessionStatusInfo,
|
||||
TxStage, TxStatusInfo, UnlockOutcome, WormholeSummary,
|
||||
};
|
||||
use wallet_entities::{AppInfo, CreationChallenge, CreationStart, WalletError, WalletSummary};
|
||||
use zeroize::Zeroizing;
|
||||
@@ -607,6 +610,181 @@ pub fn wallet_add_account(
|
||||
session_status(state)
|
||||
}
|
||||
|
||||
/// Remove a wallet that holds nothing (#86): its file is deleted, it leaves
|
||||
/// the session, and its account numbers and names leave the settings.
|
||||
/// "Nothing" is established here, not in the webview: zero on every
|
||||
/// mainnet chain's accounts, no reversible transfer pending to or from
|
||||
/// them, no unspent wormhole leaf with the scan current, and not the
|
||||
/// wallet whose key controls an intents account that holds anything. A
|
||||
/// chain that cannot be read refuses the removal rather than assuming.
|
||||
#[tauri::command]
|
||||
pub async fn wallet_remove(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
) -> Result<SessionStatusInfo, WalletError> {
|
||||
use wallet_core::near::INTENTS;
|
||||
use wallet_data::keys::wormhole::WormholeKeys;
|
||||
use wallet_data::substrate::wormhole as zk;
|
||||
use wallet_entities::ChainKind;
|
||||
|
||||
let status = state
|
||||
.session
|
||||
.status(Instant::now())
|
||||
.map_err(session_error)?;
|
||||
let wallet = status
|
||||
.wallets
|
||||
.iter()
|
||||
.find(|w| w.summary.name == name)
|
||||
.ok_or_else(|| {
|
||||
WalletError::Refused(format!(
|
||||
"{name} is not open; only an open wallet can be checked and removed"
|
||||
))
|
||||
})?;
|
||||
let account_ids: Vec<[u8; 32]> = wallet.accounts.iter().map(|a| a.account_id).collect();
|
||||
let refuse = |why: String| Err(WalletError::Refused(format!("{name} is not empty: {why}")));
|
||||
|
||||
for profile in state
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|p| p.kind == ChainKind::Mainnet)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
let addresses: std::collections::HashSet<String> = account_ids
|
||||
.iter()
|
||||
.map(|id| ss58::encode(profile.ss58_prefix, id))
|
||||
.collect();
|
||||
let balances = read_balances(&state, &profile.id).await.map_err(|e| {
|
||||
WalletError::Refused(format!(
|
||||
"{} could not be read, so {name} cannot be shown empty: {e}",
|
||||
profile.display_name
|
||||
))
|
||||
})?;
|
||||
for b in balances.iter().filter(|b| addresses.contains(&b.address)) {
|
||||
if b.total.raw > 0 {
|
||||
return refuse(format!(
|
||||
"{} holds {} {}",
|
||||
b.address,
|
||||
b.total.to_decimal_string(),
|
||||
b.symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
let adapter = adapter_for(&state, &profile.id)?;
|
||||
for id in &account_ids {
|
||||
let pending = zk::under_rate_limit(|| adapter.pending_reversible(*id))
|
||||
.await
|
||||
.map_err(chain_error)?;
|
||||
if !pending.is_empty() {
|
||||
return refuse(format!(
|
||||
"{} has {} reversible transfer(s) still pending",
|
||||
ss58::encode(profile.ss58_prefix, id),
|
||||
pending.len()
|
||||
));
|
||||
}
|
||||
}
|
||||
if wallet.has_seed {
|
||||
let keys = state
|
||||
.session
|
||||
.with_seed(&name, |seed| WormholeKeys::derive(seed, 0))
|
||||
.map_err(session_error)?
|
||||
.ok_or_else(|| WalletError::Internal("phrase missing".into()))?
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
let address = keys.address(profile.ss58_prefix);
|
||||
let conn = adapter_for(&state, &profile.id)?
|
||||
.manager()
|
||||
.connection()
|
||||
.map_err(chain_error)?;
|
||||
let leaf_count = zk::under_rate_limit(|| zk::leaf_count(&conn))
|
||||
.await
|
||||
.map_err(chain_error)?;
|
||||
let scanned_to = state
|
||||
.shielded_cache
|
||||
.scanned_to(&profile.id, &address)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
if scanned_to < leaf_count {
|
||||
return refuse(format!(
|
||||
"its wormhole scan on {} is behind ({scanned_to} of {leaf_count} leaves); open the accounts page and let it finish",
|
||||
profile.display_name
|
||||
));
|
||||
}
|
||||
let shielded: u128 = state
|
||||
.shielded_cache
|
||||
.leaves(&profile.id, &address)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?
|
||||
.iter()
|
||||
.filter(|l| !l.spent)
|
||||
.map(|l| l.amount)
|
||||
.sum();
|
||||
if shielded > 0 {
|
||||
return refuse(format!(
|
||||
"its wormhole address holds {} {} shielded",
|
||||
Amount::new(shielded, profile.token_decimals).to_decimal_string(),
|
||||
profile.token_symbol
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The intents account's owner: refused while the account holds
|
||||
// anything; otherwise the record goes with the wallet.
|
||||
let mut settings = state
|
||||
.settings
|
||||
.load()
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
if settings
|
||||
.intents_account
|
||||
.as_ref()
|
||||
.is_some_and(|r| r.wallet == name)
|
||||
{
|
||||
let rec = settings.intents_account.clone().expect("checked");
|
||||
let tokens: Vec<String> = state
|
||||
.assets
|
||||
.iter()
|
||||
.filter_map(|e| e.intents.clone())
|
||||
.filter(|t| t.starts_with("nep141:"))
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let raw = state
|
||||
.near
|
||||
.view(
|
||||
INTENTS,
|
||||
"mt_batch_balance_of",
|
||||
&serde_json::json!({ "account_id": rec.account_id, "token_ids": tokens }),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
WalletError::Refused(format!("its intents account could not be read: {e}"))
|
||||
})?;
|
||||
let raw: Vec<String> = serde_json::from_value(raw).unwrap_or_default();
|
||||
if raw.iter().any(|r| r.parse::<u128>().unwrap_or(0) > 0) {
|
||||
return refuse(format!(
|
||||
"its key controls the intents account {}, which holds tokens; move them out first",
|
||||
rec.account_id
|
||||
));
|
||||
}
|
||||
settings.intents_account = None;
|
||||
}
|
||||
|
||||
state.keystore.remove(&name).map_err(keystore_error)?;
|
||||
state.session.forget(&name).map_err(session_error)?;
|
||||
settings.wallet_accounts.remove(&name);
|
||||
for profile in state.profiles.iter() {
|
||||
for id in &account_ids {
|
||||
settings
|
||||
.account_names
|
||||
.remove(&ss58::encode(profile.ss58_prefix, id));
|
||||
}
|
||||
}
|
||||
state
|
||||
.settings
|
||||
.save(&settings)
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?;
|
||||
tracing::info!(wallet = %name, "wallet removed: it held nothing");
|
||||
session_status(state)
|
||||
}
|
||||
|
||||
/// Lock: every wallet, together; the password is wiped with them.
|
||||
#[tauri::command]
|
||||
pub fn lock(state: State<'_, AppState>) -> Result<(), WalletError> {
|
||||
@@ -1189,6 +1367,257 @@ const HISTORY_CACHE_PAGE: usize = 50;
|
||||
/// the zk-tree scanned for leaves paying it (resumed from the cache), each
|
||||
/// unspent one checked against `UsedNullifiers`. The secret is derived from
|
||||
/// the session's seed for the call and dropped with it.
|
||||
/// 1Click's index prices by Intents asset id, as 8-decimal fixed point
|
||||
/// (the JSON number is rendered to 8 places and parsed as a decimal
|
||||
/// string: no float reaches a balance). Cached for a minute. On failure,
|
||||
/// whatever was last read, or nothing.
|
||||
async fn index_prices(state: &AppState) -> (Option<i64>, HashMap<String, u128>) {
|
||||
let mut cache = state.prices.lock().await;
|
||||
if let Some((at, when, map)) = cache.as_ref()
|
||||
&& at.elapsed() < std::time::Duration::from_secs(60)
|
||||
{
|
||||
return (Some(*when), map.clone());
|
||||
}
|
||||
match state.swap.tokens().await {
|
||||
Ok(tokens) => {
|
||||
let map: HashMap<String, u128> = tokens
|
||||
.into_iter()
|
||||
.filter_map(|t| {
|
||||
let p = t.price?;
|
||||
if !(p.is_finite() && p > 0.0) {
|
||||
return None;
|
||||
}
|
||||
Amount::parse(&format!("{p:.8}"), 8)
|
||||
.ok()
|
||||
.map(|a| (t.asset_id, a.raw))
|
||||
})
|
||||
.collect();
|
||||
let when = chrono::Utc::now().timestamp();
|
||||
*cache = Some((Instant::now(), when, map.clone()));
|
||||
(Some(when), map)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "index prices unavailable");
|
||||
match cache.as_ref() {
|
||||
Some((_, when, map)) => (Some(*when), map.clone()),
|
||||
None => (None, HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `amount` of an asset with `decimals`, at `price` (8 decimals), in cents.
|
||||
/// The amount is first brought to 8 decimals so a 24-decimal token cannot
|
||||
/// overflow the product; what is dropped is below a hundred-millionth of a
|
||||
/// unit.
|
||||
fn value_cents(amount: u128, decimals: u8, price: u128) -> Option<u128> {
|
||||
let at8 = if decimals > 8 {
|
||||
amount / 10u128.pow(u32::from(decimals - 8))
|
||||
} else {
|
||||
amount.checked_mul(10u128.pow(u32::from(8 - decimals)))?
|
||||
};
|
||||
// 8 + 8 decimals, down to 2.
|
||||
at8.checked_mul(price).map(|v| v / 10u128.pow(14))
|
||||
}
|
||||
|
||||
fn holding(
|
||||
kind: &str,
|
||||
symbol: &str,
|
||||
amount: u128,
|
||||
decimals: u8,
|
||||
price: Option<u128>,
|
||||
) -> DashboardHolding {
|
||||
let value = price.and_then(|p| value_cents(amount, decimals, p));
|
||||
DashboardHolding {
|
||||
kind: kind.into(),
|
||||
symbol: symbol.into(),
|
||||
amount: Amount::new(amount, decimals),
|
||||
value: value.map(|c| Amount::new(c, 2)),
|
||||
price: price.map(|p| Amount::new(p, 8)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole wallet at a glance (#85): every mainnet chain's accounts and
|
||||
/// shielded balance, the intents account's holdings, priced by 1Click's
|
||||
/// index and split by chain. A chain that is not connected yet is started
|
||||
/// and reported as not live; the UI asks again.
|
||||
#[tauri::command]
|
||||
pub async fn dashboard(state: State<'_, AppState>) -> Result<Dashboard, WalletError> {
|
||||
use wallet_core::near::INTENTS;
|
||||
use wallet_data::keys::wormhole::WormholeKeys;
|
||||
use wallet_entities::ChainKind;
|
||||
|
||||
let status = state
|
||||
.session
|
||||
.status(Instant::now())
|
||||
.map_err(session_error)?;
|
||||
if status.is_locked() {
|
||||
return Err(WalletError::Locked);
|
||||
}
|
||||
let (priced_at, prices) = index_prices(&state).await;
|
||||
let mut chains: Vec<DashboardChain> = vec![];
|
||||
|
||||
for profile in state
|
||||
.profiles
|
||||
.iter()
|
||||
.filter(|p| p.kind == ChainKind::Mainnet)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>()
|
||||
{
|
||||
// Start the chain if nothing has yet; the read below tells whether
|
||||
// it is connected.
|
||||
{
|
||||
let mut managers = state
|
||||
.chains
|
||||
.lock()
|
||||
.map_err(|_| WalletError::Internal("state poisoned".into()))?;
|
||||
managers
|
||||
.entry(profile.id.clone())
|
||||
.or_insert_with(|| ChainManager::start(profile.clone()));
|
||||
}
|
||||
let native_price = state
|
||||
.assets
|
||||
.intents_id(&AssetId {
|
||||
chain: profile.id.clone(),
|
||||
kind: AssetKind::Native,
|
||||
})
|
||||
.ok()
|
||||
.and_then(|id| prices.get(id).copied());
|
||||
let (transparent, live) = match read_balances(&state, &profile.id).await {
|
||||
Ok(bs) => (bs.iter().map(|b| b.total.raw).sum::<u128>(), true),
|
||||
Err(e) => {
|
||||
tracing::debug!(chain = %profile.id.0, error = %e, "dashboard: balances not read");
|
||||
(0, false)
|
||||
}
|
||||
};
|
||||
let mut holdings = vec![holding(
|
||||
"transparent",
|
||||
&profile.token_symbol,
|
||||
transparent,
|
||||
profile.token_decimals,
|
||||
native_price,
|
||||
)];
|
||||
// Shielded: the unspent leaves the last scan recorded, no scan here.
|
||||
let mut shielded = 0u128;
|
||||
for w in status.wallets.iter().filter(|w| w.has_seed) {
|
||||
if let Ok(Some(Ok(keys))) = state
|
||||
.session
|
||||
.with_seed(&w.summary.name, |seed| WormholeKeys::derive(seed, 0))
|
||||
&& let Ok(leaves) = state
|
||||
.shielded_cache
|
||||
.leaves(&profile.id, &keys.address(profile.ss58_prefix))
|
||||
{
|
||||
shielded += leaves
|
||||
.iter()
|
||||
.filter(|l| !l.spent)
|
||||
.map(|l| l.amount)
|
||||
.sum::<u128>();
|
||||
}
|
||||
}
|
||||
if shielded > 0 {
|
||||
holdings.push(holding(
|
||||
"shielded",
|
||||
&profile.token_symbol,
|
||||
shielded,
|
||||
profile.token_decimals,
|
||||
native_price,
|
||||
));
|
||||
}
|
||||
chains.push(DashboardChain {
|
||||
id: profile.id.0.clone(),
|
||||
label: profile.display_name.clone(),
|
||||
value: Amount::new(0, 2),
|
||||
share_bps: 0,
|
||||
holdings,
|
||||
live,
|
||||
});
|
||||
}
|
||||
|
||||
// The intents account: every token the registry maps to Intents.
|
||||
if let Some(rec) = state
|
||||
.settings
|
||||
.load()
|
||||
.map_err(|e| WalletError::Internal(e.to_string()))?
|
||||
.intents_account
|
||||
{
|
||||
let mut tokens: Vec<(String, String, u8)> = vec![];
|
||||
for e in state.assets.iter() {
|
||||
if let Some(t) = &e.intents
|
||||
&& t.starts_with("nep141:")
|
||||
&& !tokens.iter().any(|(x, ..)| x == t)
|
||||
{
|
||||
tokens.push((t.clone(), e.info.symbol.clone(), e.info.decimals));
|
||||
}
|
||||
}
|
||||
let ids: Vec<&str> = tokens.iter().map(|(t, ..)| t.as_str()).collect();
|
||||
let (holdings, live) = match state
|
||||
.near
|
||||
.view(
|
||||
INTENTS,
|
||||
"mt_batch_balance_of",
|
||||
&serde_json::json!({ "account_id": rec.account_id, "token_ids": ids }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(raw) => {
|
||||
let raw: Vec<String> = serde_json::from_value(raw).unwrap_or_default();
|
||||
let hs = tokens
|
||||
.iter()
|
||||
.zip(raw)
|
||||
.filter_map(|((t, sym, dec), r)| {
|
||||
let n: u128 = r.parse().ok()?;
|
||||
(n > 0).then(|| holding("intents", sym, n, *dec, prices.get(t).copied()))
|
||||
})
|
||||
.collect();
|
||||
(hs, true)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "dashboard: intents balances not read");
|
||||
(vec![], false)
|
||||
}
|
||||
};
|
||||
chains.push(DashboardChain {
|
||||
id: "near-intents".into(),
|
||||
label: "NEAR Intents".into(),
|
||||
value: Amount::new(0, 2),
|
||||
share_bps: 0,
|
||||
holdings,
|
||||
live,
|
||||
});
|
||||
}
|
||||
|
||||
let mut unpriced = vec![];
|
||||
let mut total = 0u128;
|
||||
for c in &mut chains {
|
||||
let mut v = 0u128;
|
||||
for h in &c.holdings {
|
||||
match h.value {
|
||||
Some(a) => v = v.saturating_add(a.raw),
|
||||
None if h.amount.raw > 0 => unpriced.push(format!("{}: {}", c.label, h.symbol)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
c.value = Amount::new(v, 2);
|
||||
total = total.saturating_add(v);
|
||||
}
|
||||
for c in &mut chains {
|
||||
c.share_bps = c
|
||||
.value
|
||||
.raw
|
||||
.saturating_mul(10_000)
|
||||
.checked_div(total)
|
||||
.unwrap_or(0) as u32;
|
||||
}
|
||||
chains.sort_by_key(|c| std::cmp::Reverse(c.value.raw));
|
||||
Ok(Dashboard {
|
||||
currency: "USD".into(),
|
||||
total: Amount::new(total, 2),
|
||||
priced_at: if prices.is_empty() { None } else { priced_at },
|
||||
chains,
|
||||
unpriced,
|
||||
})
|
||||
}
|
||||
|
||||
/// A wallet's NEAR identity, derived from its phrase: the key's public
|
||||
/// side and the account the sponsor makes for it. `None` for a wallet
|
||||
/// opened without its phrase.
|
||||
@@ -2286,3 +2715,42 @@ pub async fn wormhole_summary(
|
||||
leaf_count,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dashboard_tests {
|
||||
use super::value_cents;
|
||||
|
||||
/// A price with 8 decimals: `$140.23438841`.
|
||||
const QTC: u128 = 14_023_438_841;
|
||||
|
||||
#[test]
|
||||
fn values_are_integer_products_in_cents() {
|
||||
// 1 QTC (12 decimals) at $140.23438841 is $140.23.
|
||||
assert_eq!(value_cents(1_000_000_000_000, 12, QTC), Some(14_023));
|
||||
// 0.0315 QTC, the #72 deposit, is $4.41.
|
||||
assert_eq!(value_cents(31_500_000_000, 12, QTC), Some(441));
|
||||
// 4.278655 USDC (6 decimals) at $1.00000000 is $4.27 (cents truncate).
|
||||
assert_eq!(value_cents(4_278_655, 6, 100_000_000), Some(427));
|
||||
// 0.889664 wNEAR (24 decimals) at $4.83 is $4.29.
|
||||
assert_eq!(
|
||||
value_cents(889_664_246_511_497_736_444_904, 24, 483_000_000),
|
||||
Some(429)
|
||||
);
|
||||
assert_eq!(value_cents(0, 12, QTC), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_million_wnear_does_not_overflow() {
|
||||
// 1e30 base units × a 9-digit price would overflow u128 multiplied
|
||||
// as is; brought to 8 decimals first it is $4,830,000.00.
|
||||
assert_eq!(
|
||||
value_cents(10u128.pow(30), 24, 483_000_000),
|
||||
Some(483_000_000)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absurd_amount_at_few_decimals_is_none_not_wrong() {
|
||||
assert_eq!(value_cents(u128::MAX, 0, 1), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ pub fn run() {
|
||||
commands::checkphrase,
|
||||
commands::unlock,
|
||||
commands::wallet_rekey,
|
||||
commands::wallet_remove,
|
||||
commands::password_change,
|
||||
commands::wallet_add_account,
|
||||
commands::wallet_discover_accounts,
|
||||
@@ -79,6 +80,7 @@ pub fn run() {
|
||||
commands::history_page,
|
||||
commands::wormhole_summary,
|
||||
commands::near_intents,
|
||||
commands::dashboard,
|
||||
commands::near_create_account,
|
||||
commands::near_qtc_deposit_address,
|
||||
commands::near_withdraw_qtc,
|
||||
|
||||
@@ -73,8 +73,16 @@ pub struct AppState {
|
||||
/// The PoA bridge between Quantus and NEAR (#75).
|
||||
pub qtc_bridge: Arc<dyn QtcBridge>,
|
||||
pub near_network: NearNetwork,
|
||||
/// 1Click's index prices for the dashboard (#85), by Intents asset id,
|
||||
/// as 8-decimal fixed point, with when they were read. Refreshed after
|
||||
/// a minute.
|
||||
pub prices: tokio::sync::Mutex<Option<PriceCache>>,
|
||||
}
|
||||
|
||||
/// When the prices were read (monotonic, for the refresh; unix, for the
|
||||
/// display) and the prices themselves.
|
||||
pub type PriceCache = (std::time::Instant, i64, HashMap<String, u128>);
|
||||
|
||||
impl AppState {
|
||||
/// The keystore lives in the platform app-data directory unless
|
||||
/// `WALLET_KEYSTORE_DIR` says otherwise (useful for a dev run against a
|
||||
@@ -147,6 +155,7 @@ impl AppState {
|
||||
near_sponsor: Arc::new(HttpSponsor::new(&near_network.sponsor_api)),
|
||||
qtc_bridge: Arc::new(PoaBridge::new(&near_network.bridge_rpc)),
|
||||
near_network,
|
||||
prices: tokio::sync::Mutex::new(None),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ pub trait Keystore: Send + Sync {
|
||||
|
||||
/// Write the wallet's file, unchanged, to `dest`. It stays encrypted.
|
||||
fn export_file(&self, name: &str, dest: &std::path::Path) -> Result<(), KeystoreError>;
|
||||
|
||||
/// Delete a wallet's file. The caller has established that nothing is
|
||||
/// in it; this only refuses a name that is not a wallet here.
|
||||
fn remove(&self, name: &str) -> Result<(), KeystoreError>;
|
||||
}
|
||||
|
||||
/// One account an opened wallet can sign with, and where it came from.
|
||||
|
||||
@@ -175,6 +175,18 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop one wallet from the session, when its file is removed. The
|
||||
/// others stay open.
|
||||
pub fn forget(&self, name: &str) -> Result<(), SessionError> {
|
||||
let mut inner = self.inner()?;
|
||||
let before = inner.wallets.len();
|
||||
inner.wallets.retain(|w| w.wallet.name != name);
|
||||
if inner.wallets.len() == before {
|
||||
return Err(SessionError::NoSuchWallet(name.to_owned()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remember the password the open wallets were decrypted with.
|
||||
pub fn set_password(&self, password: Zeroizing<String>) -> Result<(), SessionError> {
|
||||
self.inner()?.password = Some(password);
|
||||
@@ -575,6 +587,19 @@ mod tests {
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
|
||||
// Forgetting one leaves the other signing, and the password held.
|
||||
s.forget("spending").unwrap();
|
||||
assert!(matches!(
|
||||
s.sign(&a1, b"m", b"", t0),
|
||||
Err(SessionError::NoSuchAccount)
|
||||
));
|
||||
assert!(s.sign(&a3, b"m", b"", t0).is_ok());
|
||||
assert!(matches!(
|
||||
s.forget("spending"),
|
||||
Err(SessionError::NoSuchWallet(_))
|
||||
));
|
||||
assert!(s.password().unwrap().is_some());
|
||||
|
||||
// Accounts added to an open wallet sign; duplicates are skipped.
|
||||
assert_eq!(
|
||||
s.add_accounts("savings", named("x", &[4, 3]).accounts)
|
||||
|
||||
@@ -586,6 +586,14 @@ impl Keystore for FileKeystore {
|
||||
Ok(fresh.len())
|
||||
}
|
||||
|
||||
fn remove(&self, name: &str) -> Result<(), KeystoreError> {
|
||||
let path = self.path_for(name)?;
|
||||
// Read it first: a symlink or a non-wallet file is refused, and a
|
||||
// missing one is NotFound, before anything is deleted.
|
||||
let _ = self.load(name)?;
|
||||
fs::remove_file(&path).map_err(|e| KeystoreError::Io(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
fn export_file(&self, name: &str, dest: &Path) -> Result<(), KeystoreError> {
|
||||
let encrypted = self.load(name)?;
|
||||
let text = serde_json::to_vec_pretty(&encrypted)
|
||||
@@ -1010,4 +1018,25 @@ mod tests {
|
||||
ks.rekey("crystal_alice", "odd", "two").unwrap();
|
||||
assert_eq!(ks.rekey_all("two", "three").unwrap(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_deletes_a_wallet_and_nothing_else() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ks = FileKeystore::in_dir(dir.path());
|
||||
ks.import_file(&fixtures().join("v-ml-dsa-65-89001.json"), "", "")
|
||||
.unwrap();
|
||||
ks.import_file(&fixtures().join("crystal_alice.json"), "", "")
|
||||
.unwrap();
|
||||
ks.remove("crystal_alice").unwrap();
|
||||
assert_eq!(ks.list().unwrap().len(), 1);
|
||||
assert!(matches!(
|
||||
ks.remove("crystal_alice"),
|
||||
Err(KeystoreError::NotFound(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
ks.remove("../etc/passwd"),
|
||||
Err(KeystoreError::InvalidName(_))
|
||||
));
|
||||
assert!(ks.open("v-ml-dsa-65-89001", "").is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +449,59 @@ pub struct SessionStatusInfo {
|
||||
pub locks_in_seconds: Option<u64>,
|
||||
}
|
||||
|
||||
/// The whole wallet at a glance (#85): what it holds everywhere, priced in
|
||||
/// one currency, and how that splits across chains. Prices are 1Click's
|
||||
/// index prices, held as fixed-point amounts; every product is integer
|
||||
/// arithmetic on base units, and a holding with no price is listed unpriced
|
||||
/// rather than guessed at.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct Dashboard {
|
||||
/// `USD`.
|
||||
pub currency: String,
|
||||
/// The sum of every priced holding, 2 decimals.
|
||||
pub total: Amount,
|
||||
/// Unix seconds the prices were read at; `None` when no price could be
|
||||
/// read and nothing is valued.
|
||||
#[ts(type = "number | null")]
|
||||
pub priced_at: Option<i64>,
|
||||
/// Per chain, largest value first.
|
||||
pub chains: Vec<DashboardChain>,
|
||||
/// Holdings that could not be valued (no price, or the chain is not
|
||||
/// reachable yet), as `chain: symbol`.
|
||||
pub unpriced: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct DashboardChain {
|
||||
/// `quantus`, `near-intents`, …: stable, for the colour slot.
|
||||
pub id: String,
|
||||
pub label: String,
|
||||
/// This chain's share of the total, 2 decimals.
|
||||
pub value: Amount,
|
||||
/// Basis points of the total; the segment widths.
|
||||
pub share_bps: u32,
|
||||
pub holdings: Vec<DashboardHolding>,
|
||||
/// Whether every holding here was read live; false while the chain is
|
||||
/// still connecting, when the figures are the last known or zero.
|
||||
pub live: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[ts(export)]
|
||||
pub struct DashboardHolding {
|
||||
/// `transparent` (accounts), `shielded` (wormhole, as of the last
|
||||
/// scan) or `intents` (inside NEAR Intents).
|
||||
pub kind: String,
|
||||
pub symbol: String,
|
||||
pub amount: Amount,
|
||||
/// 2 decimals; `None` when unpriced.
|
||||
pub value: Option<Amount>,
|
||||
/// The unit price used, 8 decimals; `None` when unpriced.
|
||||
pub price: Option<Amount>,
|
||||
}
|
||||
|
||||
/// What an unlock did: the session, and any wallet file the password did
|
||||
/// not open. One password opens every wallet; a file that keeps another
|
||||
/// (one the CLI wrote) stays closed until it is re-keyed.
|
||||
@@ -475,9 +528,10 @@ pub struct Settings {
|
||||
/// Labels people give their accounts, by address. Cosmetic: an
|
||||
/// address without one is shown as itself.
|
||||
pub account_names: BTreeMap<String, String>,
|
||||
/// Show the swap screen. Off by default while swaps are a preview: no
|
||||
/// post-quantum asset is on NEAR Intents yet, so every swap is between
|
||||
/// transit assets paid from an external wallet.
|
||||
/// Show the Swap screen: 1Click quotes paid to a one-time deposit
|
||||
/// address, for assets this wallet does not hold (#7). Off by default;
|
||||
/// swaps from the NEAR Intents account are on the accounts page (#71)
|
||||
/// whatever this says.
|
||||
pub preview_swaps: bool,
|
||||
/// Account numbers added to each wallet beyond account 0, by wallet
|
||||
/// name. Not secret: the keys are re-derived from the wallet's phrase on
|
||||
|
||||
@@ -68,6 +68,11 @@ cargo test --workspace # also regenerates
|
||||
|
||||
The CI gate is `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test`,
|
||||
and the webview's typecheck, lint and build. Run them before claiming done.
|
||||
A green gate on `main` also uploads a debug build of the app as a run artifact
|
||||
(`blackbeard-wallet-<sha>-x86_64-linux-gnu-debug`, kept seven days), so a
|
||||
change can be tried on a machine before a release is cut. It is the dev
|
||||
profile, so slower than a release, and it needs the GTK/WebKitGTK libraries
|
||||
the rpm depends on.
|
||||
|
||||
## Work
|
||||
|
||||
|
||||
+3
-1
@@ -5,6 +5,7 @@ import { Sidebar } from './components/Sidebar'
|
||||
import { StatusBar } from './components/StatusBar'
|
||||
import { UpdateOffer } from './components/UpdateOffer'
|
||||
import { Accounts } from './routes/Accounts'
|
||||
import { Dashboard } from './routes/Dashboard'
|
||||
import { History } from './routes/History'
|
||||
import { Create, ImportFile, OnboardingIndex, Restore } from './routes/Onboarding'
|
||||
import { Receive } from './routes/Receive'
|
||||
@@ -53,7 +54,8 @@ function Gate() {
|
||||
<main className="main">
|
||||
<UpdateOffer />
|
||||
<Routes>
|
||||
<Route path="/" element={<Accounts />} />
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/accounts" element={<Accounts />} />
|
||||
<Route path="/send" element={<Send />} />
|
||||
<Route path="/receive" element={<Receive />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Amount } from "./Amount";
|
||||
import type { DashboardChain } from "./DashboardChain";
|
||||
|
||||
/**
|
||||
* The whole wallet at a glance (#85): what it holds everywhere, priced in
|
||||
* one currency, and how that splits across chains. Prices are 1Click's
|
||||
* index prices, held as fixed-point amounts; every product is integer
|
||||
* arithmetic on base units, and a holding with no price is listed unpriced
|
||||
* rather than guessed at.
|
||||
*/
|
||||
export type Dashboard = {
|
||||
/**
|
||||
* `USD`.
|
||||
*/
|
||||
currency: string,
|
||||
/**
|
||||
* The sum of every priced holding, 2 decimals.
|
||||
*/
|
||||
total: Amount,
|
||||
/**
|
||||
* Unix seconds the prices were read at; `None` when no price could be
|
||||
* read and nothing is valued.
|
||||
*/
|
||||
priced_at: number | null,
|
||||
/**
|
||||
* Per chain, largest value first.
|
||||
*/
|
||||
chains: Array<DashboardChain>,
|
||||
/**
|
||||
* Holdings that could not be valued (no price, or the chain is not
|
||||
* reachable yet), as `chain: symbol`.
|
||||
*/
|
||||
unpriced: Array<string>, };
|
||||
@@ -0,0 +1,22 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Amount } from "./Amount";
|
||||
import type { DashboardHolding } from "./DashboardHolding";
|
||||
|
||||
export type DashboardChain = {
|
||||
/**
|
||||
* `quantus`, `near-intents`, …: stable, for the colour slot.
|
||||
*/
|
||||
id: string, label: string,
|
||||
/**
|
||||
* This chain's share of the total, 2 decimals.
|
||||
*/
|
||||
value: Amount,
|
||||
/**
|
||||
* Basis points of the total; the segment widths.
|
||||
*/
|
||||
share_bps: number, holdings: Array<DashboardHolding>,
|
||||
/**
|
||||
* Whether every holding here was read live; false while the chain is
|
||||
* still connecting, when the figures are the last known or zero.
|
||||
*/
|
||||
live: boolean, };
|
||||
@@ -0,0 +1,17 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { Amount } from "./Amount";
|
||||
|
||||
export type DashboardHolding = {
|
||||
/**
|
||||
* `transparent` (accounts), `shielded` (wormhole, as of the last
|
||||
* scan) or `intents` (inside NEAR Intents).
|
||||
*/
|
||||
kind: string, symbol: string, amount: Amount,
|
||||
/**
|
||||
* 2 decimals; `None` when unpriced.
|
||||
*/
|
||||
value: Amount | null,
|
||||
/**
|
||||
* The unit price used, 8 decimals; `None` when unpriced.
|
||||
*/
|
||||
price: Amount | null, };
|
||||
@@ -24,9 +24,10 @@ show_testnets: boolean,
|
||||
*/
|
||||
account_names: { [key in string]?: string },
|
||||
/**
|
||||
* Show the swap screen. Off by default while swaps are a preview: no
|
||||
* post-quantum asset is on NEAR Intents yet, so every swap is between
|
||||
* transit assets paid from an external wallet.
|
||||
* Show the Swap screen: 1Click quotes paid to a one-time deposit
|
||||
* address, for assets this wallet does not hold (#7). Off by default;
|
||||
* swaps from the NEAR Intents account are on the accounts page (#71)
|
||||
* whatever this says.
|
||||
*/
|
||||
preview_swaps: boolean,
|
||||
/**
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
unlock,
|
||||
walletAddAccount,
|
||||
walletDiscoverAccounts,
|
||||
walletRemove,
|
||||
walletsList,
|
||||
} from './wallet'
|
||||
|
||||
@@ -100,6 +101,19 @@ export function useDiscoverAccounts() {
|
||||
}
|
||||
|
||||
/** Lock one wallet (pass its name) or every open wallet (pass nothing). */
|
||||
/** Remove an empty wallet; the session and the wallet list both change. */
|
||||
export function useRemoveWallet() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (name: string) => walletRemove(name),
|
||||
onSuccess: (s) => {
|
||||
qc.setQueryData(queryKeys.session, s)
|
||||
qc.invalidateQueries({ queryKey: queryKeys.wallets })
|
||||
qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useLock() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -24,6 +24,7 @@ import type { SwapPreview } from './generated/SwapPreview'
|
||||
import type { HistoryPage } from './generated/HistoryPage'
|
||||
import type { WalletSummary } from './generated/WalletSummary'
|
||||
import type { CashoutDestination } from './generated/CashoutDestination'
|
||||
import type { Dashboard } from './generated/Dashboard'
|
||||
import type { IntentsSwapQuote } from './generated/IntentsSwapQuote'
|
||||
import type { NearAccountSummary } from './generated/NearAccountSummary'
|
||||
import type { WormholeSummary } from './generated/WormholeSummary'
|
||||
@@ -141,6 +142,13 @@ export function walletRekey(name: string, oldPassword: string): Promise<SessionS
|
||||
return command<SessionStatusInfo>('wallet_rekey', { name, oldPassword })
|
||||
}
|
||||
|
||||
// Remove a wallet that holds nothing. Rust establishes that it is empty
|
||||
// (accounts, pending transfers, shielded leaves, the intents account) and
|
||||
// refuses with the reason otherwise; its file is deleted.
|
||||
export function walletRemove(name: string): Promise<SessionStatusInfo> {
|
||||
return command<SessionStatusInfo>('wallet_remove', { name })
|
||||
}
|
||||
|
||||
// Every wallet file re-encrypted, all or none.
|
||||
export function passwordChange(current: string, next: string): Promise<void> {
|
||||
return command<void>('password_change', { current, new: next })
|
||||
@@ -241,6 +249,13 @@ export function settingsSet(settings: Settings): Promise<Settings> {
|
||||
return command<Settings>('settings_set', { settings })
|
||||
}
|
||||
|
||||
// The whole wallet at a glance: every holding, priced and split by chain.
|
||||
// Rust reads balances, the shielded cache and the intents account, and
|
||||
// prices with 1Click's index; the UI only draws it.
|
||||
export function dashboard(): Promise<Dashboard> {
|
||||
return command<Dashboard>('dashboard')
|
||||
}
|
||||
|
||||
// History: one page per call, from the indexer or, when it cannot be
|
||||
// reached, from the local cache with `from_cache` set.
|
||||
export function historyPage(
|
||||
|
||||
@@ -6,7 +6,9 @@ import { useUnlock, useWallets } from '../api/hooks'
|
||||
* One password opens every wallet. It goes to Rust and nowhere else. A
|
||||
* wallet file the password does not open (one quantus-cli wrote under a
|
||||
* password of its own) stays closed and is offered a one-time re-key on
|
||||
* the accounts page, once the rest are open.
|
||||
* the accounts page, once the rest are open. Before the password is given
|
||||
* the screen says nothing about what is behind it: not how many wallets,
|
||||
* not their names.
|
||||
*/
|
||||
export function LockScreen() {
|
||||
const wallets = useWallets()
|
||||
@@ -37,10 +39,6 @@ export function LockScreen() {
|
||||
)}
|
||||
{wallets.isSuccess && !none && (
|
||||
<form onSubmit={submit}>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
{wallets.data.length === 1 ? '1 wallet' : `${wallets.data.length} wallets`} open
|
||||
together with one password.
|
||||
</p>
|
||||
<div className="field">
|
||||
<label htmlFor="password">Password</label>
|
||||
<input
|
||||
|
||||
@@ -4,7 +4,8 @@ import { openExternal } from '../api/opener'
|
||||
import { NetworkSwitcher } from './NetworkSwitcher'
|
||||
|
||||
const SECTIONS = [
|
||||
{ to: '/', label: 'Accounts' },
|
||||
{ to: '/', label: 'Dashboard' },
|
||||
{ to: '/accounts', label: 'Accounts' },
|
||||
{ to: '/send', label: 'Send' },
|
||||
{ to: '/receive', label: 'Receive' },
|
||||
{ to: '/history', label: 'History' },
|
||||
|
||||
@@ -428,3 +428,79 @@ textarea.input {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---- dashboard ------------------------------------------------------------ */
|
||||
.hero {
|
||||
font-family: var(--font-body);
|
||||
font-size: 48px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.hero .currency {
|
||||
font-size: 22px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-right: 6px;
|
||||
vertical-align: 0.5em;
|
||||
}
|
||||
.hero .cents {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.hero-note {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
/* A part-to-whole bar: one row, segments in chain order, a 2px gap of the
|
||||
surface between fills, ends rounded, nothing else drawn around it. */
|
||||
.share-bar {
|
||||
display: flex;
|
||||
height: 14px;
|
||||
gap: 2px;
|
||||
margin: 20px 0 14px;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.share-bar .seg {
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
cursor: default;
|
||||
}
|
||||
.share-bar .seg.is-empty {
|
||||
flex: 1;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.share-bar .seg:hover {
|
||||
filter: brightness(1.15);
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
margin-right: 8px;
|
||||
vertical-align: -1px;
|
||||
}
|
||||
.legend th {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.legend td.num,
|
||||
.legend th.num {
|
||||
text-align: right;
|
||||
font-family: var(--font-mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.legend tr.holding td {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.legend tr.holding td:first-child {
|
||||
padding-left: 26px;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
// A currency amount: every decimal kept (2 for USD), thousands grouped.
|
||||
// Rust produced the digits; this only places separators.
|
||||
export function formatMoney(a: { raw: string; decimals: number }): string {
|
||||
const raw = a.raw.padStart(a.decimals + 1, '0')
|
||||
const whole = raw.slice(0, raw.length - a.decimals)
|
||||
const frac = raw.slice(raw.length - a.decimals)
|
||||
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return a.decimals > 0 ? `${grouped}.${frac}` : grouped
|
||||
}
|
||||
|
||||
// Rust renders amounts exactly; this only inserts thousands separators in
|
||||
// the whole part for reading. No arithmetic happens here.
|
||||
export function formatAmount(a: { raw: string; decimals: number }): string {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
useAddAccount,
|
||||
useDiscoverAccounts,
|
||||
useRemoveWallet,
|
||||
useBalances,
|
||||
useChains,
|
||||
useSession,
|
||||
@@ -111,6 +113,7 @@ export function Accounts() {
|
||||
: 'Find accounts'}
|
||||
</button>
|
||||
)}
|
||||
<RemoveWallet name={w.wallet.name} />
|
||||
{w.has_seed && (
|
||||
<button
|
||||
className="button"
|
||||
@@ -176,3 +179,47 @@ export function Accounts() {
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an open wallet that holds nothing. The click asks once, inline;
|
||||
* Rust then checks every account, pending transfer, shielded leaf and the
|
||||
* intents account, and refuses with the reason if anything is there.
|
||||
*/
|
||||
function RemoveWallet({ name }: { name: string }) {
|
||||
const [asking, setAsking] = useState(false)
|
||||
const remove = useRemoveWallet()
|
||||
if (!asking) {
|
||||
return (
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
title="Delete this wallet's file, if it holds nothing"
|
||||
onClick={() => {
|
||||
remove.reset()
|
||||
setAsking(true)
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<span className="row" style={{ gap: 8, alignItems: 'center' }}>
|
||||
<span className="muted" style={{ fontSize: 12, textAlign: 'right' }}>
|
||||
Delete {name}'s file? Only if it holds nothing; its phrase is its backup.
|
||||
</span>
|
||||
<button
|
||||
className="button danger"
|
||||
type="button"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => remove.mutate(name, { onError: () => setAsking(false) })}
|
||||
>
|
||||
{remove.isPending ? 'Checking…' : 'Remove'}
|
||||
</button>
|
||||
<button className="button" type="button" onClick={() => setAsking(false)}>
|
||||
Keep
|
||||
</button>
|
||||
{remove.isError && <span className="error">{remove.error.message}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Dashboard as DashboardData } from '../api/generated/Dashboard'
|
||||
import type { DashboardChain } from '../api/generated/DashboardChain'
|
||||
import { dashboard } from '../api/wallet'
|
||||
import { formatAmount, formatMoney } from '../lib/format'
|
||||
|
||||
/** A chain keeps its colour slot for good; nothing is assigned by rank. */
|
||||
const SERIES: Record<string, number> = { quantus: 1, 'near-intents': 2 }
|
||||
const NEXT_SLOT = 3
|
||||
|
||||
function slot(id: string): number {
|
||||
return SERIES[id] ?? NEXT_SLOT
|
||||
}
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
transparent: 'accounts',
|
||||
shielded: 'shielded (as of the last scan)',
|
||||
intents: 'in NEAR Intents',
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole wallet at a glance, and the first thing shown after unlock:
|
||||
* one figure for everything held, priced by 1Click's index, and a bar
|
||||
* splitting it by chain. The legend under the bar is the table view: every
|
||||
* chain with its value and share, every holding beneath it, so the colour
|
||||
* never carries a fact alone. Rust reads and prices; this draws.
|
||||
*/
|
||||
export function Dashboard() {
|
||||
const q = useQuery({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: dashboard,
|
||||
// A chain still connecting reports `live: false`; ask again sooner.
|
||||
refetchInterval: (query) => (query.state.data?.chains.some((c) => !c.live) ? 5_000 : 30_000),
|
||||
retry: 1,
|
||||
})
|
||||
const [hover, setHover] = useState<string | null>(null)
|
||||
const d = q.data
|
||||
return (
|
||||
<section className="panel" data-testid="dashboard">
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Wallet</h2>
|
||||
{d && d.priced_at != null && (
|
||||
<span className="muted" style={{ fontSize: 12 }}>
|
||||
{d.currency} at index prices, {describeAge(d.priced_at * 1000)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
{q.isPending && <p className="muted">Reading the wallet…</p>}
|
||||
{q.isError && <p className="error">{q.error.message}</p>}
|
||||
{d && (
|
||||
<>
|
||||
<Hero d={d} />
|
||||
<ShareBar chains={d.chains} hover={hover} onHover={setHover} />
|
||||
<Legend chains={d.chains} currency={d.currency} hover={hover} onHover={setHover} />
|
||||
{d.unpriced.length > 0 && (
|
||||
<p className="hero-note">Not valued (no index price): {d.unpriced.join(', ')}.</p>
|
||||
)}
|
||||
{d.chains.some((c) => !c.live) && (
|
||||
<p className="hero-note">
|
||||
{d.chains
|
||||
.filter((c) => !c.live)
|
||||
.map((c) => c.label)
|
||||
.join(', ')}{' '}
|
||||
not reached yet; the figures there are as last known.
|
||||
</p>
|
||||
)}
|
||||
<p className="hero-note">
|
||||
Every account is on the <Link to="/accounts">accounts page</Link>.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/** The one figure this view leads with. */
|
||||
function Hero({ d }: { d: DashboardData }) {
|
||||
const text = formatMoney(d.total)
|
||||
const [whole, cents] = text.split('.')
|
||||
return (
|
||||
<div>
|
||||
<div className="hero" aria-label={`${text} ${d.currency}`}>
|
||||
<span className="currency">{d.currency === 'USD' ? '$' : d.currency}</span>
|
||||
{whole}
|
||||
{cents != null && <span className="cents">.{cents}</span>}
|
||||
</div>
|
||||
{d.priced_at == null && (
|
||||
<p className="hero-note">No index prices could be read; nothing is valued.</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One bar, one segment per chain in value order, widths by share. A 2px gap
|
||||
* of the surface separates fills; the ends are rounded; a chain with no
|
||||
* value draws nothing, and an empty wallet draws one recessed track.
|
||||
*/
|
||||
function ShareBar({
|
||||
chains,
|
||||
hover,
|
||||
onHover,
|
||||
}: {
|
||||
chains: DashboardChain[]
|
||||
hover: string | null
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
const shown = chains.filter((c) => c.share_bps > 0)
|
||||
return (
|
||||
<div className="share-bar" role="img" aria-label="Share of value by chain">
|
||||
{shown.length === 0 && <div className="seg is-empty" />}
|
||||
{shown.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
className="seg"
|
||||
style={{
|
||||
flex: `${c.share_bps} 0 0`,
|
||||
background: `var(--series-${slot(c.id)})`,
|
||||
opacity: hover && hover !== c.id ? 0.45 : 1,
|
||||
}}
|
||||
title={`${c.label}: ${(c.share_bps / 100).toFixed(1)}%`}
|
||||
onMouseEnter={() => onHover(c.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** The table view: identity by swatch and name, never by colour alone. */
|
||||
function Legend({
|
||||
chains,
|
||||
currency,
|
||||
hover,
|
||||
onHover,
|
||||
}: {
|
||||
chains: DashboardChain[]
|
||||
currency: string
|
||||
hover: string | null
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<table className="table legend">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Chain</th>
|
||||
<th className="num">{currency}</th>
|
||||
<th className="num">Share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{chains.map((c) => (
|
||||
<Row key={c.id} c={c} dim={!!hover && hover !== c.id} onHover={onHover} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({
|
||||
c,
|
||||
dim,
|
||||
onHover,
|
||||
}: {
|
||||
c: DashboardChain
|
||||
dim: boolean
|
||||
onHover: (id: string | null) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
style={{ opacity: dim ? 0.5 : 1 }}
|
||||
onMouseEnter={() => onHover(c.id)}
|
||||
onMouseLeave={() => onHover(null)}
|
||||
>
|
||||
<th>
|
||||
<span className="swatch" style={{ background: `var(--series-${slot(c.id)})` }} />
|
||||
{c.label}
|
||||
{!c.live && <span className="muted"> · connecting</span>}
|
||||
</th>
|
||||
<td className="num">{formatMoney(c.value)}</td>
|
||||
<td className="num">{(c.share_bps / 100).toFixed(1)}%</td>
|
||||
</tr>
|
||||
{c.holdings.map((h) => (
|
||||
<tr key={h.kind + h.symbol} className="holding" style={{ opacity: dim ? 0.5 : 1 }}>
|
||||
<td>
|
||||
{formatAmount(h.amount)} {h.symbol}{' '}
|
||||
<span className="muted">{KIND_LABEL[h.kind] ?? h.kind}</span>
|
||||
</td>
|
||||
<td className="num">{h.value ? formatMoney(h.value) : '—'}</td>
|
||||
<td className="num muted">{h.price ? `@ ${trimPrice(h.price)}` : 'unpriced'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** A unit price with the noise cut: at most 4 decimals, trailing zeros gone. */
|
||||
function trimPrice(p: { raw: string; decimals: number }): string {
|
||||
const text = formatAmount({ raw: p.raw, decimals: p.decimals })
|
||||
const [w, f = ''] = text.split('.')
|
||||
const frac = f.slice(0, 4).replace(/0+$/, '')
|
||||
return frac ? `${w}.${frac}` : w
|
||||
}
|
||||
|
||||
function describeAge(ms: number): string {
|
||||
const s = Math.max(0, Math.round((Date.now() - ms) / 1000))
|
||||
if (s < 90) return 'just now'
|
||||
if (s < 3600) return `${Math.floor(s / 60)} min ago`
|
||||
return `${Math.floor(s / 3600)} h ago`
|
||||
}
|
||||
@@ -52,7 +52,8 @@ export function SettingsRoute() {
|
||||
checked={s.preview_swaps}
|
||||
onChange={(e) => set.mutate({ ...s, preview_swaps: e.target.checked })}
|
||||
/>{' '}
|
||||
Show the swap screen (preview: no post-quantum asset is on NEAR Intents yet)
|
||||
Show the Swap screen: 1Click quotes paid to a one-time deposit address, for assets this
|
||||
wallet does not hold. Swaps from your NEAR Intents account are on the Accounts page.
|
||||
</label>
|
||||
</div>
|
||||
<div className="field">
|
||||
|
||||
@@ -26,7 +26,8 @@ export function Swap() {
|
||||
<h2 className="panel-title">Swap</h2>
|
||||
</div>
|
||||
<p className="panel-body muted">
|
||||
Swaps are a preview. Turn them on in Settings to see the screen.
|
||||
The Swap screen is off; turn it on in Settings. Swaps from your NEAR Intents account are
|
||||
on the Accounts page.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
@@ -113,10 +114,10 @@ function SwapForm() {
|
||||
<span className="chip testnet">preview</span>
|
||||
</div>
|
||||
<div className="panel-body">
|
||||
<p className="warn" style={{ marginTop: 0 }}>
|
||||
No post-quantum asset is on NEAR Intents yet. Every swap here is between assets this
|
||||
wallet does not hold: you pay the deposit from an external wallet, and the result goes to
|
||||
the recipient address you give.
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
A 1Click quote with a one-time deposit address: pay it from any wallet, and the result
|
||||
goes to the recipient you name. QTC is listed on 1Click but no solver quotes it yet, so
|
||||
QTC swaps run through your NEAR Intents account on the Accounts page.
|
||||
</p>
|
||||
{assets.isError && <p className="error">{assets.error.message}</p>}
|
||||
{order ? (
|
||||
|
||||
@@ -36,6 +36,18 @@
|
||||
--failed-chip: rgba(216, 69, 58, 0.12);
|
||||
--rule: 1px solid var(--border);
|
||||
--shadow: 0 1px 0 rgba(255, 255, 255, 0.03) inset;
|
||||
/*
|
||||
* The dashboard's chain palette (#85): categorical, in a fixed order a
|
||||
* chain keeps for good (slot 1 is Quantus, 2 NEAR Intents; the rest wait
|
||||
* for chains to come). Validated on this surface with the house
|
||||
* data-viz validator: lightness band, chroma, CVD separation, normal-
|
||||
* vision floor and contrast all pass.
|
||||
*/
|
||||
--series-1: #bd8829;
|
||||
--series-2: #3987e5;
|
||||
--series-3: #199e70;
|
||||
--series-4: #8f7ad8;
|
||||
--series-5: #d55181;
|
||||
}
|
||||
:root[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
@@ -62,4 +74,11 @@
|
||||
--warn-border: rgba(184, 101, 26, 0.45);
|
||||
--failed-chip: rgba(195, 56, 45, 0.09);
|
||||
--shadow: 0 1px 0 rgba(27, 23, 16, 0.04) inset;
|
||||
/* The same five chains, stepped for the light surface; slots 3 and 5 sit
|
||||
* under 3:1 against it, which the legend table beside the bar covers. */
|
||||
--series-1: #a8741c;
|
||||
--series-2: #2a78d6;
|
||||
--series-3: #1baf7a;
|
||||
--series-4: #6250d6;
|
||||
--series-5: #e87ba4;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user