Skip to content

Commit 0de701a

Browse files
feat(backend): Introduce ICPSwap as rates fallback provider (#12286)
# Motivation CoinGecko does not always return USD prices for ICRC ledger tokens. The app already uses ICPSwap as a fallback on the frontend; the backend should do the same so cached rates in the canister stay useful when CoinGecko is missing data. # Changes - Added ICPSwap as a supplemental HTTP source for missing ICRC prices, aligned with the public ICPSwap API used on the frontend. - Introduced a `SupplementalPriceProvider` trait and a composite refresh flow: CoinGecko runs first; only tokens without a valid USD price are passed to ordered supplementals (ICPSwap first today), so adding more providers later is a small change. - Only valid prices are written: invalid primary rows no longer overwrite good cached values. # Tests Added tests. --------- Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 3106fb5 commit 0de701a

5 files changed

Lines changed: 359 additions & 16 deletions

File tree

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
use std::collections::BTreeMap;
2+
3+
use shared::types::exchange::ExchangeData;
4+
5+
use crate::{
6+
exchange::{
7+
provider::ExchangePriceProvider, providers::coingecko::CoinGeckoProvider,
8+
supplemental::SupplementalPriceProvider,
9+
},
10+
types::storable::StoredTokenId,
11+
};
12+
13+
/// Whether the backend should treat this snapshot as a usable USD price.
14+
pub(crate) fn has_valid_price(data: &ExchangeData) -> bool {
15+
data.price.is_some_and(|p| p.is_finite() && p > 0.0)
16+
}
17+
18+
fn merge_valid_primary(
19+
primary: Vec<(StoredTokenId, ExchangeData)>,
20+
) -> BTreeMap<StoredTokenId, ExchangeData> {
21+
primary
22+
.into_iter()
23+
.filter(|(_, d)| has_valid_price(d))
24+
.collect()
25+
}
26+
27+
fn still_missing(
28+
requested: &[StoredTokenId],
29+
current: &BTreeMap<StoredTokenId, ExchangeData>,
30+
) -> Vec<StoredTokenId> {
31+
requested
32+
.iter()
33+
.filter(|t| !current.contains_key(t))
34+
.cloned()
35+
.collect()
36+
}
37+
38+
/// Runs the primary provider, then each supplemental in order, merging only valid prices.
39+
///
40+
/// Later supplementals only see tokens that still lack a valid price after earlier steps.
41+
pub(crate) async fn fetch_all_prices(
42+
primary: &CoinGeckoProvider,
43+
supplementals: &[Box<dyn SupplementalPriceProvider>],
44+
token_ids: &[StoredTokenId],
45+
) -> Vec<(StoredTokenId, ExchangeData)> {
46+
let primary_rows = primary.fetch_prices(token_ids).await.unwrap_or_else(|e| {
47+
ic_cdk::println!("Primary exchange provider failed: {e}");
48+
Vec::new()
49+
});
50+
51+
let mut map = merge_valid_primary(primary_rows);
52+
let mut missing = still_missing(token_ids, &map);
53+
54+
for provider in supplementals {
55+
if missing.is_empty() {
56+
break;
57+
}
58+
59+
match provider.supplement(&missing).await {
60+
Ok(filled) => {
61+
for (id, data) in filled {
62+
if has_valid_price(&data) {
63+
map.insert(id, data);
64+
}
65+
}
66+
missing = still_missing(token_ids, &map);
67+
}
68+
Err(err) => {
69+
ic_cdk::println!(
70+
"Supplemental exchange provider {} failed: {err}",
71+
provider.id()
72+
);
73+
}
74+
}
75+
}
76+
77+
token_ids
78+
.iter()
79+
.filter_map(|t| map.get(t).map(|d| (t.clone(), d.clone())))
80+
.collect()
81+
}
82+
83+
#[cfg(test)]
84+
mod tests {
85+
use candid::Principal;
86+
use shared::types::token_id::TokenId;
87+
88+
use super::*;
89+
90+
#[test]
91+
fn has_valid_price_accepts_positive_finite() {
92+
let d = ExchangeData {
93+
timestamp_ns: 0,
94+
price: Some(1.5),
95+
price_24h_change_pct: None,
96+
market_cap: None,
97+
};
98+
assert!(has_valid_price(&d));
99+
}
100+
101+
#[test]
102+
fn has_valid_price_rejects_none_zero_nan() {
103+
let none = ExchangeData {
104+
timestamp_ns: 0,
105+
price: None,
106+
price_24h_change_pct: None,
107+
market_cap: None,
108+
};
109+
assert!(!has_valid_price(&none));
110+
111+
let zero = ExchangeData {
112+
timestamp_ns: 0,
113+
price: Some(0.0),
114+
price_24h_change_pct: None,
115+
market_cap: None,
116+
};
117+
assert!(!has_valid_price(&zero));
118+
119+
let nan = ExchangeData {
120+
timestamp_ns: 0,
121+
price: Some(f64::NAN),
122+
price_24h_change_pct: None,
123+
market_cap: None,
124+
};
125+
assert!(!has_valid_price(&nan));
126+
}
127+
128+
#[test]
129+
fn still_missing_skips_tokens_present_in_map() {
130+
let p = Principal::from_text("ryjl3-tyaaa-aaaaa-aaaba-cai").unwrap();
131+
let t = StoredTokenId(TokenId::Icrc(p));
132+
let mut map = BTreeMap::new();
133+
map.insert(
134+
t.clone(),
135+
ExchangeData {
136+
timestamp_ns: 0,
137+
price: Some(1.0),
138+
price_24h_change_pct: None,
139+
market_cap: None,
140+
},
141+
);
142+
let requested = vec![t.clone()];
143+
assert!(still_missing(&requested, &map).is_empty());
144+
}
145+
}

src/backend/src/exchange/mod.rs

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
mod composite;
12
pub(crate) mod provider;
23
mod providers;
4+
mod supplemental;
35

46
use std::time::Duration;
57

@@ -11,7 +13,11 @@ use shared::types::{
1113
};
1214

1315
use crate::{
14-
exchange::{provider::ExchangePriceProvider, providers::coingecko::CoinGeckoProvider},
16+
exchange::{
17+
composite::fetch_all_prices,
18+
providers::{coingecko::CoinGeckoProvider, icpswap::IcpSwapProvider},
19+
supplemental::SupplementalPriceProvider,
20+
},
1521
read_state,
1622
state::{mutate_state, with_api_keys},
1723
types::storable::{Candid, StoredTokenId},
@@ -74,20 +80,14 @@ fn update_price(token_id: &StoredTokenId, exchange_data: &ExchangeData) {
7480
});
7581
}
7682

