diff --git a/Cargo.lock b/Cargo.lock index fea5be2..f58efea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6992,6 +6992,7 @@ dependencies = [ "bs58", "serde", "serde_json", + "sha3", "thiserror 2.0.18", "tokio", "toml 0.8.2", diff --git a/crates/wallet-app/src/commands.rs b/crates/wallet-app/src/commands.rs index 623745c..5059cb8 100644 --- a/crates/wallet-app/src/commands.rs +++ b/crates/wallet-app/src/commands.rs @@ -22,9 +22,9 @@ use wallet_data::keys::seed_from_mnemonic; use wallet_data::mnemonic; use wallet_data::substrate::{ChainManager, account_info}; use wallet_entities::{ - AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, ChainId, ChainProfile, - ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage, IntentsSwapQuote, - NearAccountSummary, OpenWalletInfo, PendingReversible, PreparedTransferInfo, + AccountBalance, AccountRef, AccountScan, Amount, AssetId, AssetKind, CashoutDestination, + ChainId, ChainProfile, ChainStatus, HighSecurityInfo, HistoryCursor, HistoryPage, + IntentsSwapQuote, NearAccountSummary, OpenWalletInfo, PendingReversible, PreparedTransferInfo, SessionAccountInfo, SessionStatusInfo, TxStage, TxStatusInfo, WormholeSummary, }; use wallet_entities::{AppInfo, CreationChallenge, CreationStart, WalletError, WalletSummary}; @@ -1405,12 +1405,13 @@ async fn price_swap( .quote_raw(&oneclick_request( account, token_in, - token_out, + &format!("nep141:{token_out}"), amount, slippage_bps, fee_bps, fee_recipient, true, + None, )) .await .ok() @@ -1479,15 +1480,20 @@ async fn price_swap( } #[allow(clippy::too_many_arguments)] +/// A 1Click quote request funded from the intents account. `pay_to` is +/// `None` for a swap that lands back in the account, or an address on the +/// destination asset's chain for a cash-out (#77). Refunds always return to +/// the account. fn oneclick_request( account: &str, token_in: &str, - token_out: &str, + destination_asset: &str, amount: u128, slippage_bps: u32, fee_bps: u32, fee_recipient: &str, dry: bool, + pay_to: Option<&str>, ) -> wallet_data::swap::oneclick::QuoteRequest { use wallet_data::swap::oneclick::{AddressType, AppFee, QuoteRequest, RecipientType, SwapType}; QuoteRequest { @@ -1496,12 +1502,16 @@ fn oneclick_request( slippage_tolerance: slippage_bps, origin_asset: format!("nep141:{token_in}"), deposit_type: AddressType::Intents, - destination_asset: format!("nep141:{token_out}"), + destination_asset: destination_asset.into(), amount: amount.to_string(), refund_to: account.into(), refund_type: AddressType::Intents, - recipient: account.into(), - recipient_type: RecipientType::Intents, + recipient: pay_to.unwrap_or(account).into(), + recipient_type: if pay_to.is_some() { + RecipientType::DestinationChain + } else { + RecipientType::Intents + }, deadline: (chrono::Utc::now() + chrono::Duration::minutes(20)) .format("%Y-%m-%dT%H:%M:%SZ") .to_string(), @@ -1645,12 +1655,13 @@ pub async fn near_swap( let req = oneclick_request( &owner.account_id, &token_in, - &token_out, + &format!("nep141:{token_out}"), raw, slippage, fee_bps, &recipient, false, + None, ); let firm = state .swap @@ -1714,6 +1725,229 @@ pub async fn near_swap( Ok(hash) } +/// Where the intents account can pay out (#77). +#[tauri::command] +pub fn near_cashout_destinations() -> Vec { + wallet_core::cashout::destinations() + .into_iter() + .map(|d| CashoutDestination { + id: d.id, + chain: d.chain, + symbol: d.symbol, + }) + .collect() +} + +/// What a cash-out needs before it is priced: a destination this wallet +/// offers, an address of its form, a known input token, and sponsor terms +/// the wallet accepts. +struct CashoutInputs { + dest: wallet_core::cashout::Destination, + symbol_in: String, + decimals_in: u8, + raw: u128, + fee_bps: u32, + fee_recipient: String, +} + +async fn cashout_inputs( + state: &AppState, + token_in: &str, + destination: &str, + amount: &str, + pay_to: &str, +) -> Result { + let dest = wallet_core::cashout::destination(destination).ok_or_else(|| { + WalletError::InvalidInput(format!( + "{destination} is not a cash-out this wallet offers" + )) + })?; + wallet_core::cashout::check_address(dest.address, pay_to).map_err(WalletError::InvalidInput)?; + let (symbol_in, decimals_in) = state + .assets + .by_intents_id(&format!("nep141:{token_in}")) + .map(|e| (e.info.symbol.clone(), e.info.decimals)) + .ok_or_else(|| { + WalletError::InvalidInput(format!("{token_in} is not an asset this wallet knows")) + })?; + let raw = Amount::parse(amount, decimals_in) + .map_err(WalletError::InvalidInput)? + .raw; + let terms = state + .near_sponsor + .terms() + .await + .map_err(|e| WalletError::Internal(format!("sponsor: {e}")))?; + let (fee_bps, fee_recipient) = + wallet_core::near::usable_terms(&terms, &state.near_network.sponsor) + .map_err(WalletError::Refused)?; + Ok(CashoutInputs { + dest, + symbol_in, + decimals_in, + raw, + fee_bps, + fee_recipient, + }) +} + +/// Price a cash-out (#77): `amount` of `token_in` from the intents account, +/// paid as `destination` to `pay_to` on its chain, through 1Click. +#[tauri::command] +pub async fn near_cashout_quote( + state: State<'_, AppState>, + token_in: String, + destination: String, + amount: String, + pay_to: String, +) -> Result { + let account = state + .settings + .load() + .map_err(|e| WalletError::Internal(e.to_string()))? + .intents_account + .ok_or_else(|| WalletError::Refused("there is no intents account yet".into()))? + .account_id; + let c = cashout_inputs(&state, &token_in, &destination, &amount, &pay_to).await?; + let r = state + .swap + .quote_raw(&oneclick_request( + &account, + &token_in, + &c.dest.id, + c.raw, + 100, + c.fee_bps, + &c.fee_recipient, + true, + Some(pay_to.trim()), + )) + .await + .map_err(|e| WalletError::Refused(format!("1Click will not quote it: {e}")))?; + let parse = |s: &str| s.parse::().unwrap_or(0); + Ok(IntentsSwapQuote { + route: "1click".into(), + venue: format!("1Click to {}", c.dest.chain), + token_in, + symbol_in: c.symbol_in, + token_out: c.dest.id.clone(), + symbol_out: format!("{} on {}", c.dest.symbol, c.dest.chain), + amount_in: Amount::new(c.raw, c.decimals_in), + fee_bps: c.fee_bps, + fee: Amount::new(0, c.decimals_in), + amount_out: Amount::new(parse(&r.quote.amount_out), c.dest.decimals), + min_out: Amount::new(parse(&r.quote.min_amount_out), c.dest.decimals), + alternative_out: None, + }) +} + +/// Cash out (#77): a firm 1Click quote paying `pay_to` on the destination +/// chain, checked against what was asked, funded by one transfer from the +/// intents account, signed by the owning wallet's key and relayed by the +/// sponsor. Returns the sponsor's transaction hash; 1Click pays out once it +/// sees the transfer. +#[tauri::command] +pub async fn near_cashout( + state: State<'_, AppState>, + token_in: String, + destination: String, + amount: String, + pay_to: String, +) -> Result { + use wallet_core::near::{INTENTS, intents_transfer_call}; + use wallet_near::delegate::DelegateAction; + use wallet_near::{NEAR_DEFAULT_PATH, NearKey}; + + let near = |e: wallet_core::ports::NearError| WalletError::Internal(format!("NEAR: {e}")); + let owner = intents_owner(&state)?; + let c = cashout_inputs(&state, &token_in, &destination, &amount, &pay_to).await?; + let held = state + .near + .view( + INTENTS, + "mt_batch_balance_of", + &serde_json::json!({ "account_id": owner.account_id, "token_ids": [format!("nep141:{token_in}")] }), + ) + .await + .map_err(near)?; + let held: u128 = held[0].as_str().and_then(|s| s.parse().ok()).unwrap_or(0); + if held < c.raw { + return Err(WalletError::InvalidInput(format!( + "the intents account holds {} of it", + Amount::new(held, c.decimals_in).to_decimal_string() + ))); + } + let req = oneclick_request( + &owner.account_id, + &token_in, + &c.dest.id, + c.raw, + 100, + c.fee_bps, + &c.fee_recipient, + false, + Some(pay_to.trim()), + ); + let firm = state + .swap + .quote_raw(&req) + .await + .map_err(|e| WalletError::Refused(format!("1Click will not quote it: {e}")))?; + let echo = &firm.quote_request; + if echo.recipient != req.recipient + || echo.recipient_type != req.recipient_type + || echo.refund_to != req.refund_to + || echo.origin_asset != req.origin_asset + || echo.destination_asset != req.destination_asset + || firm.quote.amount_in != req.amount + { + return Err(WalletError::Refused( + "1Click's quote does not match what was asked; nothing was signed".into(), + )); + } + let deposit = firm.quote.deposit_address.clone().ok_or_else(|| { + WalletError::Internal("1Click's firm quote has no deposit account".into()) + })?; + let nonce = state + .near + .access_key_nonce(&owner.account_id, &owner.public_key) + .await + .map_err(near)? + .ok_or_else(|| { + WalletError::Refused(format!( + "{} does not carry {}'s key", + owner.account_id, owner.wallet + )) + })?; + let (_, height) = state.near.final_block().await.map_err(near)?; + let call = intents_transfer_call(&deposit, &token_in, c.raw); + let signed = state + .session + .with_seed(&owner.wallet, |seed| { + let k = NearKey::derive(seed.as_bytes(), NEAR_DEFAULT_PATH)?; + DelegateAction { + sender_id: owner.account_id.clone(), + receiver_id: INTENTS.into(), + actions: vec![call], + nonce: nonce + 1, + max_block_height: height + 120, + public_key: k.public_key().to_vec(), + } + .sign(&k) + }) + .map_err(session_error)? + .ok_or_else(|| WalletError::Refused(format!("{} has no phrase", owner.wallet)))? + .map_err(|e| WalletError::Internal(e.to_string()))?; + let hash = state + .near_sponsor + .relay(&signed.encode()) + .await + .map_err(|e| WalletError::Internal(format!("sponsor: {e}")))?; + let hash = bs58::encode(hash).into_string(); + tracing::info!(to = %c.dest.chain, deposit = %deposit, tx = %hash, "cash-out relayed"); + Ok(hash) +} + /// Ask the sponsor to create the installation's intents account (#81), /// controlled by `wallet`'s phrase, then read it back. Refused when one is /// already recorded, or when any open wallet's account already exists: the diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index addd79d..cb62d5d 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -82,6 +82,9 @@ pub fn run() { commands::near_withdraw_qtc, commands::near_swap_quote, commands::near_swap, + commands::near_cashout_destinations, + commands::near_cashout_quote, + commands::near_cashout, swap::swap_assets, swap::swap_preview, swap::swap_order_create, diff --git a/crates/wallet-core/Cargo.toml b/crates/wallet-core/Cargo.toml index 1b5cf39..d9436bf 100644 --- a/crates/wallet-core/Cargo.toml +++ b/crates/wallet-core/Cargo.toml @@ -19,6 +19,7 @@ bs58.workspace = true serde.workspace = true toml.workspace = true serde_json.workspace = true +sha3.workspace = true tokio.workspace = true [dev-dependencies] diff --git a/crates/wallet-core/src/cashout.rs b/crates/wallet-core/src/cashout.rs new file mode 100644 index 0000000..d7bec69 --- /dev/null +++ b/crates/wallet-core/src/cashout.rs @@ -0,0 +1,203 @@ +//! Cash-out destinations (#77): where the intents account can pay out +//! through 1Click, and the address each takes. An address is checked here, +//! by the form its chain uses, before anything is signed; a typo in a +//! payout address is the one mistake no refund covers. + +use serde::Deserialize; +use sha3::{Digest, Keccak256}; + +const SHIPPED: &str = include_str!("cashout.toml"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AddressForm { + Evm, + Bitcoin, + Solana, + Near, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct Destination { + /// 1Click's asset id. + pub id: String, + pub chain: String, + pub symbol: String, + pub decimals: u8, + pub address: AddressForm, +} + +#[derive(Debug, Deserialize)] +struct File { + destination: Vec, +} + +pub fn destinations() -> Vec { + toml::from_str::(SHIPPED) + .expect("cashout.toml is valid; a test parses it") + .destination +} + +pub fn destination(id: &str) -> Option { + destinations().into_iter().find(|d| d.id == id) +} + +/// Whether `addr` is an address of the destination's form, with a reason +/// when it is not. +pub fn check_address(form: AddressForm, addr: &str) -> Result<(), String> { + let a = addr.trim(); + match form { + AddressForm::Evm => { + let hex = a + .strip_prefix("0x") + .filter(|h| h.len() == 40 && h.chars().all(|c| c.is_ascii_hexdigit())) + .ok_or_else(|| { + format!("{a:?} is not an Ethereum-style address (0x and 40 hex digits)") + })?; + let mixed = hex.chars().any(|c| c.is_ascii_uppercase()) + && hex.chars().any(|c| c.is_ascii_lowercase()); + if mixed && !eip55_ok(hex) { + return Err(format!( + "{a} fails its EIP-55 checksum: a character is mistyped" + )); + } + Ok(()) + } + AddressForm::Bitcoin => { + let lower = a.to_ascii_lowercase(); + let bech32 = lower.starts_with("bc1") + && (14..=74).contains(&a.len()) + && lower[3..] + .chars() + .all(|c| "qpzry9x8gf2tvdw0s3jn54khce6mua7l".contains(c)) + && (a == lower || a == a.to_ascii_uppercase()); + let legacy = (a.starts_with('1') || a.starts_with('3')) + && (26..=35).contains(&a.len()) + && bs58::decode(a).into_vec().is_ok_and(|v| v.len() == 25); + if bech32 || legacy { + Ok(()) + } else { + Err(format!("{a:?} is not a Bitcoin address")) + } + } + AddressForm::Solana => match bs58::decode(a).into_vec() { + Ok(v) if v.len() == 32 => Ok(()), + _ => Err(format!("{a:?} is not a Solana address")), + }, + AddressForm::Near => { + let implicit = a.len() == 64 && a.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')); + let named = (2..=64).contains(&a.len()) + && a.split('.').all(|part| { + !part.is_empty() + && part + .chars() + .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_')) + && !part.starts_with(['-', '_']) + && !part.ends_with(['-', '_']) + }); + if implicit || named { + Ok(()) + } else { + Err(format!("{a:?} is not a NEAR account")) + } + } + } +} + +fn eip55_ok(hex: &str) -> bool { + let hash = Keccak256::digest(hex.to_ascii_lowercase().as_bytes()); + hex.chars().enumerate().all(|(i, c)| { + if !c.is_ascii_alphabetic() { + return true; + } + let nibble = (hash[i / 2] >> if i % 2 == 0 { 4 } else { 0 }) & 0xf; + c.is_ascii_uppercase() == (nibble >= 8) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_shipped_destinations_parse_and_are_distinct() { + let d = destinations(); + assert!(d.len() >= 8); + let mut ids: Vec<_> = d.iter().map(|x| &x.id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), d.len()); + assert_eq!( + destination("nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near") + .unwrap() + .symbol, + "USDC" + ); + } + + #[test] + fn evm_addresses_are_checked_with_eip55() { + // EIP-55's own examples. + for ok in [ + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed", + "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359", + ] { + check_address(AddressForm::Evm, ok).unwrap(); + } + check_address( + AddressForm::Evm, + "0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed", + ) + .unwrap(); + assert!( + check_address( + AddressForm::Evm, + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAeD" + ) + .is_err() + ); + assert!( + check_address(AddressForm::Evm, "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeA").is_err() + ); + assert!( + check_address(AddressForm::Evm, "5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed").is_err() + ); + } + + #[test] + fn bitcoin_solana_and_near_addresses_are_checked_by_form() { + check_address( + AddressForm::Bitcoin, + "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", + ) + .unwrap(); + check_address(AddressForm::Bitcoin, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa").unwrap(); + assert!( + check_address( + AddressForm::Bitcoin, + "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdO" + ) + .is_err() + ); + assert!( + check_address( + AddressForm::Bitcoin, + "0x5aAeb6053F3E94C9b9A09f33669435E7Ef1BeAed" + ) + .is_err() + ); + check_address( + AddressForm::Solana, + "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + ) + .unwrap(); + assert!(check_address(AddressForm::Solana, "9WzDXwBbmkg8ZTbNMqUxvQ").is_err()); + check_address(AddressForm::Near, "bbspike72.near").unwrap(); + check_address( + AddressForm::Near, + "42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978", + ) + .unwrap(); + assert!(check_address(AddressForm::Near, "Bad.Near").is_err()); + } +} diff --git a/crates/wallet-core/src/cashout.toml b/crates/wallet-core/src/cashout.toml new file mode 100644 index 0000000..e4b0af2 --- /dev/null +++ b/crates/wallet-core/src/cashout.toml @@ -0,0 +1,63 @@ +# Where the intents account can pay out (#77): a 1Click withdrawal to an +# address on another chain. Each destination names its 1Click asset id (from +# GET /v0/tokens, checked 2026-10-09) and the address form it takes, which the +# wallet checks before anything is signed; 1Click checks it again. +# +# address evm (0x + 40 hex, EIP-55 checked when mixed case), bitcoin, +# solana (base58 of 32 bytes), near (a NEAR account id) + +[[destination]] +id = "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near" +chain = "Ethereum" +symbol = "USDC" +decimals = 6 +address = "evm" + +[[destination]] +id = "nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near" +chain = "Ethereum" +symbol = "USDT" +decimals = 6 +address = "evm" + +[[destination]] +id = "nep141:eth.omft.near" +chain = "Ethereum" +symbol = "ETH" +decimals = 18 +address = "evm" + +[[destination]] +id = "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near" +chain = "Base" +symbol = "USDC" +decimals = 6 +address = "evm" + +[[destination]] +id = "nep141:arb-0xaf88d065e77c8cc2239327c5edb3a432268e5831.omft.near" +chain = "Arbitrum" +symbol = "USDC" +decimals = 6 +address = "evm" + +[[destination]] +id = "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near" +chain = "Solana" +symbol = "USDC" +decimals = 6 +address = "solana" + +[[destination]] +id = "nep141:btc.omft.near" +chain = "Bitcoin" +symbol = "BTC" +decimals = 8 +address = "bitcoin" + +[[destination]] +id = "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" +chain = "NEAR" +symbol = "USDC" +decimals = 6 +address = "near" diff --git a/crates/wallet-core/src/lib.rs b/crates/wallet-core/src/lib.rs index 1f4692f..9a6f836 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 cashout; pub mod near; pub mod orders; pub mod paths; diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index 788aa83..6aff6d6 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -785,6 +785,16 @@ pub struct IntentsSwapQuote { pub alternative_out: Option, } +/// Somewhere the intents account can pay out to (#77). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] +#[ts(export)] +pub struct CashoutDestination { + /// 1Click's asset id. + pub id: String, + pub chain: String, + pub symbol: String, +} + /// One token held inside NEAR Intents. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)] #[ts(export)] diff --git a/ui/src/api/generated/CashoutDestination.ts b/ui/src/api/generated/CashoutDestination.ts new file mode 100644 index 0000000..a0d8608 --- /dev/null +++ b/ui/src/api/generated/CashoutDestination.ts @@ -0,0 +1,10 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Somewhere the intents account can pay out to (#77). + */ +export type CashoutDestination = { +/** + * 1Click's asset id. + */ +id: string, chain: string, symbol: string, }; diff --git a/ui/src/api/wallet.ts b/ui/src/api/wallet.ts index f1d803d..9784cf3 100644 --- a/ui/src/api/wallet.ts +++ b/ui/src/api/wallet.ts @@ -22,6 +22,7 @@ import type { SwapAsk } from './generated/SwapAsk' import type { SwapPreview } from './generated/SwapPreview' import type { HistoryPage } from './generated/HistoryPage' import type { WalletSummary } from './generated/WalletSummary' +import type { CashoutDestination } from './generated/CashoutDestination' import type { IntentsSwapQuote } from './generated/IntentsSwapQuote' import type { NearAccountSummary } from './generated/NearAccountSummary' import type { WormholeSummary } from './generated/WormholeSummary' @@ -256,6 +257,31 @@ export function nearSwap(tokenIn: string, tokenOut: string, amount: string): Pro return command('near_swap', { tokenIn, tokenOut, amount }) } +// Cash-out: pay a token out of the intents account to an address on +// another chain, through 1Click. Rust checks the address by the +// destination chain's form before anything is priced or signed. +export function nearCashoutDestinations(): Promise { + return command('near_cashout_destinations') +} + +export function nearCashoutQuote( + tokenIn: string, + destination: string, + amount: string, + payTo: string, +): Promise { + return command('near_cashout_quote', { tokenIn, destination, amount, payTo }) +} + +export function nearCashout( + tokenIn: string, + destination: string, + amount: string, + payTo: string, +): Promise { + return command('near_cashout', { tokenIn, destination, amount, payTo }) +} + // Ask the sponsor to create it, controlled by `wallet`'s phrase. The wallet // makes one, and only one. Returns it, read back. export function nearCreateAccount(wallet: string): Promise { diff --git a/ui/src/components/NearIntentsPanel.tsx b/ui/src/components/NearIntentsPanel.tsx index 6e8f99b..2733929 100644 --- a/ui/src/components/NearIntentsPanel.tsx +++ b/ui/src/components/NearIntentsPanel.tsx @@ -4,6 +4,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { copyText } from '../api/clipboard' import { useSession } from '../api/hooks' import { + nearCashout, + nearCashoutDestinations, + nearCashoutQuote, nearCreateAccount, nearIntents, nearQtcDepositAddress, @@ -134,6 +137,9 @@ export function NearIntentsPanel() { {active && a.owner_open && a.intents.length > 0 && ( account.refetch()} /> )} + {active && a.owner_open && a.intents.length > 0 && ( + account.refetch()} /> + )}
) } + +/** + * Cash-out (#77): a token in the intents account paid to your address on + * another chain through 1Click. The address is checked by the destination + * chain's form on the Rust side before anything is priced; a payout sent + * to a wrong address cannot be refunded. + */ +function CashoutLeg({ + held, + onMoved, +}: { + held: { token: string; symbol: string }[] + onMoved: () => void +}) { + const strip = (t: string) => t.replace(/^nep141:/, '') + const destinations = useQuery({ + queryKey: ['cashout-destinations'], + queryFn: nearCashoutDestinations, + staleTime: Infinity, + }) + const [from, setFrom] = useState(strip(held[0]?.token ?? '')) + const [dest, setDest] = useState('') + const destination = dest || destinations.data?.[0]?.id || '' + const [amount, setAmount] = useState('') + const [payTo, setPayTo] = useState('') + const quote = useMutation({ + mutationFn: () => nearCashoutQuote(from, destination, amount, payTo), + }) + const cashout = useMutation({ + mutationFn: () => nearCashout(from, destination, amount, payTo), + onSuccess: () => { + quote.reset() + setAmount('') + setTimeout(onMoved, 4000) + }, + }) + const q = quote.data + return ( +
+ +
{ + e.preventDefault() + cashout.reset() + quote.mutate() + }} + > + { + setAmount(e.target.value) + quote.reset() + }} + inputMode="decimal" + aria-label="Amount to cash out" + /> + + → + +
+
+ { + setPayTo(e.target.value) + quote.reset() + }} + aria-label="Pay to" + style={{ flex: 1 }} + /> + +
+ {quote.isError &&

{quote.error.message}

} + {q && ( + <> + + + + + + + + + + + + + + + + + + + + + + + +
Route{q.venue}
You send + {formatAmount(q.amount_in)} {q.symbol_in} +
Fee{(q.fee_bps / 100).toFixed(2)}%, inside the quote
Paid to{payTo.trim()}
You get + {formatAmount(q.amount_out)} {q.symbol_out} + at least {formatAmount(q.min_out)} +
+ + + )} + {cashout.isError &&

{cashout.error.message}

} + {cashout.data && ( +

+ Cash-out relayed (NEAR transaction{' '} + {cashout.data.slice(0, 12)}…). 1Click pays out once it sees + the transfer, usually within minutes. +

+ )} +
+ ) +}