feat(near): cash out from the intents account to an address on another chain
ci / gate (push) Successful in 13m0s
ci / gate (push) Successful in 13m0s
The intents panel can now pay a token it holds out through 1Click (#77): USDC on Ethereum, Base, Arbitrum, Solana or NEAR; USDT or ETH on Ethereum; BTC. - cashout.toml ships the destinations with their 1Click asset ids, checked against GET /v0/tokens today, and the address form each takes. - check_address refuses a mistyped payout address before anything is priced or signed, because nothing refunds a payout to the wrong address: - EVM: 0x and 40 hex digits, with the EIP-55 checksum enforced when the address is mixed case; - Bitcoin: bech32 or base58; - Solana: base58 of 32 bytes; - NEAR: an account id. - near_cashout takes a firm 1Click quote with recipientType DESTINATION_CHAIN, the fee as appFees, and refunds to the account. It checks the echoed recipient, its type, the refund, the assets and the amount against what was asked, funds it with one mt_transfer signed by the owning wallet's key, and has the sponsor relay it. - The 1Click request builder now serves swaps and cash-outs: None pays the account itself, Some(address) pays out on the destination chain. Checked live from the dev account: - 10 wNEAR quoted at 46.38 USDC on Ethereum, Ethereum's withdrawal included, with a floor of 45.91; - the same EIP-55 example address with its last character's case flipped was refused on its checksum. Refs #77 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NUx7SPonBnB3ofU4mH41WT
This commit is contained in:
Generated
+1
@@ -6992,6 +6992,7 @@ dependencies = [
|
||||
"bs58",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha3",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
|
||||
@@ -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<CashoutDestination> {
|
||||
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<CashoutInputs, WalletError> {
|
||||
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<IntentsSwapQuote, WalletError> {
|
||||
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::<u128>().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<String, WalletError> {
|
||||
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<Destination>,
|
||||
}
|
||||
|
||||
pub fn destinations() -> Vec<Destination> {
|
||||
toml::from_str::<File>(SHIPPED)
|
||||
.expect("cashout.toml is valid; a test parses it")
|
||||
.destination
|
||||
}
|
||||
|
||||
pub fn destination(id: &str) -> Option<Destination> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
pub mod assets;
|
||||
pub mod backup;
|
||||
pub mod cashout;
|
||||
pub mod near;
|
||||
pub mod orders;
|
||||
pub mod paths;
|
||||
|
||||
@@ -785,6 +785,16 @@ pub struct IntentsSwapQuote {
|
||||
pub alternative_out: Option<Amount>,
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
|
||||
@@ -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, };
|
||||
@@ -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<string>('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<CashoutDestination[]> {
|
||||
return command<CashoutDestination[]>('near_cashout_destinations')
|
||||
}
|
||||
|
||||
export function nearCashoutQuote(
|
||||
tokenIn: string,
|
||||
destination: string,
|
||||
amount: string,
|
||||
payTo: string,
|
||||
): Promise<IntentsSwapQuote> {
|
||||
return command<IntentsSwapQuote>('near_cashout_quote', { tokenIn, destination, amount, payTo })
|
||||
}
|
||||
|
||||
export function nearCashout(
|
||||
tokenIn: string,
|
||||
destination: string,
|
||||
amount: string,
|
||||
payTo: string,
|
||||
): Promise<string> {
|
||||
return command<string>('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<NearAccountSummary> {
|
||||
|
||||
@@ -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 && (
|
||||
<SwapLeg held={a.intents} onMoved={() => account.refetch()} />
|
||||
)}
|
||||
{active && a.owner_open && a.intents.length > 0 && (
|
||||
<CashoutLeg held={a.intents} onMoved={() => account.refetch()} />
|
||||
)}
|
||||
<div className="row">
|
||||
<button className="button" type="button" onClick={() => copyText(a.account_id)}>
|
||||
Copy account
|
||||
@@ -420,3 +426,171 @@ function SwapLeg({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<div className="field" data-testid="intents-cashout">
|
||||
<label>Cash out to another chain</label>
|
||||
<form
|
||||
className="row"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
cashout.reset()
|
||||
quote.mutate()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="input numeral"
|
||||
placeholder="amount"
|
||||
value={amount}
|
||||
onChange={(e) => {
|
||||
setAmount(e.target.value)
|
||||
quote.reset()
|
||||
}}
|
||||
inputMode="decimal"
|
||||
aria-label="Amount to cash out"
|
||||
/>
|
||||
<select
|
||||
className="input"
|
||||
value={from}
|
||||
onChange={(e) => {
|
||||
setFrom(e.target.value)
|
||||
quote.reset()
|
||||
}}
|
||||
aria-label="Cash out from"
|
||||
>
|
||||
{held.map((h) => (
|
||||
<option key={h.token} value={strip(h.token)}>
|
||||
{h.symbol}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="muted">→</span>
|
||||
<select
|
||||
className="input"
|
||||
value={destination}
|
||||
onChange={(e) => {
|
||||
setDest(e.target.value)
|
||||
quote.reset()
|
||||
}}
|
||||
aria-label="Cash out as"
|
||||
>
|
||||
{destinations.data?.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.symbol} on {d.chain}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</form>
|
||||
<div className="row">
|
||||
<input
|
||||
className="input mono"
|
||||
placeholder="your address on that chain"
|
||||
value={payTo}
|
||||
onChange={(e) => {
|
||||
setPayTo(e.target.value)
|
||||
quote.reset()
|
||||
}}
|
||||
aria-label="Pay to"
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
cashout.reset()
|
||||
quote.mutate()
|
||||
}}
|
||||
disabled={!amount || !payTo || quote.isPending}
|
||||
>
|
||||
{quote.isPending ? 'Pricing…' : 'Quote'}
|
||||
</button>
|
||||
</div>
|
||||
{quote.isError && <p className="error">{quote.error.message}</p>}
|
||||
{q && (
|
||||
<>
|
||||
<table className="table review">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Route</th>
|
||||
<td>{q.venue}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>You send</th>
|
||||
<td className="numeral">
|
||||
{formatAmount(q.amount_in)} {q.symbol_in}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Fee</th>
|
||||
<td className="numeral">{(q.fee_bps / 100).toFixed(2)}%, inside the quote</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Paid to</th>
|
||||
<td className="mono address">{payTo.trim()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>You get</th>
|
||||
<td className="numeral">
|
||||
{formatAmount(q.amount_out)} {q.symbol_out}
|
||||
<span className="muted"> at least {formatAmount(q.min_out)}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
className="button"
|
||||
type="button"
|
||||
onClick={() => cashout.mutate()}
|
||||
disabled={cashout.isPending}
|
||||
>
|
||||
{cashout.isPending ? 'Sending…' : `Cash out to ${q.symbol_out}`}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{cashout.isError && <p className="error">{cashout.error.message}</p>}
|
||||
{cashout.data && (
|
||||
<p className="muted">
|
||||
Cash-out relayed (NEAR transaction{' '}
|
||||
<span className="mono">{cashout.data.slice(0, 12)}…</span>). 1Click pays out once it sees
|
||||
the transfer, usually within minutes.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user