feat(swap): a 1Click client typed from the pinned spec, behind SwapProvider
ci / gate (push) Successful in 11m41s
ci / gate (push) Successful in 11m41s
crates/wallet-data/spec/1click-v0-openapi.yaml pins NEAR Intents' 1Click API (0.1.10); swap/oneclick.rs types tokens, quote (with dry), deposit/submit and status from it, camelCase as the spec has them, with every documented execution status round-tripping through serde. The wallet's SwapRequest maps both assets through the asset registry and is refused by name for anything it cannot map; a status answer maps to the wallet's states with the destination tx hash on success and the refund reason on failure. The JWT is a bearer the relay sets at run time, never a constant; without it the API answers tokens and dry quotes, which is all a preview needs. Live, ignored test: a dry EXACT_INPUT quote of 10 USDC on Base to USDC on NEAR at 100 bps came back 9.974166 with a floor of 9.874424, within the slippage asked for, and reserved no deposit address. Closes #38 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -10,3 +10,4 @@ pub mod keystore;
|
||||
pub mod mnemonic;
|
||||
pub mod settings;
|
||||
pub mod substrate;
|
||||
pub mod swap;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! Swap providers. NEAR Intents' 1Click API first.
|
||||
|
||||
pub mod oneclick;
|
||||
|
||||
pub use oneclick::OneClick;
|
||||
@@ -0,0 +1,612 @@
|
||||
//! NEAR Intents' 1Click API as a `SwapProvider`.
|
||||
//!
|
||||
//! Typed from the pinned spec, `spec/1click-v0-openapi.yaml` (0.1.10):
|
||||
//! `GET /v0/tokens`, `POST /v0/quote` (with `dry`), `POST /v0/deposit/submit`
|
||||
//! and `GET /v0/status`. Field names are the spec's camelCase; the wallet
|
||||
//! speaks its own asset ids and goes through the asset registry both ways,
|
||||
//! refusing by name anything the registry does not map. The JWT is an
|
||||
//! optional bearer supplied by the relay at run time (#40), never a
|
||||
//! compile-time constant; without it the API still answers tokens and
|
||||
//! dry quotes, which is all the wallet needs to preview.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::RwLock;
|
||||
use wallet_core::assets::AssetRegistry;
|
||||
use wallet_core::ports::{SwapError, SwapProvider, SwapQuote, SwapRequest, SwapStatus};
|
||||
use wallet_entities::{Amount, AssetInfo};
|
||||
|
||||
pub const DEFAULT_BASE_URL: &str = "https://1click.chaindefuser.com";
|
||||
|
||||
/// The distribution-channel identifier 1Click asks for; lowercase.
|
||||
const REFERRAL: &str = "blackbeard";
|
||||
|
||||
pub struct OneClick {
|
||||
client: reqwest::Client,
|
||||
base: String,
|
||||
registry: AssetRegistry,
|
||||
/// Set by the relay when a session exists; read on every call.
|
||||
jwt: RwLock<Option<String>>,
|
||||
}
|
||||
|
||||
impl OneClick {
|
||||
pub fn new(registry: AssetRegistry) -> Self {
|
||||
Self::at(DEFAULT_BASE_URL, registry)
|
||||
}
|
||||
|
||||
pub fn at(base: &str, registry: AssetRegistry) -> Self {
|
||||
let client = reqwest::Client::builder()
|
||||
.user_agent(concat!("blackbeard-wallet/", env!("CARGO_PKG_VERSION")))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("a TLS client can be built");
|
||||
Self {
|
||||
client,
|
||||
base: base.trim_end_matches('/').to_owned(),
|
||||
registry,
|
||||
jwt: RwLock::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay's token for this session, or `None` to go unauthenticated.
|
||||
pub fn set_jwt(&self, jwt: Option<String>) {
|
||||
*self.jwt.write().expect("jwt lock") = jwt;
|
||||
}
|
||||
|
||||
fn request(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
match self.jwt.read().expect("jwt lock").as_deref() {
|
||||
Some(t) => req.bearer_auth(t),
|
||||
None => req,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Result<T, SwapError> {
|
||||
let status = resp.status();
|
||||
let body = resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| SwapError::Unavailable(e.to_string()))?;
|
||||
if !status.is_success() {
|
||||
let message = serde_json::from_str::<BadRequest>(&body)
|
||||
.map(|b| b.message.join("; "))
|
||||
.unwrap_or_else(|_| body.chars().take(300).collect());
|
||||
return Err(match status.as_u16() {
|
||||
401 | 403 => {
|
||||
SwapError::Unavailable(format!("1Click refused the credential: {message}"))
|
||||
}
|
||||
400 => SwapError::Other(format!("1Click rejected the request: {message}")),
|
||||
_ => SwapError::Unavailable(format!("1Click answered HTTP {status}: {message}")),
|
||||
});
|
||||
}
|
||||
serde_json::from_str(&body)
|
||||
.map_err(|e| SwapError::Other(format!("1Click answered badly: {e}")))
|
||||
}
|
||||
|
||||
/// `GET /v0/tokens`, raw.
|
||||
pub async fn tokens(&self) -> Result<Vec<Token>, SwapError> {
|
||||
let resp = self
|
||||
.request(self.client.get(format!("{}/v0/tokens", self.base)))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SwapError::Unavailable(e.to_string()))?;
|
||||
Self::read(resp).await
|
||||
}
|
||||
|
||||
/// `POST /v0/quote`, raw. `dry` previews without reserving a deposit
|
||||
/// address.
|
||||
pub async fn quote_raw(&self, req: &QuoteRequest) -> Result<QuoteResponse, SwapError> {
|
||||
let resp = self
|
||||
.request(
|
||||
self.client
|
||||
.post(format!("{}/v0/quote", self.base))
|
||||
.json(req),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SwapError::Unavailable(e.to_string()))?;
|
||||
Self::read(resp).await
|
||||
}
|
||||
|
||||
/// `POST /v0/deposit/submit`: tell 1Click about a deposit the wallet
|
||||
/// made itself, so it need not wait to notice it.
|
||||
pub async fn submit_deposit(
|
||||
&self,
|
||||
deposit_address: &str,
|
||||
tx_hash: &str,
|
||||
memo: Option<&str>,
|
||||
) -> Result<(), SwapError> {
|
||||
let body = SubmitDepositTx {
|
||||
tx_hash: tx_hash.to_owned(),
|
||||
deposit_address: deposit_address.to_owned(),
|
||||
memo: memo.map(str::to_owned),
|
||||
};
|
||||
let resp = self
|
||||
.request(
|
||||
self.client
|
||||
.post(format!("{}/v0/deposit/submit", self.base))
|
||||
.json(&body),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SwapError::Unavailable(e.to_string()))?;
|
||||
Self::read::<serde_json::Value>(resp).await.map(|_| ())
|
||||
}
|
||||
|
||||
/// `GET /v0/status`, raw. An order is identified by its deposit address.
|
||||
pub async fn status_raw(&self, deposit_address: &str) -> Result<ExecutionStatus, SwapError> {
|
||||
let resp = self
|
||||
.request(
|
||||
self.client
|
||||
.get(format!("{}/v0/status", self.base))
|
||||
.query(&[("depositAddress", deposit_address)]),
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SwapError::Unavailable(e.to_string()))?;
|
||||
Self::read(resp).await
|
||||
}
|
||||
|
||||
/// Build the spec's request from the wallet's, mapping both assets
|
||||
/// through the registry. Pure, so it is testable without the network.
|
||||
pub fn quote_request(&self, r: &SwapRequest, dry: bool) -> Result<QuoteRequest, SwapError> {
|
||||
let origin = self.registry.intents_id(&r.from)?.to_owned();
|
||||
let destination = self.registry.intents_id(&r.to)?.to_owned();
|
||||
// A deposit on a slow chain (Bitcoin) can take an hour to confirm;
|
||||
// the deadline is when the refund begins, so leave room.
|
||||
let deadline = chrono::Utc::now() + chrono::Duration::hours(2);
|
||||
Ok(QuoteRequest {
|
||||
dry,
|
||||
swap_type: SwapType::ExactInput,
|
||||
slippage_tolerance: u32::from(r.slippage_bps),
|
||||
origin_asset: origin,
|
||||
deposit_type: AddressType::OriginChain,
|
||||
destination_asset: destination,
|
||||
amount: r.amount_in.raw.to_string(),
|
||||
refund_to: r.refund_to.clone(),
|
||||
refund_type: AddressType::OriginChain,
|
||||
recipient: r.recipient.clone(),
|
||||
recipient_type: RecipientType::DestinationChain,
|
||||
deadline: deadline.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
|
||||
referral: Some(REFERRAL.to_owned()),
|
||||
quote_waiting_time_ms: Some(3_000),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn registry(&self) -> &AssetRegistry {
|
||||
&self.registry
|
||||
}
|
||||
}
|
||||
|
||||
/// A 1Click status into the wallet's, with the failure named.
|
||||
pub fn map_status(s: &ExecutionStatus) -> SwapStatus {
|
||||
match s.status {
|
||||
Status::KnownDepositTx | Status::PendingDeposit | Status::IncompleteDeposit => {
|
||||
SwapStatus::AwaitingDeposit
|
||||
}
|
||||
Status::Processing => SwapStatus::Processing,
|
||||
Status::Success => SwapStatus::Success {
|
||||
tx_out: s
|
||||
.swap_details
|
||||
.as_ref()
|
||||
.and_then(|d| d.destination_chain_tx_hashes.first())
|
||||
.map(|t| t.hash.clone()),
|
||||
},
|
||||
Status::Refunded => SwapStatus::Refunded,
|
||||
Status::Failed => SwapStatus::Failed {
|
||||
reason: s
|
||||
.swap_details
|
||||
.as_ref()
|
||||
.and_then(|d| d.refund_reason.clone())
|
||||
.unwrap_or_else(|| "1Click reported FAILED".into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SwapProvider for OneClick {
|
||||
fn name(&self) -> &'static str {
|
||||
"NEAR Intents (1Click)"
|
||||
}
|
||||
|
||||
/// The assets both 1Click lists and the registry maps, as the wallet
|
||||
/// names them. Anything 1Click lists that the registry does not is not
|
||||
/// offered; anything the registry maps that 1Click no longer lists is
|
||||
/// dropped too, so a stale registry cannot quote a dead asset.
|
||||
async fn supported_assets(&self) -> Result<Vec<AssetInfo>, SwapError> {
|
||||
let live = self.tokens().await?;
|
||||
Ok(live
|
||||
.iter()
|
||||
.filter_map(|t| self.registry.by_intents_id(&t.asset_id))
|
||||
.map(|e| e.info.clone())
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn quote(&self, request: &SwapRequest) -> Result<SwapQuote, SwapError> {
|
||||
let req = self.quote_request(request, false)?;
|
||||
let resp = self.quote_raw(&req).await?;
|
||||
let to = self.registry.get(&request.to).ok_or_else(|| {
|
||||
SwapError::UnsupportedPair(wallet_core::assets::describe(&request.to))
|
||||
})?;
|
||||
let amount_out =
|
||||
resp.quote.amount_out.parse::<u128>().map_err(|e| {
|
||||
SwapError::Other(format!("amountOut {:?}: {e}", resp.quote.amount_out))
|
||||
})?;
|
||||
let deposit_address = resp.quote.deposit_address.clone().ok_or_else(|| {
|
||||
SwapError::Other("a wet quote came back with no deposit address".into())
|
||||
})?;
|
||||
let expires_at_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(0);
|
||||
Ok(SwapQuote {
|
||||
order_id: deposit_address.clone(),
|
||||
deposit_address,
|
||||
amount_out: Amount::new(amount_out, to.info.decimals),
|
||||
expires_at_unix,
|
||||
})
|
||||
}
|
||||
|
||||
async fn status(&self, order_id: &str) -> Result<SwapStatus, SwapError> {
|
||||
Ok(map_status(&self.status_raw(order_id).await?))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the spec's shapes -------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Token {
|
||||
pub asset_id: String,
|
||||
pub decimals: u8,
|
||||
pub blockchain: String,
|
||||
pub symbol: String,
|
||||
#[serde(default)]
|
||||
pub price: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub contract_address: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum SwapType {
|
||||
ExactInput,
|
||||
ExactOutput,
|
||||
FlexInput,
|
||||
AnyInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum AddressType {
|
||||
OriginChain,
|
||||
Intents,
|
||||
ConfidentialIntents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum RecipientType {
|
||||
DestinationChain,
|
||||
Intents,
|
||||
ConfidentialIntents,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuoteRequest {
|
||||
pub dry: bool,
|
||||
pub swap_type: SwapType,
|
||||
/// Basis points: 100 is 1%.
|
||||
pub slippage_tolerance: u32,
|
||||
pub origin_asset: String,
|
||||
pub deposit_type: AddressType,
|
||||
pub destination_asset: String,
|
||||
/// Base units, as a decimal integer string.
|
||||
pub amount: String,
|
||||
pub refund_to: String,
|
||||
pub refund_type: AddressType,
|
||||
pub recipient: String,
|
||||
pub recipient_type: RecipientType,
|
||||
/// ISO 8601; when the refund begins if the swap has not completed.
|
||||
pub deadline: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub referral: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quote_waiting_time_ms: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Quote {
|
||||
/// Absent on a dry quote.
|
||||
#[serde(default)]
|
||||
pub deposit_address: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deposit_memo: Option<String>,
|
||||
pub amount_in: String,
|
||||
pub amount_in_formatted: String,
|
||||
pub min_amount_in: String,
|
||||
pub amount_out: String,
|
||||
pub amount_out_formatted: String,
|
||||
pub min_amount_out: String,
|
||||
#[serde(default)]
|
||||
pub amount_in_usd: Option<String>,
|
||||
#[serde(default)]
|
||||
pub amount_out_usd: Option<String>,
|
||||
/// Absent on a dry quote.
|
||||
#[serde(default)]
|
||||
pub deadline: Option<String>,
|
||||
#[serde(default)]
|
||||
pub time_when_inactive: Option<String>,
|
||||
/// Seconds after the deposit confirms.
|
||||
pub time_estimate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct QuoteResponse {
|
||||
pub correlation_id: String,
|
||||
pub timestamp: String,
|
||||
pub signature: String,
|
||||
pub quote_request: QuoteRequest,
|
||||
pub quote: Quote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SubmitDepositTx {
|
||||
tx_hash: String,
|
||||
deposit_address: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
memo: Option<String>,
|
||||
}
|
||||
|
||||
/// Every documented value of `GetExecutionStatusResponse.status`.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
|
||||
pub enum Status {
|
||||
KnownDepositTx,
|
||||
PendingDeposit,
|
||||
IncompleteDeposit,
|
||||
Processing,
|
||||
Success,
|
||||
Refunded,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Status {
|
||||
pub const ALL: [Status; 7] = [
|
||||
Status::KnownDepositTx,
|
||||
Status::PendingDeposit,
|
||||
Status::IncompleteDeposit,
|
||||
Status::Processing,
|
||||
Status::Success,
|
||||
Status::Refunded,
|
||||
Status::Failed,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransactionDetails {
|
||||
pub hash: String,
|
||||
#[serde(default)]
|
||||
pub explorer_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SwapDetails {
|
||||
#[serde(default)]
|
||||
pub amount_in: Option<String>,
|
||||
#[serde(default)]
|
||||
pub amount_out: Option<String>,
|
||||
#[serde(default)]
|
||||
pub slippage: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub origin_chain_tx_hashes: Vec<TransactionDetails>,
|
||||
#[serde(default)]
|
||||
pub destination_chain_tx_hashes: Vec<TransactionDetails>,
|
||||
#[serde(default)]
|
||||
pub refunded_amount: Option<String>,
|
||||
#[serde(default)]
|
||||
pub refund_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub deposited_amount: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExecutionStatus {
|
||||
pub correlation_id: String,
|
||||
pub status: Status,
|
||||
pub updated_at: String,
|
||||
#[serde(default)]
|
||||
pub swap_details: Option<SwapDetails>,
|
||||
}
|
||||
|
||||
/// A 400 body: NestJS style, `message` a string or a list.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BadRequest {
|
||||
#[serde(deserialize_with = "string_or_list")]
|
||||
message: Vec<String>,
|
||||
}
|
||||
|
||||
fn string_or_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum M {
|
||||
One(String),
|
||||
Many(Vec<String>),
|
||||
}
|
||||
Ok(match M::deserialize(d)? {
|
||||
M::One(s) => vec![s],
|
||||
M::Many(v) => v,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wallet_entities::{AssetId, AssetKind, ChainId};
|
||||
|
||||
fn provider() -> OneClick {
|
||||
OneClick::new(AssetRegistry::shipped().unwrap())
|
||||
}
|
||||
|
||||
fn asset(chain: &str, kind: AssetKind) -> AssetId {
|
||||
AssetId {
|
||||
chain: ChainId(chain.into()),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
fn usdc_near() -> AssetId {
|
||||
asset(
|
||||
"near",
|
||||
AssetKind::Nep141(
|
||||
"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1".into(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_documented_status_round_trips() {
|
||||
for s in Status::ALL {
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Status>(&json).unwrap(), s, "{json}");
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Status>("\"KNOWN_DEPOSIT_TX\"").unwrap(),
|
||||
Status::KnownDepositTx
|
||||
);
|
||||
assert!(serde_json::from_str::<Status>("\"DONE\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_maps_both_assets_through_the_registry_or_names_the_one_it_cannot() {
|
||||
let p = provider();
|
||||
let ok = p
|
||||
.quote_request(
|
||||
&SwapRequest {
|
||||
from: asset("bitcoin", AssetKind::Native),
|
||||
to: usdc_near(),
|
||||
amount_in: Amount::new(50_000, 8),
|
||||
recipient: "wrap.near".into(),
|
||||
refund_to: "bc1qexample".into(),
|
||||
slippage_bps: 100,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ok.origin_asset, "nep141:btc.omft.near");
|
||||
assert_eq!(ok.amount, "50000");
|
||||
assert_eq!(ok.slippage_tolerance, 100);
|
||||
assert!(ok.dry);
|
||||
let json = serde_json::to_value(&ok).unwrap();
|
||||
assert_eq!(json["swapType"], "EXACT_INPUT");
|
||||
assert_eq!(json["depositType"], "ORIGIN_CHAIN");
|
||||
assert_eq!(json["recipientType"], "DESTINATION_CHAIN");
|
||||
assert!(json["deadline"].as_str().unwrap().ends_with('Z'));
|
||||
|
||||
let refused = p
|
||||
.quote_request(
|
||||
&SwapRequest {
|
||||
from: asset("quantus", AssetKind::Native),
|
||||
to: usdc_near(),
|
||||
amount_in: Amount::new(1, 12),
|
||||
recipient: "x".into(),
|
||||
refund_to: "y".into(),
|
||||
slippage_bps: 50,
|
||||
},
|
||||
true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(refused, SwapError::UnsupportedPair(ref a) if a == "quantus/native"),
|
||||
"{refused}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_status_answer_maps_to_the_wallets_states() {
|
||||
let done: ExecutionStatus = serde_json::from_str(
|
||||
r#"{"correlationId":"c","status":"SUCCESS","updatedAt":"2026-09-16T00:00:00Z",
|
||||
"swapDetails":{"destinationChainTxHashes":[{"hash":"0xabc"}]}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
map_status(&done),
|
||||
SwapStatus::Success {
|
||||
tx_out: Some("0xabc".into())
|
||||
}
|
||||
);
|
||||
let failed: ExecutionStatus = serde_json::from_str(
|
||||
r#"{"correlationId":"c","status":"FAILED","updatedAt":"x","swapDetails":{"refundReason":"PARTIAL_DEPOSIT"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
map_status(&failed),
|
||||
SwapStatus::Failed {
|
||||
reason: "PARTIAL_DEPOSIT".into()
|
||||
}
|
||||
);
|
||||
let waiting: ExecutionStatus = serde_json::from_str(
|
||||
r#"{"correlationId":"c","status":"PENDING_DEPOSIT","updatedAt":"x","swapDetails":null}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(map_status(&waiting), SwapStatus::AwaitingDeposit);
|
||||
}
|
||||
|
||||
/// The live API. Unauthenticated tokens and a dry quote; a JWT in
|
||||
/// ONECLICK_JWT is used when set.
|
||||
#[tokio::test]
|
||||
#[ignore = "reaches 1click.chaindefuser.com"]
|
||||
async fn a_dry_quote_for_usdc_on_base_to_near_lands_within_slippage() {
|
||||
let p = provider();
|
||||
p.set_jwt(std::env::var("ONECLICK_JWT").ok());
|
||||
let tokens = p.tokens().await.unwrap();
|
||||
assert!(tokens.iter().any(|t| t.asset_id == "nep141:btc.omft.near"));
|
||||
let supported = p.supported_assets().await.unwrap();
|
||||
assert!(supported.iter().any(|a| a.symbol == "BTC"), "{supported:?}");
|
||||
|
||||
// USDC on Base is a transit asset the registry does not list yet, so
|
||||
// the raw request is built here; the mapped path is tested above.
|
||||
let deadline = (chrono::Utc::now() + chrono::Duration::hours(1))
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
|
||||
let req = QuoteRequest {
|
||||
dry: true,
|
||||
swap_type: SwapType::ExactInput,
|
||||
slippage_tolerance: 100,
|
||||
origin_asset: "nep141:base-0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.omft.near".into(),
|
||||
deposit_type: AddressType::OriginChain,
|
||||
destination_asset:
|
||||
"nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1".into(),
|
||||
amount: "10000000".into(),
|
||||
refund_to: "0x2527D02599Ba641c19FEa793cD0F167589a0f10D".into(),
|
||||
refund_type: AddressType::OriginChain,
|
||||
recipient: "wrap.near".into(),
|
||||
recipient_type: RecipientType::DestinationChain,
|
||||
deadline,
|
||||
referral: Some(REFERRAL.into()),
|
||||
quote_waiting_time_ms: Some(3_000),
|
||||
};
|
||||
let resp = p.quote_raw(&req).await.unwrap();
|
||||
let out: u128 = resp.quote.amount_out.parse().unwrap();
|
||||
let min: u128 = resp.quote.min_amount_out.parse().unwrap();
|
||||
// 10 USDC in, 1% slippage: the floor is within 1% of the quote, and
|
||||
// USDC to USDC comes back near par.
|
||||
assert!(min >= out * 99 / 100, "min {min} vs out {out}");
|
||||
assert!((9_000_000..=10_100_000).contains(&out), "amountOut {out}");
|
||||
assert!(
|
||||
resp.quote.deposit_address.is_none(),
|
||||
"dry quotes reserve nothing"
|
||||
);
|
||||
eprintln!(
|
||||
"dry quote: {} USDC in, {} out, min {}",
|
||||
resp.quote.amount_in_formatted, resp.quote.amount_out_formatted, min
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user