feat(swap): orders as a state machine persisted in SQLite, and the preview screen
ci / gate (push) Waiting to run
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:
Generated
+1
@@ -6971,6 +6971,7 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
pub mod assets;
|
||||
pub mod backup;
|
||||
pub mod orders;
|
||||
pub mod paths;
|
||||
pub mod ports;
|
||||
pub mod profiles;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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, };
|
||||
@@ -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" };
|
||||
@@ -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, };
|
||||
|
||||
@@ -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, };
|
||||
@@ -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, };
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user