diff --git a/src/lib/components/Balance.svelte b/src/lib/components/Balance.svelte
index b2d1507..3ec4a47 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 { 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';
@@ -26,6 +27,11 @@
});
});
+ 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;
};
@@ -66,3 +72,14 @@
{/each}
+{#if price !== undefined}
+
+ {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
new file mode 100644
index 0000000..8f77314
--- /dev/null
+++ b/src/lib/services/prices.svelte.ts
@@ -0,0 +1,173 @@
+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 }[] }[];
+
+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 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 fetchPrices(chainId: string): Promise> {
+ const chain = chains[chainId];
+ if (!chain) return { usd: {} };
+
+ 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 usd: Record = {};
+ for (const [key, id] of Object.entries(coins)) {
+ const price = usdPrice(id);
+ if (price) usd[key] = price;
+ }
+ 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>({});
+ #currency = $state(readCurrency());
+
+ get currency() {
+ return this.#currency;
+ }
+
+ 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;
+ 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) {
+ 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 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);
+ 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);
+ });
+});
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 @@
+