353 lines
9.8 KiB
JavaScript
353 lines
9.8 KiB
JavaScript
/**
|
|
* Currency Service - XMR/Fiat Conversion
|
|
*
|
|
* Primary: Kraken API (no key required, reliable)
|
|
* Fallback: CoinGecko API
|
|
* Supports two modes: fiat-fix and xmr-fix
|
|
*/
|
|
|
|
const KRAKEN_API = 'https://api.kraken.com/0/public/Ticker'
|
|
const COINGECKO_API = 'https://api.coingecko.com/api/v3/simple/price'
|
|
|
|
const CURRENCY_SYMBOLS = {
|
|
XMR: 'ɱ',
|
|
EUR: '€',
|
|
USD: '$',
|
|
GBP: '£',
|
|
CHF: 'CHF',
|
|
JPY: '¥',
|
|
RUB: '₽',
|
|
BRL: 'R$'
|
|
}
|
|
|
|
const CACHE_DURATION = 60 * 60 * 1000 // 60 minutes (CoinGecko free tier is strict)
|
|
const MIN_REQUEST_INTERVAL = 120 * 1000 // 2 minutes between requests
|
|
const STORAGE_KEY = 'xmr_rates_cache'
|
|
|
|
let cachedRates = null
|
|
let cacheTimestamp = 0
|
|
let lastRequestTime = 0
|
|
let pendingRequest = null
|
|
|
|
// Load from localStorage on init
|
|
function loadFromStorage() {
|
|
try {
|
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
if (stored) {
|
|
const { rates, timestamp } = JSON.parse(stored)
|
|
if (rates && timestamp && Date.now() - timestamp < CACHE_DURATION) {
|
|
cachedRates = rates
|
|
cacheTimestamp = timestamp
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
function saveToStorage() {
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
rates: cachedRates,
|
|
timestamp: cacheTimestamp
|
|
}))
|
|
} catch (e) {
|
|
// Ignore storage errors
|
|
}
|
|
}
|
|
|
|
loadFromStorage()
|
|
|
|
/**
|
|
* Fetches current XMR rates from CoinGecko with rate limiting
|
|
* @returns {Promise<Object>} Rates per currency (e.g. { EUR: 329.05, USD: 388.87 })
|
|
*/
|
|
export async function getXmrRates() {
|
|
// Return cached rates if still valid
|
|
if (cachedRates && Date.now() - cacheTimestamp < CACHE_DURATION) {
|
|
return cachedRates
|
|
}
|
|
|
|
// If a request is already pending, wait for it
|
|
if (pendingRequest) {
|
|
return pendingRequest
|
|
}
|
|
|
|
// Rate limiting: ensure minimum interval between requests
|
|
const timeSinceLastRequest = Date.now() - lastRequestTime
|
|
if (timeSinceLastRequest < MIN_REQUEST_INTERVAL && cachedRates) {
|
|
return cachedRates
|
|
}
|
|
|
|
// Create and store the pending request
|
|
pendingRequest = fetchRates()
|
|
|
|
try {
|
|
const rates = await pendingRequest
|
|
return rates
|
|
} finally {
|
|
pendingRequest = null
|
|
}
|
|
}
|
|
|
|
async function fetchRates() {
|
|
lastRequestTime = Date.now()
|
|
|
|
// Try Kraken first, then CoinGecko as fallback
|
|
const rates = await fetchFromKraken() || await fetchFromCoinGecko()
|
|
|
|
if (rates) {
|
|
cachedRates = rates
|
|
cacheTimestamp = Date.now()
|
|
saveToStorage()
|
|
return rates
|
|
}
|
|
|
|
return cachedRates || getDefaultRates()
|
|
}
|
|
|
|
async function fetchFromKraken() {
|
|
try {
|
|
const pairs = 'XMREUR,XMRUSD,XMRGBP,XMRJPY'
|
|
const response = await fetch(`${KRAKEN_API}?pair=${pairs}`)
|
|
if (!response.ok) return null
|
|
|
|
const data = await response.json()
|
|
if (data.error?.length > 0) return null
|
|
|
|
const r = data.result
|
|
const getPrice = (key) => {
|
|
const entry = r[key]
|
|
return entry ? parseFloat(entry.c[0]) : null
|
|
}
|
|
|
|
const eur = getPrice('XXMRZEUR') || getPrice('XMREUR')
|
|
const usd = getPrice('XXMRZUSD') || getPrice('XMRUSD')
|
|
const gbp = getPrice('XXMRGBP') || getPrice('XMRGBP')
|
|
const jpy = getPrice('XXMRJPY') || getPrice('XMRJPY')
|
|
|
|
if (!eur || !usd) return null
|
|
|
|
// Kraken doesn't have CHF/RUB/BRL pairs for XMR — derive from EUR
|
|
const chf = eur * 0.97
|
|
const rub = eur * 100
|
|
const brl = usd * 5.5
|
|
|
|
return { EUR: eur, USD: usd, GBP: gbp || eur * 0.86, CHF: chf, JPY: jpy || eur * 162, RUB: rub, BRL: brl }
|
|
} catch (e) {
|
|
console.warn('Kraken API failed, trying CoinGecko:', e.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
async function fetchFromCoinGecko() {
|
|
try {
|
|
const currencies = 'eur,usd,gbp,chf,jpy,rub,brl'
|
|
const response = await fetch(`${COINGECKO_API}?ids=monero&vs_currencies=${currencies}`)
|
|
|
|
if (response.status === 429) {
|
|
console.warn('CoinGecko rate limit hit')
|
|
return null
|
|
}
|
|
|
|
const data = await response.json()
|
|
if (!data.monero) return null
|
|
|
|
return {
|
|
EUR: data.monero.eur,
|
|
USD: data.monero.usd,
|
|
GBP: data.monero.gbp,
|
|
CHF: data.monero.chf,
|
|
JPY: data.monero.jpy,
|
|
RUB: data.monero.rub,
|
|
BRL: data.monero.brl
|
|
}
|
|
} catch (e) {
|
|
console.error('CoinGecko API failed:', e.message)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fallback rates if API is unreachable
|
|
*/
|
|
function getDefaultRates() {
|
|
return {
|
|
EUR: 150,
|
|
USD: 165,
|
|
GBP: 130,
|
|
CHF: 145,
|
|
JPY: 24000,
|
|
RUB: 15000,
|
|
BRL: 850
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Converts amount to XMR
|
|
* @param {number} amount - Amount in source currency
|
|
* @param {string} currency - Source currency (EUR, USD, etc.)
|
|
* @param {Object} rates - Rates from getXmrRates()
|
|
* @returns {number} Amount in XMR
|
|
*/
|
|
export function convertToXmr(amount, currency, rates) {
|
|
if (currency === 'XMR') return amount
|
|
if (!rates[currency]) return amount
|
|
return amount / rates[currency]
|
|
}
|
|
|
|
/**
|
|
* Converts XMR to fiat
|
|
* @param {number} xmrAmount - Amount in XMR
|
|
* @param {string} currency - Target currency (EUR, USD, etc.)
|
|
* @param {Object} rates - Rates from getXmrRates()
|
|
* @returns {number} Amount in target currency
|
|
*/
|
|
export function convertFromXmr(xmrAmount, currency, rates) {
|
|
if (currency === 'XMR') return xmrAmount
|
|
if (!rates[currency]) return xmrAmount
|
|
return xmrAmount * rates[currency]
|
|
}
|
|
|
|
/**
|
|
* Converts between fiat currencies using XMR as bridge
|
|
* @param {number} amount - Amount in source currency
|
|
* @param {string} fromCurrency - Source currency
|
|
* @param {string} toCurrency - Target currency
|
|
* @param {Object} rates - Rates from getXmrRates() (XMR -> Fiat)
|
|
* @returns {number} Amount in target currency
|
|
*/
|
|
export function convertFiat(amount, fromCurrency, toCurrency, rates) {
|
|
if (fromCurrency === toCurrency) return amount
|
|
if (!rates[fromCurrency] || !rates[toCurrency]) return amount
|
|
|
|
// Use XMR as bridge: EUR -> XMR -> USD
|
|
// 1 XMR = rates[EUR] EUR, so 1 EUR = 1/rates[EUR] XMR
|
|
// 1 XMR = rates[USD] USD
|
|
// Therefore: 1 EUR = rates[USD] / rates[EUR] USD
|
|
return amount * (rates[toCurrency] / rates[fromCurrency])
|
|
}
|
|
|
|
/**
|
|
* Get user's preferred display currency from settings
|
|
* @returns {string} Currency code (default: 'USD')
|
|
*/
|
|
export function getDisplayCurrency() {
|
|
return localStorage.getItem('kashilo_currency') || 'USD'
|
|
}
|
|
|
|
/**
|
|
* Formats a price for display
|
|
* Shows price in user's preferred currency with XMR equivalent
|
|
* @param {Object} listing - Listing with price, currency, price_mode
|
|
* @param {Object} rates - Rates from getXmrRates()
|
|
* @param {string} [displayCurrency] - Override display currency
|
|
* @returns {Object} { primary, secondary, xmrAmount }
|
|
*/
|
|
export function formatPrice(listing, rates, displayCurrency = null) {
|
|
const { price, currency, price_mode } = listing
|
|
const userCurrency = displayCurrency || getDisplayCurrency()
|
|
|
|
if (!price || price === 0) {
|
|
return {
|
|
primary: 'Free',
|
|
secondary: null,
|
|
xmrAmount: 0
|
|
}
|
|
}
|
|
|
|
// XMR mode: XMR is the reference price
|
|
if (price_mode === 'xmr' || currency === 'XMR') {
|
|
const xmrPrice = currency === 'XMR' ? price : convertToXmr(price, currency, rates)
|
|
const fiatEquivalent = convertFromXmr(xmrPrice, userCurrency, rates)
|
|
|
|
return {
|
|
primary: formatFiat(fiatEquivalent, userCurrency),
|
|
secondary: `≈ ${formatXmr(xmrPrice)}`,
|
|
xmrAmount: xmrPrice
|
|
}
|
|
}
|
|
|
|
// Fiat mode: Convert to user's preferred currency
|
|
const xmrEquivalent = convertToXmr(price, currency, rates)
|
|
|
|
// Always show in user's currency as primary
|
|
const displayPrice = userCurrency === currency
|
|
? price
|
|
: convertFiat(price, currency, userCurrency, rates)
|
|
|
|
return {
|
|
primary: formatFiat(displayPrice, userCurrency),
|
|
secondary: `≈ ${formatXmr(xmrEquivalent)}`,
|
|
xmrAmount: xmrEquivalent
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Formats XMR amount
|
|
* @param {number} amount - Amount in XMR
|
|
* @returns {string} Formatted string (e.g. "0.5234 XMR")
|
|
*/
|
|
export function formatXmr(amount) {
|
|
if (amount >= 1) {
|
|
return `${amount.toFixed(4)} XMR`
|
|
}
|
|
return `${amount.toFixed(6)} XMR`
|
|
}
|
|
|
|
/**
|
|
* Formats fiat amount
|
|
* @param {number} amount - Amount
|
|
* @param {string} currency - Currency
|
|
* @returns {string} Formatted string (e.g. "€ 150,00")
|
|
*/
|
|
export function formatFiat(amount, currency) {
|
|
const symbol = CURRENCY_SYMBOLS[currency] || currency
|
|
|
|
let locale = 'en-US'
|
|
try {
|
|
const stored = localStorage.getItem('locale')
|
|
const localeMap = { de: 'de-DE', en: 'en-US', fr: 'fr-FR', it: 'it-IT', es: 'es-ES', pt: 'pt-BR', ru: 'ru-RU' }
|
|
locale = localeMap[stored] || 'en-US'
|
|
} catch (e) {
|
|
// Fallback
|
|
}
|
|
|
|
const formatted = new Intl.NumberFormat(locale, {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
}).format(amount)
|
|
|
|
if (['EUR', 'GBP', 'USD', 'BRL'].includes(currency)) {
|
|
return `${symbol} ${formatted}`
|
|
}
|
|
return `${formatted} ${symbol}`
|
|
}
|
|
|
|
/**
|
|
* Returns the currency symbol
|
|
* @param {string} currency - Currency code
|
|
* @returns {string} Symbol
|
|
*/
|
|
export function getCurrencySymbol(currency) {
|
|
return CURRENCY_SYMBOLS[currency] || currency
|
|
}
|
|
|
|
/**
|
|
* List of supported currencies
|
|
*/
|
|
export const SUPPORTED_CURRENCIES = ['XMR', 'EUR', 'CHF', 'USD', 'GBP', 'JPY', 'RUB', 'BRL']
|
|
|
|
export default {
|
|
getXmrRates,
|
|
getDisplayCurrency,
|
|
convertToXmr,
|
|
convertFromXmr,
|
|
convertFiat,
|
|
formatPrice,
|
|
formatXmr,
|
|
formatFiat,
|
|
getCurrencySymbol,
|
|
SUPPORTED_CURRENCIES
|
|
}
|