feat(swap): a 1Click client typed from the pinned spec, behind SwapProvider
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:
2026-09-16 09:31:48 +03:00
co-authored by Claude Fable 5.1
parent 248e1bb2c9
commit 82df5bf2b4
4 changed files with 46982 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+1
View File
@@ -10,3 +10,4 @@ pub mod keystore;
pub mod mnemonic;
pub mod settings;
pub mod substrate;
pub mod swap;
+5
View File
@@ -0,0 +1,5 @@
//! Swap providers. NEAR Intents' 1Click API first.
pub mod oneclick;
pub use oneclick::OneClick;
+612
View File
@@ -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
);
}
}