From 7c074d2c17c42ec798bb169a29ff08774143940d Mon Sep 17 00:00:00 2001 From: Christophe de Carvalho Date: Sun, 4 Oct 2026 16:03:15 +0100 Subject: [PATCH 1/2] feat: show approximate EUR value next to reward amounts Prices come from DefiLlama's keyless coins API (one batched request per chain, cached for 5 minutes in a shared runes store). CoinGecko's keyless API only accepts one contract address per request, so it can't batch a token list. USD prices are converted with EURC as the EUR rate. The value is hidden on testnets, for unpriced tokens, and when the API fails, so the reward flow is never blocked. Co-Authored-By: Claude Opus 5.5 --- src/lib/components/Balance.svelte | 23 ++++++ src/lib/services/prices.svelte.ts | 126 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/lib/services/prices.svelte.ts diff --git a/src/lib/components/Balance.svelte b/src/lib/components/Balance.svelte index b2d1507..cb561db 100644 --- a/src/lib/components/Balance.svelte +++ b/src/lib/components/Balance.svelte @@ -2,6 +2,7 @@ import { untrack } from 'svelte'; import { balances } from '#lib/services/balances.svelte.ts'; + import { PRICE_SOURCE, prices } from '#lib/services/prices.svelte.ts'; import { wallet } from '#lib/services/wallet.svelte.ts'; import type { Balance } from '#lib/types.ts'; @@ -26,6 +27,17 @@ }); }); + const price = $derived(prices.eur(token)); + const amountEur = $derived(price !== undefined && amount > 0 ? amount * price : undefined); + const balanceEur = $derived(price !== undefined && token ? token.amount * price : undefined); + + const eurFormat = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + const formatEur = (value: number) => + value > 0 && value < 0.01 ? '< 0.01 €' : `${eurFormat.format(value)} €`; + const setMax = () => { amount = token?.amount ?? 0; }; @@ -66,3 +78,14 @@ {/each} +{#if price !== undefined} +
+ {amountEur !== undefined ? `≈ ${formatEur(amountEur)}` : ''} + {#if balanceEur !== undefined} + Balance ≈ {formatEur(balanceEur)} + {/if} +
+{/if} diff --git a/src/lib/services/prices.svelte.ts b/src/lib/services/prices.svelte.ts new file mode 100644 index 0000000..c52987c --- /dev/null +++ b/src/lib/services/prices.svelte.ts @@ -0,0 +1,126 @@ +import { TOKEN_DETAILS } from '@squirrel-labs/peanut-sdk'; + +import type { Balance } from '#lib/types.ts'; + +import { isNativeToken } from './balances.svelte.ts'; +import { wallet } from './wallet.svelte.ts'; + +// DefiLlama's coins API is free, needs no API key, sends `Access-Control-Allow-Origin: *` and +// accepts many coins per request. CoinGecko's keyless API only allows one contract address per +// request, which doesn't scale to a whole token list. +// https://defillama.com/docs/api +const PRICES_API = 'https://coins.llama.fi/prices/current/'; +export const PRICE_SOURCE = 'DefiLlama'; +export const PRICE_TTL = 5 * 60 * 1000; + +// DefiLlama quotes prices in USD. EURC (Circle's euro stablecoin) is used as the USD → EUR rate, +// which is close enough for an approximate value and keeps everything in a single request. +const EUR_COIN = 'coingecko:euro-coin'; +// Ignore prices DefiLlama itself isn't confident about (illiquid tokens). +const MIN_CONFIDENCE = 0.9; + +// DefiLlama chain slug and native coin per chain. Testnets are left out on purpose. +const chains: Record = { + '1': { slug: 'ethereum', native: 'coingecko:ethereum' }, + '10': { slug: 'optimism', native: 'coingecko:ethereum' }, + '56': { slug: 'bsc', native: 'coingecko:binancecoin' }, + '100': { slug: 'xdai', native: 'coingecko:xdai' }, + '137': { slug: 'polygon', native: 'coingecko:polygon-ecosystem-token' }, + '8453': { slug: 'base', native: 'coingecko:ethereum' }, + '42161': { slug: 'arbitrum', native: 'coingecko:ethereum' }, + '43114': { slug: 'avax', native: 'coingecko:avalanche-2' }, + '59144': { slug: 'linea', native: 'coingecko:ethereum' } +}; + +const tokenDetails = TOKEN_DETAILS as { chainId: string; tokens: { address: string }[] }[]; + +type EurPrices = Record; + +const priceKey = (address: string) => (isNativeToken(address) ? 'native' : address.toLowerCase()); + +/** + * Fetches the EUR price of the native coin and of every token the Peanut SDK lists for a chain, + * in a single request. Tokens without a (confident) price are left out. + */ +export async function fetchEurPrices(chainId: string): Promise { + const chain = chains[chainId]; + if (!chain) return {}; + + const tokens = tokenDetails.find((c) => c.chainId === chainId)?.tokens ?? []; + // price key -> DefiLlama coin id + const coins: Record = { native: chain.native }; + for (const t of tokens) { + if (!isNativeToken(t.address)) + coins[priceKey(t.address)] = `${chain.slug}:${priceKey(t.address)}`; + } + + const ids = [EUR_COIN, ...Object.values(coins)]; + const res = await fetch(PRICES_API + ids.join(',')); + if (!res.ok) throw new Error(`price request failed: ${res.status}`); + const { coins: quotes }: { coins: Record } = + await res.json(); + + const usdPrice = (id: string) => { + const quote = quotes[id]; + if (!quote || !(quote.price > 0) || (quote.confidence ?? 1) < MIN_CONFIDENCE) return; + return quote.price; + }; + const eurUsd = usdPrice(EUR_COIN); + if (!eurUsd) throw new Error('missing EUR rate'); + + const prices: EurPrices = {}; + for (const [key, id] of Object.entries(coins)) { + const usd = usdPrice(id); + if (usd) prices[key] = usd / eurUsd; + } + return prices; +} + +class Prices { + byChain = $state.raw>({}); + + /** Approximate EUR price of one unit of `token`, if known. */ + eur(token?: Balance): number | undefined { + if (!token) return; + return this.byChain[token.chainId]?.eur[priceKey(token.address)]; + } +} + +export const prices = new Prices(); + +const inFlight: Record = {}; + +async function loadPrices(chainId: string) { + const cached = prices.byChain[chainId]; + if (!chains[chainId] || inFlight[chainId]) return; + if (cached && Date.now() - cached.fetchedAt < PRICE_TTL) return; + + inFlight[chainId] = true; + try { + const eur = await fetchEurPrices(chainId); + prices.byChain = { ...prices.byChain, [chainId]: { fetchedAt: Date.now(), eur } }; + } catch (err) { + // keep recent prices and try again at the next refresh, but don't show outdated ones + console.warn('Failed to fetch token prices', err); + if (cached && Date.now() - cached.fetchedAt > 3 * PRICE_TTL) { + const rest = { ...prices.byChain }; + delete rest[chainId]; + prices.byChain = rest; + } + } finally { + delete inFlight[chainId]; + } +} + +// One shared refresh loop for the connected chain, no matter how many inputs display prices. +$effect.root(() => { + $effect(() => { + // AppKit reports a default network even without a wallet, so wait for a connection + const chainId = wallet.isConnected ? wallet.chainId?.toString() : undefined; + if (!chainId || !chains[chainId]) return; + + loadPrices(chainId); + const timer = setInterval(() => loadPrices(chainId), PRICE_TTL); + return () => clearInterval(timer); + }); +}); From 56ce4b6765353a15b3fd42bb642cc8fefbfc1221 Mon Sep 17 00:00:00 2001 From: Christophe de Carvalho Date: Sun, 4 Oct 2026 16:08:58 +0100 Subject: [PATCH 2/2] feat: let users switch reward values between EUR and USD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A €/$ button in the header toggles the currency for every reward input. The choice is saved in localStorage. The store now keeps DefiLlama's USD prices plus the EURC rate, so switching needs no new request, and USD values still show if the EUR rate can't be priced. Co-Authored-By: Claude Opus 5.5 --- src/lib/components/Balance.svelte | 22 +++---- src/lib/components/CurrencySwitch.svelte | 15 +++++ src/lib/services/prices.svelte.ts | 81 +++++++++++++++++++----- src/routes/+layout.svelte | 2 + 4 files changed, 89 insertions(+), 31 deletions(-) create mode 100644 src/lib/components/CurrencySwitch.svelte diff --git a/src/lib/components/Balance.svelte b/src/lib/components/Balance.svelte index cb561db..3ec4a47 100644 --- a/src/lib/components/Balance.svelte +++ b/src/lib/components/Balance.svelte @@ -2,7 +2,7 @@ import { untrack } from 'svelte'; import { balances } from '#lib/services/balances.svelte.ts'; - import { PRICE_SOURCE, prices } from '#lib/services/prices.svelte.ts'; + import { formatPrice, PRICE_SOURCE, prices } from '#lib/services/prices.svelte.ts'; import { wallet } from '#lib/services/wallet.svelte.ts'; import type { Balance } from '#lib/types.ts'; @@ -27,16 +27,10 @@ }); }); - const price = $derived(prices.eur(token)); - const amountEur = $derived(price !== undefined && amount > 0 ? amount * price : undefined); - const balanceEur = $derived(price !== undefined && token ? token.amount * price : undefined); - - const eurFormat = new Intl.NumberFormat(undefined, { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }); - const formatEur = (value: number) => - value > 0 && value < 0.01 ? '< 0.01 €' : `${eurFormat.format(value)} €`; + const price = $derived(prices.price(token)); + const amountValue = $derived(price !== undefined && amount > 0 ? amount * price : undefined); + const balanceValue = $derived(price !== undefined && token ? token.amount * price : undefined); + const format = (value: number) => formatPrice(value, prices.currency); const setMax = () => { amount = token?.amount ?? 0; @@ -83,9 +77,9 @@ class="flex w-full justify-between gap-2 px-1 text-xs opacity-70" title="Approximate value based on {PRICE_SOURCE} prices, refreshed every few minutes" > - {amountEur !== undefined ? `≈ ${formatEur(amountEur)}` : ''} - {#if balanceEur !== undefined} - Balance ≈ {formatEur(balanceEur)} + {amountValue !== undefined ? `≈ ${format(amountValue)}` : ''} + {#if balanceValue !== undefined} + Balance ≈ {format(balanceValue)} {/if} {/if} diff --git a/src/lib/components/CurrencySwitch.svelte b/src/lib/components/CurrencySwitch.svelte new file mode 100644 index 0000000..c08d506 --- /dev/null +++ b/src/lib/components/CurrencySwitch.svelte @@ -0,0 +1,15 @@ + + + diff --git a/src/lib/services/prices.svelte.ts b/src/lib/services/prices.svelte.ts index c52987c..8f77314 100644 --- a/src/lib/services/prices.svelte.ts +++ b/src/lib/services/prices.svelte.ts @@ -34,17 +34,26 @@ const chains: Record = { const tokenDetails = TOKEN_DETAILS as { chainId: string; tokens: { address: string }[] }[]; -type EurPrices = Record; +export type Currency = 'EUR' | 'USD'; +const CURRENCY_KEY = 'currency'; + +interface ChainPrices { + fetchedAt: number; + /** USD price per price key */ + usd: Record; + /** USD value of one euro, missing when the EUR rate couldn't be priced */ + eurUsd?: number; +} const priceKey = (address: string) => (isNativeToken(address) ? 'native' : address.toLowerCase()); /** - * Fetches the EUR price of the native coin and of every token the Peanut SDK lists for a chain, - * in a single request. Tokens without a (confident) price are left out. + * Fetches the USD price of the native coin and of every token the Peanut SDK lists for a chain, + * plus the EUR rate, in a single request. Tokens without a (confident) price are left out. */ -export async function fetchEurPrices(chainId: string): Promise { +export async function fetchPrices(chainId: string): Promise> { const chain = chains[chainId]; - if (!chain) return {}; + if (!chain) return { usd: {} }; const tokens = tokenDetails.find((c) => c.chainId === chainId)?.tokens ?? []; // price key -> DefiLlama coin id @@ -65,29 +74,67 @@ export async function fetchEurPrices(chainId: string): Promise { if (!quote || !(quote.price > 0) || (quote.confidence ?? 1) < MIN_CONFIDENCE) return; return quote.price; }; - const eurUsd = usdPrice(EUR_COIN); - if (!eurUsd) throw new Error('missing EUR rate'); - const prices: EurPrices = {}; + const usd: Record = {}; for (const [key, id] of Object.entries(coins)) { - const usd = usdPrice(id); - if (usd) prices[key] = usd / eurUsd; + const price = usdPrice(id); + if (price) usd[key] = price; } - return prices; + return { usd, eurUsd: usdPrice(EUR_COIN) }; +} + +function readCurrency(): Currency { + try { + if (localStorage.getItem(CURRENCY_KEY) === 'USD') return 'USD'; + } catch { + // storage unavailable + } + return 'EUR'; } class Prices { - byChain = $state.raw>({}); + byChain = $state.raw>({}); + #currency = $state(readCurrency()); + + get currency() { + return this.#currency; + } - /** Approximate EUR price of one unit of `token`, if known. */ - eur(token?: Balance): number | undefined { + set currency(value: Currency) { + this.#currency = value; + try { + localStorage.setItem(CURRENCY_KEY, value); + } catch { + // storage unavailable + } + } + + /** Approximate price of one unit of `token` in the selected currency, if known. */ + price(token?: Balance): number | undefined { if (!token) return; - return this.byChain[token.chainId]?.eur[priceKey(token.address)]; + const chain = this.byChain[token.chainId]; + const usd = chain?.usd[priceKey(token.address)]; + if (usd === undefined) return; + if (this.currency === 'USD') return usd; + return chain.eurUsd ? usd / chain.eurUsd : undefined; } } export const prices = new Prices(); +const amountFormat = new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 +}); + +/** Formats a value in the selected currency, e.g. "4.62 €" or "$4.62". */ +export function formatPrice(value: number, currency: Currency) { + const small = value > 0 && value < 0.01; + const amount = small ? '0.01' : amountFormat.format(value); + const formatted = currency === 'USD' ? `$${amount}` : `${amount} €`; + return small ? `< ${formatted}` : formatted; +} + const inFlight: Record = {}; async function loadPrices(chainId: string) { @@ -97,8 +144,8 @@ async function loadPrices(chainId: string) { inFlight[chainId] = true; try { - const eur = await fetchEurPrices(chainId); - prices.byChain = { ...prices.byChain, [chainId]: { fetchedAt: Date.now(), eur } }; + const fetched = await fetchPrices(chainId); + prices.byChain = { ...prices.byChain, [chainId]: { fetchedAt: Date.now(), ...fetched } }; } catch (err) { // keep recent prices and try again at the next refresh, but don't show outdated ones console.warn('Failed to fetch token prices', err); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index f776333..c3e38b0 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,7 @@ import { AppBar, Toast } from '@skeletonlabs/skeleton-svelte'; import { inject } from '@vercel/analytics'; + import CurrencySwitch from '#lib/components/CurrencySwitch.svelte'; import LightSwitch from '#lib/components/LightSwitch.svelte'; import Web3Modal from '#lib/components/Web3Modal.svelte'; import { toaster } from '#lib/toaster.ts'; @@ -39,6 +40,7 @@ +