diff --git a/Cargo.lock b/Cargo.lock index 6a6ac91..bc92e99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6971,6 +6971,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-updater", + "tokio", "toml 0.8.2", "tracing", "tracing-subscriber", diff --git a/crates/wallet-app/Cargo.toml b/crates/wallet-app/Cargo.toml index e0ae4ff..70eb039 100644 --- a/crates/wallet-app/Cargo.toml +++ b/crates/wallet-app/Cargo.toml @@ -28,6 +28,7 @@ tauri-plugin-opener.workspace = true tauri-plugin-updater.workspace = true tauri-plugin-process.workspace = true chrono.workspace = true +tokio.workspace = true # Used by tauri::generate_context! for the plugins section of tauri.conf.json. serde_json.workspace = true tracing.workspace = true diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index b3729c4..4f453af 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -5,6 +5,7 @@ mod commands; mod platform; mod state; +mod swap; pub fn run() { tracing_subscriber::fmt() @@ -25,6 +26,8 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .manage(state) .setup(|app| { + // Orders left open by the last run are followed again from here. + swap::resume_open_orders(app.handle()); // The idle rule is applied on every command too; this ticker is // what locks a wallet nobody is touching, and tells the UI so // the lock screen appears without a click. @@ -71,6 +74,10 @@ pub fn run() { commands::settings_set, commands::history_page, commands::wormhole_summary, + swap::swap_assets, + swap::swap_preview, + swap::swap_order_create, + swap::orders_list, ]) .run(tauri::generate_context!()) .expect("the Tauri runtime failed to start"); diff --git a/crates/wallet-app/src/state.rs b/crates/wallet-app/src/state.rs index 055c5e7..27cf5e9 100644 --- a/crates/wallet-app/src/state.rs +++ b/crates/wallet-app/src/state.rs @@ -8,14 +8,16 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; use wallet_core::PreparedTransaction; +use wallet_core::assets::AssetRegistry; use wallet_core::backup::BackupChallenge; -use wallet_core::ports::{HistoryCache, HistorySource, ShieldedCache}; +use wallet_core::ports::{HistoryCache, HistorySource, OrderStore, ShieldedCache}; use wallet_core::profiles::ProfileRegistry; use wallet_core::session::Session; use wallet_data::history::{ObserverHistory, SqliteHistoryCache}; use wallet_data::keystore::FileKeystore; use wallet_data::settings::SettingsStore; use wallet_data::substrate::ChainManager; +use wallet_data::swap::OneClick; use wallet_entities::ChainId; use wallet_entities::SignatureScheme; use zeroize::Zeroizing; @@ -50,6 +52,11 @@ pub struct AppState { pub history_cache: Arc, /// The same SQLite file, for the zk-tree scan of the wormhole address. pub shielded_cache: Arc, + /// Swap orders, in the same file, polled while open. + pub orders: Arc, + /// The swap provider: NEAR Intents' 1Click, through the asset registry. + pub swap: Arc, + pub assets: AssetRegistry, } impl AppState { @@ -89,6 +96,7 @@ impl AppState { profiles = profiles.with_override(&text)?; tracing::info!(path = %override_path.display(), "applied profile overrides"); } + let assets = AssetRegistry::shipped().map_err(|e| anyhow::anyhow!("assets.toml: {e}"))?; let cache = Arc::new( SqliteHistoryCache::open(&data_dir.join("history.sqlite")) .map_err(|e| anyhow::anyhow!("history cache: {e}"))?, @@ -104,7 +112,10 @@ impl AppState { settings, history_source: Arc::new(ObserverHistory::new()), history_cache: cache.clone(), - shielded_cache: cache, + shielded_cache: cache.clone(), + orders: cache, + swap: Arc::new(OneClick::new(assets.clone())), + assets, }) } } diff --git a/crates/wallet-app/src/swap.rs b/crates/wallet-app/src/swap.rs new file mode 100644 index 0000000..84ca5c5 --- /dev/null +++ b/crates/wallet-app/src/swap.rs @@ -0,0 +1,237 @@ +//! Swap commands and the order poller (blackbeard/wallet #39, #41). +//! +//! Quotes go straight to the provider; an order is a wet quote persisted +//! with its deposit address, then followed by the poller, which asks the +//! provider with backoff while the order is open and emits +//! `orders:updated` on every change. Orders left open by a previous run +//! are picked up at start. + +use std::sync::Arc; +use std::time::Duration; + +use tauri::{Emitter, Manager, State}; +use wallet_core::orders::{Step, advance, poll_delay_secs, without_asking}; +use wallet_core::ports::{OrderStore, SwapProvider, SwapRequest}; +use wallet_entities::{ + Amount, AssetId, AssetInfo, Order, OrderState, SwapAsk, SwapPreview, WalletError, +}; + +use crate::state::AppState; + +fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn swap_error(e: wallet_core::ports::SwapError) -> WalletError { + use wallet_core::ports::SwapError::*; + match e { + Unavailable(m) => WalletError::Chain(format!("swaps unavailable: {m}")), + UnsupportedPair(a) => WalletError::InvalidInput(format!("{a} cannot be swapped here")), + Other(m) => WalletError::Chain(m), + } +} + +/// The assets the provider lists and the registry maps, as the wallet +/// names them: what the swap screen offers on either side. +#[tauri::command] +pub async fn swap_assets(state: State<'_, AppState>) -> Result, WalletError> { + state.swap.supported_assets().await.map_err(swap_error) +} + +/// A dry quote: what `amount` of `from` would give in `to`, without +/// reserving a deposit address. `amount` is in the origin asset's units. +#[tauri::command] +pub async fn swap_preview( + state: State<'_, AppState>, + ask: SwapAsk, +) -> Result { + let (from_info, to_info) = pair(&state, &ask.from, &ask.to)?; + let amount_in = + Amount::parse(&ask.amount, from_info.decimals).map_err(WalletError::InvalidInput)?; + let req = SwapRequest { + from: ask.from, + to: ask.to, + amount_in, + recipient: ask.recipient, + refund_to: ask.refund_to, + slippage_bps: ask.slippage_bps, + }; + let raw = state.swap.quote_request(&req, true).map_err(swap_error)?; + let resp = state.swap.quote_raw(&raw).await.map_err(swap_error)?; + let parse = |s: &str| { + s.parse::() + .map_err(|e| WalletError::Chain(format!("the provider's amount {s:?}: {e}"))) + }; + Ok(SwapPreview { + amount_in, + amount_out: Amount::new(parse(&resp.quote.amount_out)?, to_info.decimals), + min_amount_out: Amount::new(parse(&resp.quote.min_amount_out)?, to_info.decimals), + time_estimate_secs: resp.quote.time_estimate.max(0.0) as u64, + }) +} + +/// A wet quote, persisted as an order: the deposit address to pay and the +/// deadline. The poller follows it from here. +#[tauri::command] +pub async fn swap_order_create( + app: tauri::AppHandle, + state: State<'_, AppState>, + ask: SwapAsk, +) -> Result { + let (from_info, to_info) = pair(&state, &ask.from, &ask.to)?; + let amount_in = + Amount::parse(&ask.amount, from_info.decimals).map_err(WalletError::InvalidInput)?; + let SwapAsk { + from, + to, + recipient, + refund_to, + slippage_bps, + .. + } = ask; + let req = SwapRequest { + from: from.clone(), + to: to.clone(), + amount_in, + recipient: recipient.clone(), + refund_to: refund_to.clone(), + slippage_bps, + }; + let raw = state.swap.quote_request(&req, false).map_err(swap_error)?; + let resp = state.swap.quote_raw(&raw).await.map_err(swap_error)?; + let deposit_address = resp + .quote + .deposit_address + .clone() + .ok_or_else(|| WalletError::Chain("the provider gave no deposit address".into()))?; + let amount_out = resp + .quote + .amount_out + .parse::() + .map_err(|e| WalletError::Chain(format!("the provider's amount: {e}")))?; + let deadline_unix = resp + .quote + .deadline + .as_deref() + .and_then(|d| chrono::DateTime::parse_from_rfc3339(d).ok()) + .map(|d| d.timestamp().max(0) as u64) + .unwrap_or_else(|| now_unix() + 3600); + let now = now_unix(); + let order = Order { + id: deposit_address.clone(), + provider: state.swap.name().to_owned(), + from, + to, + amount_in, + amount_out: Amount::new(amount_out, to_info.decimals), + deposit_address, + deposit_memo: resp.quote.deposit_memo.clone(), + recipient, + refund_to, + deadline_unix, + created_unix: now, + updated_unix: now, + state: OrderState::Quoted, + deposit_tx: None, + }; + state + .orders + .put(&order) + .map_err(|e| WalletError::Internal(e.to_string()))?; + spawn_follow(app, order.id.clone()); + Ok(order) +} + +/// Every order, newest first. +#[tauri::command] +pub fn orders_list(state: State<'_, AppState>) -> Result, WalletError> { + state + .orders + .list() + .map_err(|e| WalletError::Internal(e.to_string())) +} + +fn pair( + state: &AppState, + from: &AssetId, + to: &AssetId, +) -> Result<(AssetInfo, AssetInfo), WalletError> { + let name = |a: &AssetId| wallet_core::assets::describe(a); + let f = state + .assets + .get(from) + .ok_or_else(|| WalletError::InvalidInput(format!("{} is not a known asset", name(from))))?; + let t = state + .assets + .get(to) + .ok_or_else(|| WalletError::InvalidInput(format!("{} is not a known asset", name(to))))?; + Ok((f.info.clone(), t.info.clone())) +} + +/// Pick up every order still open from a previous run. +pub fn resume_open_orders(app: &tauri::AppHandle) { + let state = app.state::(); + match state.orders.open() { + Ok(open) => { + for o in open { + tracing::info!(order = %o.id, state = ?o.state, "resuming an open swap order"); + spawn_follow(app.clone(), o.id); + } + } + Err(e) => tracing::warn!(%e, "could not read open orders"), + } +} + +/// Follow one order until it is terminal: ask the provider with backoff, +/// persist each move, tell the UI. +fn spawn_follow(app: tauri::AppHandle, id: String) { + tauri::async_runtime::spawn(async move { + let state = app.state::(); + let orders: Arc = state.orders.clone(); + let swap = state.swap.clone(); + let mut unchanged: u32 = 0; + loop { + let Ok(Some(mut order)) = orders.get(&id) else { + return; + }; + if order.state.is_terminal() { + return; + } + let now = now_unix(); + let moved = match without_asking(&order, now) { + Some(next) => Some(next), + None => match swap.status(&id).await { + Ok(status) => match advance(&order, &status, now) { + Step::Moved(next) => Some(next), + Step::Unchanged => None, + }, + Err(e) => { + tracing::warn!(order = %id, %e, "the provider did not answer; asking later"); + None + } + }, + }; + if let Some(next) = moved { + order.state = next; + order.updated_unix = now; + if let Err(e) = orders.put(&order) { + tracing::warn!(order = %id, %e, "could not persist the order"); + } + let _ = app.emit("orders:updated", &order); + unchanged = 0; + if order.state.is_terminal() { + return; + } + } else { + unchanged = unchanged.saturating_add(1); + } + // Backoff with jitter, so a fleet of wallets does not ask in step. + let base = poll_delay_secs(&order.state, unchanged); + let jitter = now % 7; + tokio::time::sleep(Duration::from_secs(base + jitter)).await; + } + }); +} diff --git a/crates/wallet-core/src/lib.rs b/crates/wallet-core/src/lib.rs index 9e0d81d..da6f1dc 100644 --- a/crates/wallet-core/src/lib.rs +++ b/crates/wallet-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod assets; pub mod backup; +pub mod orders; pub mod paths; pub mod ports; pub mod profiles; diff --git a/crates/wallet-core/src/orders.rs b/crates/wallet-core/src/orders.rs new file mode 100644 index 0000000..bb05087 --- /dev/null +++ b/crates/wallet-core/src/orders.rs @@ -0,0 +1,185 @@ +//! Swap orders as a state machine (blackbeard/wallet #39). +//! +//! `Quoted → AwaitingDeposit → Processing → Success | Refunded | Failed`, +//! with `Expired` when the quote's deadline passes before a deposit is +//! seen. Transitions come from the provider's status answers; the +//! machine decides what a status means from where the order is, refuses +//! to move backwards, and decides when to stop asking. Persistence and +//! polling live in the data and app crates; this is the pure part. + +use wallet_entities::{Order, OrderState}; + +use crate::ports::SwapStatus; + +/// What a provider's answer does to an order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Step { + /// Nothing new. + Unchanged, + /// Moved to this state; persist it. + Moved(OrderState), +} + +/// Apply a status answer to an order at `now` (unix seconds). +pub fn advance(order: &Order, status: &SwapStatus, now: u64) -> Step { + if order.state.is_terminal() { + return Step::Unchanged; + } + let next = match status { + SwapStatus::AwaitingDeposit => match order.state { + // Past the deadline with no deposit: the provider will refund + // whatever arrives late; for the wallet the order is over. + OrderState::Quoted | OrderState::AwaitingDeposit if now >= order.deadline_unix => { + OrderState::Expired + } + OrderState::Quoted => OrderState::AwaitingDeposit, + _ => return Step::Unchanged, + }, + SwapStatus::Processing => OrderState::Processing, + SwapStatus::Success { tx_out } => OrderState::Success { + tx_out: tx_out.clone(), + }, + SwapStatus::Refunded => OrderState::Refunded, + SwapStatus::Failed { reason } => OrderState::Failed { + reason: reason.clone(), + }, + }; + if next == order.state { + Step::Unchanged + } else { + Step::Moved(next) + } +} + +/// Whether an order should be asked about at all, without a network call: +/// terminal orders never; a quoted order past its deadline is expired on +/// the spot. +pub fn without_asking(order: &Order, now: u64) -> Option { + match order.state { + OrderState::Quoted if now >= order.deadline_unix => Some(OrderState::Expired), + _ => None, + } +} + +/// How long to wait before asking again, from how many times the answer +/// has been the same: 10 s, then doubling to five minutes, with the +/// caller adding jitter. A Processing order is asked more often. +pub fn poll_delay_secs(state: &OrderState, unchanged_polls: u32) -> u64 { + let base = match state { + OrderState::Processing => 5, + _ => 10, + }; + (base << unchanged_polls.min(6)).min(300) +} + +#[cfg(test)] +mod tests { + use super::*; + use wallet_entities::{Amount, AssetId, AssetKind, ChainId}; + + fn order(state: OrderState, deadline: u64) -> Order { + Order { + id: "dep".into(), + provider: "test".into(), + from: AssetId { + chain: ChainId("bitcoin".into()), + kind: AssetKind::Native, + }, + to: AssetId { + chain: ChainId("near".into()), + kind: AssetKind::Nep141("wrap.near".into()), + }, + amount_in: Amount::new(1, 8), + amount_out: Amount::new(2, 24), + deposit_address: "dep".into(), + deposit_memo: None, + recipient: "r".into(), + refund_to: "f".into(), + deadline_unix: deadline, + created_unix: 0, + updated_unix: 0, + state, + deposit_tx: None, + } + } + + #[test] + fn walks_forward_and_never_back() { + let o = order(OrderState::Quoted, 1_000); + assert_eq!( + advance(&o, &SwapStatus::AwaitingDeposit, 10), + Step::Moved(OrderState::AwaitingDeposit) + ); + let o = order(OrderState::AwaitingDeposit, 1_000); + assert_eq!( + advance(&o, &SwapStatus::AwaitingDeposit, 10), + Step::Unchanged + ); + assert_eq!( + advance(&o, &SwapStatus::Processing, 10), + Step::Moved(OrderState::Processing) + ); + let o = order(OrderState::Processing, 1_000); + // The provider's "waiting for deposit" after processing is stale + // news, not a step back. + assert_eq!( + advance(&o, &SwapStatus::AwaitingDeposit, 10), + Step::Unchanged + ); + assert_eq!( + advance( + &o, + &SwapStatus::Success { + tx_out: Some("0x1".into()) + }, + 10 + ), + Step::Moved(OrderState::Success { + tx_out: Some("0x1".into()) + }) + ); + let done = order(OrderState::Success { tx_out: None }, 1_000); + assert_eq!( + advance( + &done, + &SwapStatus::Failed { + reason: "late".into() + }, + 10 + ), + Step::Unchanged + ); + } + + #[test] + fn a_quote_past_its_deadline_expires_without_a_network_call() { + let o = order(OrderState::Quoted, 1_000); + assert_eq!(without_asking(&o, 999), None); + assert_eq!(without_asking(&o, 1_000), Some(OrderState::Expired)); + // And a provider still saying "waiting" past the deadline is the + // same answer. + assert_eq!( + advance(&o, &SwapStatus::AwaitingDeposit, 1_000), + Step::Moved(OrderState::Expired) + ); + // But a deposit that did land keeps the order alive past it. + assert_eq!( + advance(&o, &SwapStatus::Processing, 5_000), + Step::Moved(OrderState::Processing) + ); + let waiting = order(OrderState::AwaitingDeposit, 1_000); + assert_eq!( + without_asking(&waiting, 5_000), + None, + "asks once more, the provider decides" + ); + } + + #[test] + fn polling_backs_off_and_caps() { + assert_eq!(poll_delay_secs(&OrderState::Quoted, 0), 10); + assert_eq!(poll_delay_secs(&OrderState::Quoted, 3), 80); + assert_eq!(poll_delay_secs(&OrderState::Quoted, 20), 300); + assert_eq!(poll_delay_secs(&OrderState::Processing, 0), 5); + } +} diff --git a/crates/wallet-core/src/ports.rs b/crates/wallet-core/src/ports.rs index e57d068..79e07cd 100644 --- a/crates/wallet-core/src/ports.rs +++ b/crates/wallet-core/src/ports.rs @@ -446,3 +446,14 @@ pub trait ShieldedCache: Send + Sync { indexes: &[u64], ) -> Result<(), HistoryError>; } + +/// Where swap orders live between runs. The app polls every non-terminal +/// order it finds here on start. +pub trait OrderStore: Send + Sync { + fn put(&self, order: &wallet_entities::Order) -> Result<(), HistoryError>; + fn get(&self, id: &str) -> Result, HistoryError>; + /// Every order, newest first. + fn list(&self) -> Result, HistoryError>; + /// Orders still moving, oldest first. + fn open(&self) -> Result, HistoryError>; +} diff --git a/crates/wallet-data/src/history/cache.rs b/crates/wallet-data/src/history/cache.rs index bc704cb..47767c4 100644 --- a/crates/wallet-data/src/history/cache.rs +++ b/crates/wallet-data/src/history/cache.rs @@ -8,7 +8,8 @@ use rusqlite::{Connection, OptionalExtension, params}; use std::path::Path; use std::sync::Mutex; -use wallet_core::ports::{HistoryCache, HistoryError, ShieldedCache, ShieldedLeaf}; +use wallet_core::ports::{HistoryCache, HistoryError, OrderStore, ShieldedCache, ShieldedLeaf}; +use wallet_entities::Order; use wallet_entities::{ChainId, HistoryCursor, HistoryEntry, HistorySource}; pub struct SqliteHistoryCache { @@ -42,6 +43,13 @@ CREATE TABLE IF NOT EXISTS shielded_scans ( scanned_to INTEGER NOT NULL, PRIMARY KEY (chain, address) ); +CREATE TABLE IF NOT EXISTS orders ( + id TEXT PRIMARY KEY, + created_unix INTEGER NOT NULL, + updated_unix INTEGER NOT NULL, + terminal INTEGER NOT NULL, + body TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS fetches ( chain TEXT NOT NULL, address TEXT NOT NULL, @@ -328,6 +336,62 @@ impl ShieldedCache for SqliteHistoryCache { } } +impl OrderStore for SqliteHistoryCache { + fn put(&self, order: &Order) -> Result<(), HistoryError> { + let body = serde_json::to_string(order).map_err(|e| HistoryError::Other(e.to_string()))?; + self.conn()? + .execute( + "INSERT INTO orders (id, created_unix, updated_unix, terminal, body) + VALUES (?1, ?2, ?3, ?4, ?5) + ON CONFLICT (id) DO UPDATE SET updated_unix = excluded.updated_unix, + terminal = excluded.terminal, body = excluded.body", + params![ + order.id, + order.created_unix as i64, + order.updated_unix as i64, + i64::from(order.state.is_terminal()), + body + ], + ) + .map(|_| ()) + .map_err(db) + } + + fn get(&self, id: &str) -> Result, HistoryError> { + let body: Option = self + .conn()? + .query_row("SELECT body FROM orders WHERE id = ?1", params![id], |r| { + r.get(0) + }) + .optional() + .map_err(db)?; + body.map(|b| serde_json::from_str(&b).map_err(|e| HistoryError::Other(e.to_string()))) + .transpose() + } + + fn list(&self) -> Result, HistoryError> { + self.rows("SELECT body FROM orders ORDER BY created_unix DESC, id") + } + + fn open(&self) -> Result, HistoryError> { + self.rows("SELECT body FROM orders WHERE terminal = 0 ORDER BY created_unix ASC, id") + } +} + +impl SqliteHistoryCache { + fn rows(&self, sql: &str) -> Result, HistoryError> { + let conn = self.conn()?; + let mut stmt = conn.prepare(sql).map_err(db)?; + let rows = stmt.query_map([], |r| r.get::<_, String>(0)).map_err(db)?; + let mut out = Vec::new(); + for row in rows { + let body = row.map_err(db)?; + out.push(serde_json::from_str(&body).map_err(|e| HistoryError::Other(e.to_string()))?); + } + Ok(out) + } +} + #[cfg(test)] mod tests { use super::*; @@ -451,4 +515,65 @@ mod tests { assert!(!after[0].spent && after[1].spent); assert!(c.leaves(&chain, "qzother").unwrap().is_empty()); } + + #[test] + fn orders_survive_a_reopen_and_open_ones_are_found() { + use wallet_entities::{AssetId, AssetKind, OrderState}; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("h.sqlite"); + let mk = |id: &str, created: u64, state: OrderState| Order { + id: id.into(), + provider: "1click".into(), + from: AssetId { + chain: ChainId("bitcoin".into()), + kind: AssetKind::Native, + }, + to: AssetId { + chain: ChainId("near".into()), + kind: AssetKind::Nep141("wrap.near".into()), + }, + amount_in: Amount::new(1, 8), + amount_out: Amount::new(2, 24), + deposit_address: id.into(), + deposit_memo: None, + recipient: "r".into(), + refund_to: "f".into(), + deadline_unix: 100, + created_unix: created, + updated_unix: created, + state, + deposit_tx: None, + }; + { + let c = SqliteHistoryCache::open(&path).unwrap(); + c.put(&mk("a", 1, OrderState::Quoted)).unwrap(); + c.put(&mk("b", 2, OrderState::Success { tx_out: None })) + .unwrap(); + c.put(&mk("c", 3, OrderState::Processing)).unwrap(); + } + let c = SqliteHistoryCache::open(&path).unwrap(); + assert_eq!( + c.list() + .unwrap() + .iter() + .map(|o| o.id.as_str()) + .collect::>(), + ["c", "b", "a"] + ); + assert_eq!( + c.open() + .unwrap() + .iter() + .map(|o| o.id.as_str()) + .collect::>(), + ["a", "c"] + ); + let mut a = c.get("a").unwrap().unwrap(); + a.state = OrderState::Expired; + a.updated_unix = 9; + c.put(&a).unwrap(); + assert_eq!(c.open().unwrap().len(), 1); + assert_eq!(c.get("a").unwrap().unwrap().state, OrderState::Expired); + assert!(c.get("zzz").unwrap().is_none()); + } } diff --git a/crates/wallet-data/src/settings.rs b/crates/wallet-data/src/settings.rs index d32475d..4f5cb5b 100644 --- a/crates/wallet-data/src/settings.rs +++ b/crates/wallet-data/src/settings.rs @@ -83,6 +83,7 @@ mod tests { idle_lock_seconds: 120, show_testnets: true, account_names: [("qz1".to_string(), "savings".to_string())].into(), + preview_swaps: true, }; store.save(&changed).unwrap(); assert_eq!(store.load().unwrap(), changed); diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index 9eedd00..29e8758 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -443,6 +443,10 @@ pub struct Settings { /// Labels people give their accounts, by address. Cosmetic: an /// address without one is shown as itself. pub account_names: BTreeMap, + /// 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. + pub preview_swaps: bool, } impl Default for Settings { @@ -452,6 +456,7 @@ impl Default for Settings { idle_lock_seconds: 10 * 60, show_testnets: false, account_names: BTreeMap::new(), + preview_swaps: false, } } } @@ -673,3 +678,95 @@ pub struct WormholeSummary { #[ts(type = "number")] pub leaf_count: u64, } + +/// Where a swap order is in its life (blackbeard/wallet #39). Terminal +/// states are `Success`, `Refunded`, `Failed` and `Expired`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[serde(tag = "state", rename_all = "snake_case")] +#[ts(export)] +pub enum OrderState { + /// A quote with a deposit address; nothing paid yet. + Quoted, + /// The provider has seen the deposit or is waiting for it to confirm. + AwaitingDeposit, + /// The provider is executing the swap. + Processing, + Success { + tx_out: Option, + }, + Refunded, + Failed { + reason: String, + }, + /// The quote's deadline passed with no deposit seen. + Expired, +} + +impl OrderState { + pub fn is_terminal(&self) -> bool { + matches!( + self, + OrderState::Success { .. } + | OrderState::Refunded + | OrderState::Failed { .. } + | OrderState::Expired + ) + } +} + +/// A swap order as the wallet keeps it: the quote it was made from, where +/// to pay, and where it stands. Persisted so it survives a restart. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct Order { + /// The provider's identifier: for 1Click, the deposit address. + pub id: String, + pub provider: String, + pub from: AssetId, + pub to: AssetId, + pub amount_in: Amount, + pub amount_out: Amount, + /// Where the origin asset must be paid, and any memo it needs. + pub deposit_address: String, + pub deposit_memo: Option, + pub recipient: String, + pub refund_to: String, + /// Unix seconds; after this the provider refunds and the wallet stops asking. + #[ts(type = "number")] + pub deadline_unix: u64, + #[ts(type = "number")] + pub created_unix: u64, + #[ts(type = "number")] + pub updated_unix: u64, + pub state: OrderState, + /// The deposit transaction this wallet made itself, when it did (a + /// Quantus leg, later); `None` when an external wallet paid. + pub deposit_tx: Option, +} + +/// A dry swap quote: what an amount of one asset would give in another, +/// without a deposit address reserved. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct SwapPreview { + pub amount_in: Amount, + pub amount_out: Amount, + pub min_amount_out: Amount, + /// Seconds the provider expects after the deposit confirms. + #[ts(type = "number")] + pub time_estimate_secs: u64, +} + +/// What the swap screen asks for: both assets, the amount in the origin +/// asset's units as typed, where the result goes, where a refund goes, +/// and the slippage in basis points. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct SwapAsk { + pub from: AssetId, + pub to: AssetId, + pub amount: String, + pub recipient: String, + pub refund_to: String, + pub slippage_bps: u16, +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 4150270..0c01594 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,6 +9,7 @@ import { History } from './routes/History' import { Create, ImportFile, OnboardingIndex, Restore } from './routes/Onboarding' import { Receive } from './routes/Receive' import { Send } from './routes/Send' +import { Swap } from './routes/Swap' import { SettingsRoute } from './routes/Settings' // The webview renders; it never holds a key, decodes a transaction, or does @@ -52,6 +53,7 @@ function Gate() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/api/events.ts b/ui/src/api/events.ts index 10be92b..e51baf1 100644 --- a/ui/src/api/events.ts +++ b/ui/src/api/events.ts @@ -21,6 +21,7 @@ export function subscribe(pending: Promise): () => void { } } import type { AccountBalance } from './generated/AccountBalance' +import type { Order } from './generated/Order' import type { TxStatusInfo } from './generated/TxStatusInfo' export function onSessionLocked(handler: () => void): Promise { @@ -36,3 +37,7 @@ export function onBalancesUpdated( export function onTxStatus(handler: (status: TxStatusInfo) => void): Promise { return listen('tx:status', (e) => handler(e.payload)) } + +export function onOrdersUpdated(handler: (order: Order) => void): Promise { + return listen('orders:updated', (e) => handler(e.payload)) +} diff --git a/ui/src/api/generated/Order.ts b/ui/src/api/generated/Order.ts new file mode 100644 index 0000000..dc828b1 --- /dev/null +++ b/ui/src/api/generated/Order.ts @@ -0,0 +1,27 @@ +// 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 { AssetId } from "./AssetId"; +import type { OrderState } from "./OrderState"; + +/** + * A swap order as the wallet keeps it: the quote it was made from, where + * to pay, and where it stands. Persisted so it survives a restart. + */ +export type Order = { +/** + * The provider's identifier: for 1Click, the deposit address. + */ +id: string, provider: string, from: AssetId, to: AssetId, amount_in: Amount, amount_out: Amount, +/** + * Where the origin asset must be paid, and any memo it needs. + */ +deposit_address: string, deposit_memo: string | null, recipient: string, refund_to: string, +/** + * Unix seconds; after this the provider refunds and the wallet stops asking. + */ +deadline_unix: number, created_unix: number, updated_unix: number, state: OrderState, +/** + * The deposit transaction this wallet made itself, when it did (a + * Quantus leg, later); `None` when an external wallet paid. + */ +deposit_tx: string | null, }; diff --git a/ui/src/api/generated/OrderState.ts b/ui/src/api/generated/OrderState.ts new file mode 100644 index 0000000..04410e8 --- /dev/null +++ b/ui/src/api/generated/OrderState.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Where a swap order is in its life (blackbeard/wallet #39). Terminal + * states are `Success`, `Refunded`, `Failed` and `Expired`. + */ +export type OrderState = { "state": "quoted" } | { "state": "awaiting_deposit" } | { "state": "processing" } | { "state": "success", tx_out: string | null, } | { "state": "refunded" } | { "state": "failed", reason: string, } | { "state": "expired" }; diff --git a/ui/src/api/generated/Settings.ts b/ui/src/api/generated/Settings.ts index 7c98f8c..76e26a0 100644 --- a/ui/src/api/generated/Settings.ts +++ b/ui/src/api/generated/Settings.ts @@ -21,4 +21,10 @@ show_testnets: boolean, * Labels people give their accounts, by address. Cosmetic: an * address without one is shown as itself. */ -account_names: { [key in string]?: string }, }; +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. + */ +preview_swaps: boolean, }; diff --git a/ui/src/api/generated/SwapAsk.ts b/ui/src/api/generated/SwapAsk.ts new file mode 100644 index 0000000..afbea12 --- /dev/null +++ b/ui/src/api/generated/SwapAsk.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AssetId } from "./AssetId"; + +/** + * What the swap screen asks for: both assets, the amount in the origin + * asset's units as typed, where the result goes, where a refund goes, + * and the slippage in basis points. + */ +export type SwapAsk = { from: AssetId, to: AssetId, amount: string, recipient: string, refund_to: string, slippage_bps: number, }; diff --git a/ui/src/api/generated/SwapPreview.ts b/ui/src/api/generated/SwapPreview.ts new file mode 100644 index 0000000..a8d5f6c --- /dev/null +++ b/ui/src/api/generated/SwapPreview.ts @@ -0,0 +1,12 @@ +// 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"; + +/** + * A dry swap quote: what an amount of one asset would give in another, + * without a deposit address reserved. + */ +export type SwapPreview = { amount_in: Amount, amount_out: Amount, min_amount_out: Amount, +/** + * Seconds the provider expects after the deposit confirms. + */ +time_estimate_secs: number, }; diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index 00d43d2..166e4f4 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -14,7 +14,11 @@ import type { PreparedTransferInfo } from './generated/PreparedTransferInfo' import type { CreationStart } from './generated/CreationStart' import type { SessionStatusInfo } from './generated/SessionStatusInfo' import type { Settings } from './generated/Settings' +import type { AssetInfo } from './generated/AssetInfo' import type { HistoryCursor } from './generated/HistoryCursor' +import type { Order } from './generated/Order' +import type { SwapAsk } from './generated/SwapAsk' +import type { SwapPreview } from './generated/SwapPreview' import type { HistoryPage } from './generated/HistoryPage' import type { SignatureScheme } from './generated/SignatureScheme' import type { WalletSummary } from './generated/WalletSummary' @@ -198,3 +202,22 @@ export function historyPage( export function wormholeSummary(chain: ChainId): Promise { return command('wormhole_summary', { chain }) } + +// Swaps, in preview: the assets the provider and the registry agree on, a +// dry quote, an order (a wet quote persisted with its deposit address), +// and the list. Rust follows open orders and emits `orders:updated`. +export function swapAssets(): Promise { + return command('swap_assets') +} + +export function swapPreview(ask: SwapAsk): Promise { + return command('swap_preview', { ask }) +} + +export function swapOrderCreate(ask: SwapAsk): Promise { + return command('swap_order_create', { ask }) +} + +export function ordersList(): Promise { + return command('orders_list') +} diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index 904dda6..755aabc 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ const SECTIONS = [ { to: '/send', label: 'Send' }, { to: '/receive', label: 'Receive' }, { to: '/history', label: 'History' }, + { to: '/swap', label: 'Swap' }, { to: '/settings', label: 'Settings' }, ] diff --git a/ui/src/routes/Settings.tsx b/ui/src/routes/Settings.tsx index 53f7904..30140d0 100644 --- a/ui/src/routes/Settings.tsx +++ b/ui/src/routes/Settings.tsx @@ -43,6 +43,16 @@ export function SettingsRoute() { Offer testnets in the network switcher +
+ +
{ + setFrom(e.target.value) + setPreview(null) + }} + > + {list.map((a) => ( + + ))} + +
+
+ + +
+ +
+ + { + setAmount(e.target.value) + setPreview(null) + }} + /> +
+
+ + setRecipient(e.target.value)} + /> +
+
+ + setRefundTo(e.target.value)} + /> + + Where the deposit comes back if the swap does not complete by the deadline. + +
+
+ + setSlippage(e.target.value)} + /> +
+ {error &&

{error}

} + {preview && toAsset && ( + + + + + + + + + + + + + + + + + + + +
You pay + {formatAmount(preview.amount_in)} {fromAsset?.symbol} +
You get + {formatAmount(preview.amount_out)} {toAsset.symbol} +
At least + {formatAmount(preview.min_amount_out)} {toAsset.symbol} +
Takes about + {preview.time_estimate_secs} s after the + deposit confirms +
+ )} +
+ + {preview && ( + + )} +
+ + )} + + + ) +} + +function label(a: AssetInfo): string { + return `${a.symbol} on ${a.id.chain}` +} + +function DepositCard({ order, onDone }: { order: Order; onDone: () => void }) { + const [copied, setCopied] = useState(false) + return ( +
+

+ Pay {formatAmount(order.amount_in)} {symbolOf(order.from)}{' '} + to this address from an external wallet before{' '} + {new Date(order.deadline_unix * 1000).toLocaleString()}. + After that the provider refunds what arrives. +

+
+
+
+
+ +
{order.deposit_address}
+
+ {order.deposit_memo && ( +
+ +
{order.deposit_memo}
+
+ )} +
+ + +
+
+
+
+ ) +} + +function symbolOf(a: AssetId): string { + return a.kind === 'native' ? a.chain : a.chain +} + +function Orders() { + const qc = useQueryClient() + const orders = useQuery({ queryKey: ['orders'], queryFn: ordersList, refetchInterval: 30_000 }) + useEffect( + () => + subscribe( + onOrdersUpdated(() => { + qc.invalidateQueries({ queryKey: ['orders'] }) + }), + ), + [qc], + ) + if (!orders.data || orders.data.length === 0) return null + return ( +
+
+

Orders

+
+ + + + + + + + + + + {orders.data.map((o) => ( + + + + + + + ))} + +
CreatedSwapDeposit toState
{new Date(o.created_unix * 1000).toLocaleString()} + {formatAmount(o.amount_in)} {o.from.chain} → {formatAmount(o.amount_out)}{' '} + {o.to.chain} + {o.deposit_address.slice(0, 14)}…{describeState(o)}
+
+ ) +} + +function describeState(o: Order): string { + const s = o.state + switch (s.state) { + case 'quoted': + return 'waiting for your deposit' + case 'awaiting_deposit': + return 'deposit seen, confirming' + case 'processing': + return 'swapping' + case 'success': + return s.tx_out ? `done (${s.tx_out.slice(0, 10)}…)` : 'done' + case 'refunded': + return 'refunded' + case 'failed': + return `failed: ${s.reason}` + case 'expired': + return 'expired without a deposit' + } +}