77-
async fn fetch_and_update_prices(
78-
provider: &impl ExchangePriceProvider,
79-
token_ids: &[StoredTokenId],
80-
) {
81-
match provider.fetch_prices(token_ids).await {
82-
Ok(prices) => {
83-
for (token_id, exchange_data) in &prices {
84-
update_price(token_id, exchange_data);
85-
}
86-
}
87-
Err(err) => {
88-
ic_cdk::println!("Failed to fetch prices: {err}");
89-
}
90-
}
83+
/// Ordered supplemental sources that run after `CoinGecko` for tokens still missing a valid USD
84+
/// price.
85+
///
86+
/// To add another provider: implement [`SupplementalPriceProvider`] for a new type (any token
87+
/// variant you support), place it under `exchange/providers/`, and append `Box::new(...)` here in
88+
/// priority order (first match wins; later providers only see still-missing tokens).
89+
fn supplemental_price_providers() -> Vec<Box<dyn SupplementalPriceProvider>> {
90+
vec![Box::new(IcpSwapProvider::default())]
9191
}
9292

9393
pub(crate) async fn refresh_exchange_rates() -> Result<(), ExchangeError> {
@@ -117,7 +117,12 @@ pub(crate) async fn refresh_exchange_rates() -> Result<(), ExchangeError> {
117117
return Ok(());
118118
}
119119

120-
fetch_and_update_prices(&provider, &tokens_to_fetch).await;
120+
let supplementals = supplemental_price_providers();
121+
let prices = fetch_all_prices(&provider, &supplementals, &tokens_to_fetch).await;
122+
123+
for (token_id, exchange_data) in prices {
124+
update_price(&token_id, &exchange_data);
125+
}
121126

122127
Ok(())
123128
}
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
use ic_cdk::{api::time, management_canister::HttpHeader};
2+
use serde::Deserialize;
3+
use serde_json::from_slice;
4+
use shared::types::{exchange::ExchangeData, token_id::TokenId};
5+
6+
use crate::{
7+
exchange::supplemental::{SupplementalPriceProvider, SupplementalPricesFuture},
8+
types::storable::StoredTokenId,
9+
utils::http_outcall,
10+
};
11+
12+
const DEFAULT_BASE_URL: &str = "https://api.icpswap.com";
13+
/// `ICPSwap` token info responses are small JSON objects; keep the cap tight for cycle costs.
14+
const MAX_RESPONSE_BYTES: u64 = 8_192;
15+
16+
#[derive(Debug, Deserialize)]
17+
struct IcpSwapEnvelope {
18+
code: i64,
19+
#[serde(default)]
20+
data: Option<IcpSwapTokenBody>,
21+
}
22+
23+
#[derive(Debug, Deserialize)]
24+
struct IcpSwapTokenBody {
25+
#[serde(rename = "tokenLedgerId")]
26+
_token_ledger_id: String,
27+
price: String,
28+
#[serde(rename = "priceChange24H")]
29+
price_change_24h: String,
30+
}
31+
32+
fn exchange_data_from_icpswap_envelope(
33+
parsed: IcpSwapEnvelope,
34+
timestamp_ns: u64,
35+
) -> Option<ExchangeData> {
36+
if parsed.code != 0 {
37+
return None;
38+
}
39+
let token = parsed.data?;
40+
let price: f64 = token.price.parse().ok()?;
41+
if !price.is_finite() || price <= 0.0 {
42+
return None;
43+
}
44+
let price_24h_change_pct = token
45+
.price_change_24h
46+
.parse()
47+
.ok()
48+
.filter(|v: &f64| v.is_finite());
49+
Some(ExchangeData {
50+
timestamp_ns,
51+
price: Some(price),
52+
price_24h_change_pct,
53+
market_cap: None,
54+
})
55+
}
56+
57+
fn parse_icpswap_body(body: &[u8]) -> Option<ExchangeData> {
58+
let parsed: IcpSwapEnvelope = from_slice(body).ok()?;
59+
exchange_data_from_icpswap_envelope(parsed, time())
60+
}
61+
62+
/// Supplemental USD prices for ICRC ledger tokens via [ICPSwap](https://api.icpswap.com).
63+
#[derive(Debug, Clone)]
64+
pub(crate) struct IcpSwapProvider {
65+
base_url: String,
66+
}
67+
68+
impl Default for IcpSwapProvider {
69+
fn default() -> Self {
70+
Self {
71+
base_url: DEFAULT_BASE_URL.to_string(),
72+
}
73+
}
74+
}
75+
76+
impl IcpSwapProvider {
77+
#[expect(dead_code)]
78+
pub(crate) fn with_base_url(base_url: String) -> Self {
79+
Self { base_url }
80+
}
81+
82+
async fn fetch_icrc_token_usd(
83+
&self,
84+
ledger_text: &str,
85+
) -> Result<Option<ExchangeData>, String> {
86+
let url = format!(
87+
"{}/info/token/{ledger_text}",
88+
self.base_url.trim_end_matches('/')
89+
);
90+
91+
let response = http_outcall::get(
92+
&url,
93+
vec![HttpHeader {
94+
name: "Accept".to_string(),
95+
value: "application/json".to_string(),
96+
}],
97+
MAX_RESPONSE_BYTES,
98+
)
99+
.await?;
100+
101+
Ok(parse_icpswap_body(&response.body))
102+
}
103+
}
104+
105+
impl SupplementalPriceProvider for IcpSwapProvider {
106+
fn id(&self) -> &'static str {
107+
"icpswap"
108+
}
109+
110+
fn supplement<'a>(&'a self, missing: &'a [StoredTokenId]) -> SupplementalPricesFuture<'a> {
111+
Box::pin(async move {
112+
let mut out = Vec::new();
113+
114+
for stored in missing {
115+
let StoredTokenId(TokenId::Icrc(ledger_id)) = stored else {
116+
continue;
117+
};
118+
119+
let ledger_text = ledger_id.to_text();
120+
121+
match self.fetch_icrc_token_usd(&ledger_text).await {
122+
Ok(Some(data)) => out.push((stored.clone(), data)),
123+
Ok(None) => {}
124+
Err(err) => {
125+
ic_cdk::println!("ICPSwap price fetch for {ledger_text} failed: {err}");
126+
}
127+
}
128+
}
129+
130+
Ok(out)
131+
})
132+
}
133+
}
134+
135+
#[cfg(test)]
136+
mod tests {
137+
use super::*;
138+
139+
#[test]
140+
fn parse_icpswap_body_success() {
141+
let json = br#"{"code":0,"message":null,"data":{"tokenLedgerId":"ryjl3-tyaaa-aaaaa-aaaba-cai","tokenName":"x","tokenSymbol":"X","price":"1.25","priceChange24H":"-2.5","tvlUSD":"0","tvlUSDChange24H":"0","txCount24H":"0","volumeUSD24H":"0","volumeUSD7D":"0","totalVolumeUSD":"0","priceLow24H":"0","priceHigh24H":"0","priceLow7D":"0","priceHigh7D":"0","priceLow30D":"0","priceHigh30D":"0"}}"#;
142+
let parsed: IcpSwapEnvelope = serde_json::from_slice(json).unwrap();
143+
let d = exchange_data_from_icpswap_envelope(parsed, 99).unwrap();
144+
assert_eq!(d.timestamp_ns, 99);
145+
assert_eq!(d.price, Some(1.25));
146+
assert_eq!(d.price_24h_change_pct, Some(-2.5));
147+
}
148+
149+
#[test]
150+
fn parse_icpswap_body_rejects_nonzero_code() {
151+
let json = br#"{"code":404,"message":"not found","data":null}"#;
152+
let parsed: IcpSwapEnvelope = serde_json::from_slice(json).unwrap();
153+
assert!(exchange_data_from_icpswap_envelope(parsed, 0).is_none());
154+
}
155+
156+
#[test]
157+
fn parse_icpswap_body_filters_non_finite_price_change() {
158+
let json =
159+
br#"{"code":0,"data":{"tokenLedgerId":"x","price":"1.0","priceChange24H":"NaN"}}"#;
160+
let parsed: IcpSwapEnvelope = serde_json::from_slice(json).unwrap();
161+
let d = exchange_data_from_icpswap_envelope(parsed, 0).unwrap();
162+
assert_eq!(d.price, Some(1.0));
163+
assert_eq!(d.price_24h_change_pct, None);
164+
}
165+
166+
#[test]
167+
fn parse_icpswap_body_rejects_bad_price() {
168+
let json = br#"{"code":0,"data":{"tokenLedgerId":"x","price":"0","priceChange24H":"0"}}"#;
169+
let parsed: IcpSwapEnvelope = serde_json::from_slice(json).unwrap();
170+
assert!(exchange_data_from_icpswap_envelope(parsed, 0).is_none());
171+
}
172+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
pub(crate) mod coingecko;
2+
pub(crate) mod icpswap;

0 commit comments

Comments
 (0)