6 Commits
Author SHA1 Message Date
grenadeandClaude Fable 5.1 67bae582a4 ci: the artifact is the debug build, so the gate stays as fast as it was
ci / gate (push) Successful in 16m34s
The release-profile build added to the gate in 9757f90 would have
recompiled the workspace for minutes on every push (and looked for a
binary name plain cargo does not produce). The operator asked for what
already exists: `tauri build --debug --no-bundle` reuses everything
`cargo test` compiled (21 s locally), embeds the webview the gate built
rather than pointing at a dev server, and the binary is stripped of
debug info, 93 MB instead of 498. Checked: the stripped binary, run on
its own with no dev server, serves its page from tauri://localhost.

Refs #87

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 12:51:45 +03:00
grenadeandClaude Fable 5.1 9757f907d6 ci: the gate uploads the release binary as a run artifact
ci / gate (push) Canceled after 5m25s
Releases are batched, so a change on main could wait days for a package
(#87). The gate now ends by building the bare release binary as the
release workflow does, with the webview it already built embedded, and
uploading it named for the commit, kept seven days. It is the last step
so a red gate uploads nothing that could be mistaken for tested.

Closes #87

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 12:46:14 +03:00
grenadeandClaude Fable 5.1 caff8a6be6 feat: remove a wallet that holds nothing
ci / gate (push) Canceled after 6m53s
An open wallet can be removed from the accounts page (#86): its file is
deleted, it leaves the session, and its account numbers and names leave
the settings. Whether it holds nothing is decided in Rust, never by the
webview, and a refusal says why:

- every account must hold zero, free plus reserved, on every mainnet
  chain, read live; a chain that cannot be read refuses rather than
  assumes;
- no reversible transfer may be pending to or from any of its accounts;
- a phrase wallet's wormhole address must hold no unspent leaf, with the
  zk-tree scan current so none is unseen;
- the wallet whose key controls the intents account is refused while
  that account holds any token; if it holds none, the record goes with
  the wallet and the next creation makes a new account.

The control asks once, inline, and names what happens; no browser
dialog. Keystore::remove reads the file first, so a symlink or a
stranger's file is refused before anything is deleted; Session::forget
drops the one wallet and keeps the password.

Checked in a dev run with three wallets: Remove on a raw-key wallet
holding nothing asked, deleted its file, took it out of the sidebar and
cleaned its settings, with the other two left open.

Closes #86

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 12:39:21 +03:00
grenadeandClaude Fable 5.1 20f171be96 fix(ui): say what the Swap screen is, now that QTC is on NEAR Intents
ci / gate (push) Successful in 12m27s
The Settings toggle, the Swap screen and the setting's doc all said no
post-quantum asset is on NEAR Intents yet. QTC has been since October
(#71), and the wallet's own intents account swaps it from the accounts
page. The Swap screen is the other route: a 1Click quote paid to a
one-time deposit address, for assets this wallet does not hold (#7),
and QTC is not among them until a solver quotes it. All three now say
so.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 12:17:35 +03:00
grenadeandClaude Fable 5.1 102f659900 feat(ui): a dashboard of the whole wallet, first after unlock
ci / gate (push) Canceled after 3m6s
The default route is now a dashboard (#85): one figure for everything
the wallet holds, priced in USD at 1Click's index prices, and a bar
splitting it by chain. The accounts page moves to /accounts.

Rust reads and prices; the webview draws:
- Each mainnet chain's accounts (the balances read as the accounts page
  does) and its shielded balance (the wormhole cache's unspent leaves,
  no scan), and the intents account's tokens, one view call.
- Prices come from GET /v0/tokens, cached a minute. A price arrives as a
  JSON number, is rendered to 8 places and parsed as a decimal; every
  product is integer arithmetic on base units, brought to 8 decimals
  first so a 24-decimal token cannot overflow u128. Three tests pin it,
  including a million wNEAR.
- What cannot be valued is named, not guessed; a chain not reached yet
  is started, marked, and the UI asks again every five seconds.

The form follows the house data-viz method: a hero figure (48px, the
body sans, proportional figures), and part-to-whole as a single bar
with a 2px surface gap between segments rather than a ring, which
hides close values and has no room for labels. Chains keep a fixed
colour slot for good (Quantus bronze, NEAR Intents blue, three more
validated for later); the legend table beneath carries every value and
holding, so colour never carries a fact alone. Both palettes passed the
validator against the wallet's own surfaces; the light mode's contrast
warning on two slots is what the table answers.

Checked in a dev run: unlock lands on the dashboard, the index priced
QTC at $140.23, both chains read live, and an empty wallet drew the
recessed track. A bar with segments wants a wallet that holds
something, which the operator's will show.

Closes #85

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 12:14:24 +03:00
grenadeandClaude Fable 5.1 700850c75e fix(ui): the lock screen says nothing about what it guards
ci / gate (push) Successful in 12m37s
It announced how many wallets the password would open. Before the
password is given, the screen shows a password field and nothing else:
not a count, not names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
2026-10-12 11:59:29 +03:00
25 changed files with 1120 additions and 22 deletions
+29
View File
@@ -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
+471 -3
View File
@@ -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);
}
}
+2
View File
@@ -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,
+9
View File
@@ -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),
})
}
}
+4
View File
@@ -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.
+25
View File
@@ -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)
+29
View File
@@ -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());
}
}
+57 -3
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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 />} />
+34
View File
@@ -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>, };
+22
View File
@@ -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, };
+17
View File
@@ -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, };
+4 -3
View File
@@ -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,
/**
+14
View File
@@ -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({
+15
View File
@@ -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(
+3 -5
View File
@@ -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
+2 -1
View File
@@ -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' },
+76
View File
@@ -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;
}
+10
View File
@@ -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 {
+47
View File
@@ -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>
)
}
+215
View File
@@ -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`
}
+2 -1
View File
@@ -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">
+6 -5
View File
@@ -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 ? (
+19
View File
@@ -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;
}