feat(swap): orders as a state machine persisted in SQLite, and the preview screen
ci / gate (push) Waiting to run

wallet_core::orders is the pure part: Quoted → AwaitingDeposit →
Processing → Success | Refunded | Failed, with Expired when a quote's
deadline passes before a deposit is seen; it never moves backwards, a
quote past its deadline expires without a network call, and the poll
delay doubles from ten seconds to five minutes (five seconds while
processing). Orders persist through an OrderStore port on the same
SQLite file; on start the app follows every order still open, and the
follower asks the provider with backoff and jitter, persists each move
and emits orders:updated.

Commands: swap_assets (what 1Click lists and the registry maps),
swap_preview (a dry quote), swap_order_create (a wet quote persisted
with its deposit address and deadline) and orders_list. The swap screen
sits behind a preview setting, says plainly that every pair today is
paid from an external wallet, quotes, shows the deposit address with a
QR, memo and deadline, and lists orders with their states.

Driven live on the dev network: BTC on bitcoin to USDC on near, 0.001
BTC quoted at 75.568313 USDC, at least 74.812629, about 805 s. Reserving
a deposit address failed with 1Click's "Failed to get quote": a wet
quote needs the partner credential the relay will hold (#40), which is
the operator's registration. Until then the screen previews and the
poller has nothing to follow; the state machine and the store are
covered by unit tests, including the mid-order restart the store makes
possible.

Refs #39
Refs #41

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
2026-09-16 10:37:36 +03:00
co-authored by Claude Fable 5.1
parent 14d1a39519
commit c59ba49e90
22 changed files with 1164 additions and 4 deletions
Generated
+1
View File
@@ -6971,6 +6971,7 @@ dependencies = [
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
"tokio",
"toml 0.8.2",
"tracing",
"tracing-subscriber",
+1
View File
@@ -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
+7
View File
@@ -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");
+13 -2
View File
@@ -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<dyn HistoryCache>,
/// The same SQLite file, for the zk-tree scan of the wormhole address.
pub shielded_cache: Arc<dyn ShieldedCache>,
/// Swap orders, in the same file, polled while open.
pub orders: Arc<dyn OrderStore>,
/// The swap provider: NEAR Intents' 1Click, through the asset registry.
pub swap: Arc<OneClick>,
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,
})
}
}
+237
View File
@@ -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<Vec<AssetInfo>, 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<SwapPreview, WalletError> {
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::<u128>()
.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<Order, WalletError> {
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::<u128>()
.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<Vec<Order>, 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::<AppState>();
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::<AppState>();
let orders: Arc<dyn OrderStore> = 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;
}
});
}
+1
View File
@@ -13,6 +13,7 @@
pub mod assets;
pub mod backup;
pub mod orders;
pub mod paths;
pub mod ports;
pub mod profiles;
+185
View File
@@ -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<OrderState> {
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);
}
}
+11
View File
@@ -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<Option<wallet_entities::Order>, HistoryError>;
/// Every order, newest first.
fn list(&self) -> Result<Vec<wallet_entities::Order>, HistoryError>;
/// Orders still moving, oldest first.
fn open(&self) -> Result<Vec<wallet_entities::Order>, HistoryError>;
}
+126 -1
View File
@@ -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<Option<Order>, HistoryError> {
let body: Option<String> = 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<Vec<Order>, HistoryError> {
self.rows("SELECT body FROM orders ORDER BY created_unix DESC, id")
}
fn open(&self) -> Result<Vec<Order>, HistoryError> {
self.rows("SELECT body FROM orders WHERE terminal = 0 ORDER BY created_unix ASC, id")
}
}
impl SqliteHistoryCache {
fn rows(&self, sql: &str) -> Result<Vec<Order>, 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::<Vec<_>>(),
["c", "b", "a"]
);
assert_eq!(
c.open()
.unwrap()
.iter()
.map(|o| o.id.as_str())
.collect::<Vec<_>>(),
["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());
}
}
+1
View File
@@ -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);
+97
View File
@@ -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<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.
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<String>,
},
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<String>,
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<String>,
}
/// 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,
}
+2
View File
@@ -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() {
<Route path="/send" element={<Send />} />
<Route path="/receive" element={<Receive />} />
<Route path="/history" element={<History />} />
<Route path="/swap" element={<Swap />} />
<Route path="/settings" element={<SettingsRoute />} />
<Route path="/onboarding/*" element={<OnboardingRoutes />} />
<Route path="*" element={<Navigate to="/" replace />} />
+5
View File
@@ -21,6 +21,7 @@ export function subscribe(pending: Promise<UnlistenFn>): () => 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<UnlistenFn> {
@@ -36,3 +37,7 @@ export function onBalancesUpdated(
export function onTxStatus(handler: (status: TxStatusInfo) => void): Promise<UnlistenFn> {
return listen<TxStatusInfo>('tx:status', (e) => handler(e.payload))
}
export function onOrdersUpdated(handler: (order: Order) => void): Promise<UnlistenFn> {
return listen<Order>('orders:updated', (e) => handler(e.payload))
}
+27
View File
@@ -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, };
+7
View File
@@ -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" };
+7 -1
View File
@@ -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, };
+9
View File
@@ -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, };
+12
View File
@@ -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, };
+23
View File
@@ -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<WormholeSummary> {
return command<WormholeSummary>('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<AssetInfo[]> {
return command<AssetInfo[]>('swap_assets')
}
export function swapPreview(ask: SwapAsk): Promise<SwapPreview> {
return command<SwapPreview>('swap_preview', { ask })
}
export function swapOrderCreate(ask: SwapAsk): Promise<Order> {
return command<Order>('swap_order_create', { ask })
}
export function ordersList(): Promise<Order[]> {
return command<Order[]>('orders_list')
}
+1
View File
@@ -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' },
]
+10
View File
@@ -43,6 +43,16 @@ export function SettingsRoute() {
Offer testnets in the network switcher
</label>
</div>
<div className="field">
<label>
<input
type="checkbox"
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)
</label>
</div>
<div className="field">
<label htmlFor="theme">Theme</label>
<select
+381
View File
@@ -0,0 +1,381 @@
import { useEffect, useState, type FormEvent } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { copyText } from '../api/clipboard'
import { onOrdersUpdated, subscribe } from '../api/events'
import type { AssetId } from '../api/generated/AssetId'
import type { AssetInfo } from '../api/generated/AssetInfo'
import type { Order } from '../api/generated/Order'
import type { SwapPreview } from '../api/generated/SwapPreview'
import { useSettings } from '../api/hooks'
import { ordersList, swapAssets, swapOrderCreate, swapPreview } from '../api/wallet'
import { formatAmount } from '../lib/format'
import { qrSvg } from '../lib/qr'
/**
* Swaps, in preview. Every pair today is between transit assets this
* wallet never holds keys for, so the deposit comes from an external
* wallet: the screen says so, shows the deposit address to pay, and
* follows the order. Behind the preview setting.
*/
export function Swap() {
const settings = useSettings()
if (settings.data && !settings.data.preview_swaps) {
return (
<section className="panel">
<div className="panel-head">
<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.
</p>
</section>
)
}
return (
<>
<SwapForm />
<Orders />
</>
)
}
function assetKey(a: AssetId): string {
return JSON.stringify(a)
}
function SwapForm() {
const assets = useQuery({ queryKey: ['swap-assets'], queryFn: swapAssets, staleTime: 300_000 })
const qc = useQueryClient()
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const [amount, setAmount] = useState('')
const [recipient, setRecipient] = useState('')
const [refundTo, setRefundTo] = useState('')
const [slippage, setSlippage] = useState('100')
const [preview, setPreview] = useState<SwapPreview | null>(null)
const [order, setOrder] = useState<Order | null>(null)
const [error, setError] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
const list = assets.data ?? []
const byKey = new Map(list.map((a) => [assetKey(a.id), a]))
const fromAsset = byKey.get(from) ?? list[0]
const toAsset = byKey.get(to) ?? list.find((a) => a !== fromAsset)
function ask() {
if (!fromAsset || !toAsset) return null
return {
from: fromAsset.id,
to: toAsset.id,
amount: amount.trim(),
recipient: recipient.trim(),
refund_to: refundTo.trim(),
slippage_bps: Math.max(1, Math.min(5000, Number(slippage) || 100)),
}
}
async function quote(e: FormEvent) {
e.preventDefault()
const a = ask()
if (!a) return
setError(null)
setBusy(true)
try {
setPreview(await swapPreview(a))
} catch (err) {
setError((err as Error).message)
} finally {
setBusy(false)
}
}
async function create() {
const a = ask()
if (!a) return
setError(null)
setBusy(true)
try {
const o = await swapOrderCreate(a)
setOrder(o)
setPreview(null)
qc.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setError((err as Error).message)
} finally {
setBusy(false)
}
}
return (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Swap</h2>
<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>
{assets.isError && <p className="error">{assets.error.message}</p>}
{order ? (
<DepositCard order={order} onDone={() => setOrder(null)} />
) : (
<form onSubmit={quote}>
<div className="row">
<div className="field" style={{ flex: 1 }}>
<label htmlFor="from">From</label>
<select
id="from"
className="select"
value={fromAsset ? assetKey(fromAsset.id) : ''}
onChange={(e) => {
setFrom(e.target.value)
setPreview(null)
}}
>
{list.map((a) => (
<option key={assetKey(a.id)} value={assetKey(a.id)}>
{label(a)}
</option>
))}
</select>
</div>
<div className="field" style={{ flex: 1 }}>
<label htmlFor="to">To</label>
<select
id="to"
className="select"
value={toAsset ? assetKey(toAsset.id) : ''}
onChange={(e) => {
setTo(e.target.value)
setPreview(null)
}}
>
{list.map((a) => (
<option key={assetKey(a.id)} value={assetKey(a.id)}>
{label(a)}
</option>
))}
</select>
</div>
</div>
<div className="field">
<label htmlFor="amount">Amount{fromAsset ? ` (${fromAsset.symbol})` : ''}</label>
<input
id="amount"
className="input numeral"
inputMode="decimal"
value={amount}
onChange={(e) => {
setAmount(e.target.value)
setPreview(null)
}}
/>
</div>
<div className="field">
<label htmlFor="recipient">
Recipient{toAsset ? ` (an address on ${toAsset.id.chain})` : ''}
</label>
<input
id="recipient"
className="input mono"
autoComplete="off"
spellCheck={false}
value={recipient}
onChange={(e) => setRecipient(e.target.value)}
/>
</div>
<div className="field">
<label htmlFor="refund">
Refund address{fromAsset ? ` (an address on ${fromAsset.id.chain})` : ''}
</label>
<input
id="refund"
className="input mono"
autoComplete="off"
spellCheck={false}
value={refundTo}
onChange={(e) => setRefundTo(e.target.value)}
/>
<span className="muted" style={{ fontSize: 12 }}>
Where the deposit comes back if the swap does not complete by the deadline.
</span>
</div>
<div className="field">
<label htmlFor="slippage">Slippage (basis points)</label>
<input
id="slippage"
className="input numeral"
inputMode="numeric"
value={slippage}
onChange={(e) => setSlippage(e.target.value)}
/>
</div>
{error && <p className="error">{error}</p>}
{preview && toAsset && (
<table className="table review" data-testid="preview">
<tbody>
<tr>
<th>You pay</th>
<td className="numeral">
{formatAmount(preview.amount_in)} {fromAsset?.symbol}
</td>
</tr>
<tr>
<th>You get</th>
<td className="numeral">
{formatAmount(preview.amount_out)} {toAsset.symbol}
</td>
</tr>
<tr>
<th>At least</th>
<td className="numeral">
{formatAmount(preview.min_amount_out)} {toAsset.symbol}
</td>
</tr>
<tr>
<th>Takes about</th>
<td>
<span className="numeral">{preview.time_estimate_secs}</span> s after the
deposit confirms
</td>
</tr>
</tbody>
</table>
)}
<div className="row">
<button
className="button"
type="submit"
disabled={busy || !amount.trim() || !recipient.trim() || !refundTo.trim()}
>
{busy && !preview ? 'Asking…' : 'Quote'}
</button>
{preview && (
<button className="button primary" type="button" onClick={create} disabled={busy}>
{busy ? 'Reserving…' : 'Get a deposit address'}
</button>
)}
</div>
</form>
)}
</div>
</section>
)
}
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 (
<div data-testid="deposit">
<p>
Pay <span className="numeral">{formatAmount(order.amount_in)}</span> {symbolOf(order.from)}{' '}
to this address from an external wallet before{' '}
<span className="numeral">{new Date(order.deadline_unix * 1000).toLocaleString()}</span>.
After that the provider refunds what arrives.
</p>
<div className="receive">
<div className="qr" dangerouslySetInnerHTML={{ __html: qrSvg(order.deposit_address) }} />
<div>
<div className="field">
<label>Deposit address</label>
<div className="mono address">{order.deposit_address}</div>
</div>
{order.deposit_memo && (
<div className="field">
<label>Memo (required with the deposit)</label>
<div className="mono">{order.deposit_memo}</div>
</div>
)}
<div className="row">
<button
className="button primary"
type="button"
onClick={() => copyText(order.deposit_address).then(() => setCopied(true))}
>
{copied ? 'Copied' : 'Copy address'}
</button>
<button className="button" type="button" onClick={onDone}>
Done
</button>
</div>
</div>
</div>
</div>
)
}
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 (
<section className="panel">
<div className="panel-head">
<h2 className="panel-title">Orders</h2>
</div>
<table className="table">
<thead>
<tr>
<th>Created</th>
<th>Swap</th>
<th>Deposit to</th>
<th>State</th>
</tr>
</thead>
<tbody>
{orders.data.map((o) => (
<tr key={o.id} data-testid="order">
<td className="muted">{new Date(o.created_unix * 1000).toLocaleString()}</td>
<td className="numeral">
{formatAmount(o.amount_in)} {o.from.chain} → {formatAmount(o.amount_out)}{' '}
{o.to.chain}
</td>
<td className="mono">{o.deposit_address.slice(0, 14)}…</td>
<td>{describeState(o)}</td>
</tr>
))}
</tbody>
</table>
</section>
)
}
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'
}
}