From f4bf98f6fc116d1678c507283da8aac46b1c3406 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Thu, 14 May 2026 08:58:58 -0400 Subject: [PATCH] Initial Hash Wallet price aggregator (Cloudflare Worker) Single endpoint serving USD spot prices for Hash Wallet, sourcing from Kraken (majors) and NonKYC (Wownero), KV-cached and refreshed via cron every minute. Drop-in /v2/rates shape compatible with the existing wallet client; preferred /v1/prices returns everything in one call. --- .gitignore | 6 ++ README.md | 51 ++++++++++++++ package.json | 16 +++++ src/index.ts | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 15 ++++ wrangler.toml | 22 ++++++ 6 files changed, 302 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 tsconfig.json create mode 100644 wrangler.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fc7ba2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.wrangler/ +.dev.vars +dist/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..7d58783 --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# hash-wallet-prices + +Cloudflare Worker that aggregates USD prices for [Hash Wallet](https://github.com/Such-Software/hash-wallet) and serves them at one cached endpoint. + +Why this exists: see the architectural notes in the wallet repo. Short version — having every user's phone hit Kraken/NonKYC directly leaks wallet-holder activity to the exchanges and spreads our rate-limit budget across thousands of IPs. One Worker with a cron-refreshed KV cache fixes both. + +## Sources + +* **Kraken** — BTC, XMR, LTC, DOGE, ETH, BCH, XNO (all → USD) +* **NonKYC** — WOW (WOW_USDT, treated as ≈ USD) +* **Pinned** — USDT, USDC, DAI all return 1.00 (stables we don't quote upstream) + +## Endpoints + +| Path | Shape | +| --- | --- | +| `GET /v2/rates?base=BTC"e=USD` | `{ "results": { "BTC_USD": 67342.5 } }` — drop-in compatible with the wallet's existing fetch code | +| `GET /v1/prices` | `{ "rates": { ... }, "sources": { ... }, "fetched_at": "..." }` — preferred, returns everything in one call | +| `GET /healthz` | `{ ok: true, coins: N, fetched_at: ... }` | + +USD-only for now. Other `quote` values return 0 and the wallet falls back to "price unavailable." + +## Deploy + +```bash +npm install +npx wrangler login +npx wrangler kv:namespace create PRICES +# Paste the returned id into wrangler.toml's kv_namespaces.id field. +npx wrangler deploy +``` + +After first deploy the worker is live at `https://hash-wallet-prices..workers.dev`. To put it behind `prices.suchsoftware.com`, point that hostname's DNS at Cloudflare and uncomment the `[[routes]]` block in `wrangler.toml`. + +## Local dev + +```bash +npm install +npx wrangler dev # starts a local server with hot reload +curl localhost:8787/healthz +curl 'localhost:8787/v2/rates?base=BTC"e=USD' +``` + +## Adding a coin + +1. If Kraken lists it: add `TICKER: "PAIRUSD"` to `KRAKEN_PAIRS` in `src/index.ts`. +2. If only smaller exchanges list it: add a new fetcher function alongside `fetchNonkyc()` and merge its results in `refreshPrices()`. Keep it `try`/`catch` wrapped so a single source failure doesn't poison the whole refresh. + +## License + +MIT. diff --git a/package.json b/package.json new file mode 100644 index 0000000..0f6a0de --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "hash-wallet-prices", + "version": "0.1.0", + "private": true, + "description": "Price aggregator for Hash Wallet — Cloudflare Worker that fetches USD prices from Kraken (majors) and NonKYC (Wownero) and serves them to the app", + "scripts": { + "dev": "wrangler dev", + "deploy": "wrangler deploy", + "tail": "wrangler tail" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250101.0", + "typescript": "^5.4.0", + "wrangler": "^3.90.0" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..174c465 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,192 @@ +/** + * Hash Wallet price aggregator. + * + * One Cloudflare Worker that: + * - Fetches USD spot prices from Kraken for majors (BTC/XMR/LTC/DOGE/ETH/BCH/XNO). + * - Fetches WOW from NonKYC (Wownero is too small for major CEX listings). + * - Caches results in KV every 60s via cron, then serves from KV on request. + * + * Endpoints: + * - GET /v2/rates?base=BTC"e=USD → { "results": { "BTC_USD": 67342.5 } } + * (Drop-in shape compatible with the wallet's existing fiat_conversion_service.dart.) + * - GET /v1/prices → { "rates": { ... }, "fetched_at": "..." } + * (Preferred future shape — single call returns everything.) + * + * USD only. The wallet's other fiat currencies will see price=0 and degrade + * gracefully via existing fallback logic. + */ + +export interface Env { + PRICES: KVNamespace; +} + +const KV_KEY = "rates:v1"; +const KV_TTL_SECONDS = 600; // soft expiry; cron writes fresh values every 60s + +/** + * Map of wallet-side ticker → Kraken pair name (USD quote). + * Kraken uses some legacy "X"/"Z" prefixes for older assets; the public Ticker + * endpoint normalizes these in the response, but we send the modern names. + */ +const KRAKEN_PAIRS: Record = { + BTC: "XBTUSD", + XMR: "XMRUSD", + LTC: "LTCUSD", + DOGE: "XDGUSD", + ETH: "ETHUSD", + BCH: "BCHUSD", + XNO: "NANOUSD", +}; + +/** + * Wallet-side ticker → NonKYC market symbol. + * NonKYC quotes WOW only against USDT, which we treat as ≈ USD for spot pricing. + */ +const NONKYC_MARKETS: Record = { + WOW: "WOW_USDT", +}; + +interface RatesPayload { + rates: Record; + sources: Record; + fetched_at: string; +} + +async function fetchKraken(): Promise> { + const pairs = Object.values(KRAKEN_PAIRS).join(","); + const url = `https://api.kraken.com/0/public/Ticker?pair=${pairs}`; + const res = await fetch(url, { cf: { cacheTtl: 30, cacheEverything: true } }); + if (!res.ok) throw new Error(`kraken http ${res.status}`); + const body = (await res.json()) as { error: string[]; result: Record }; + if (body.error?.length) throw new Error(`kraken err ${body.error.join(",")}`); + + // Kraken's response keys can include legacy X/Z prefixes (e.g. "XXBTZUSD" for "XBTUSD"). + // Match by suffix instead of exact key. + const out: Record = {}; + for (const [ticker, pair] of Object.entries(KRAKEN_PAIRS)) { + const matchKey = Object.keys(body.result).find((k) => k.endsWith(pair) || k.endsWith(pair.replace("USD", "ZUSD"))); + if (!matchKey) continue; + const last = body.result[matchKey]?.c?.[0]; + if (last) out[ticker] = parseFloat(last); + } + return out; +} + +async function fetchNonkyc(): Promise> { + const out: Record = {}; + for (const [ticker, market] of Object.entries(NONKYC_MARKETS)) { + try { + const res = await fetch(`https://api.nonkyc.io/api/v2/ticker/${market}`, { + cf: { cacheTtl: 30, cacheEverything: true }, + }); + if (!res.ok) continue; + // NonKYC ticker shape: { "last_price": "0.0234", ... }. Field name has been + // both `last_price` and `last` historically — accept either. + const body = (await res.json()) as { last_price?: string; last?: string }; + const last = body.last_price ?? body.last; + if (last) out[ticker] = parseFloat(last); + } catch { + // Skip — WOW just won't be in this refresh cycle. Stale KV value will be + // returned to clients until the next successful refresh. + } + } + return out; +} + +async function refreshPrices(env: Env): Promise { + const [kraken, nonkyc] = await Promise.all([ + fetchKraken().catch((e) => { + console.error("kraken failed", e); + return {}; + }), + fetchNonkyc().catch((e) => { + console.error("nonkyc failed", e); + return {}; + }), + ]); + + const rates: Record = { ...kraken, ...nonkyc }; + const sources: Record = {}; + for (const k of Object.keys(kraken)) sources[k] = "kraken"; + for (const k of Object.keys(nonkyc)) sources[k] = "nonkyc"; + + // Stables — we don't quote them upstream, just pin to 1.0. + for (const stable of ["USDT", "USDC", "DAI"]) { + rates[stable] = 1.0; + sources[stable] = "pinned"; + } + + const payload: RatesPayload = { + rates, + sources, + fetched_at: new Date().toISOString(), + }; + + await env.PRICES.put(KV_KEY, JSON.stringify(payload), { expirationTtl: KV_TTL_SECONDS }); + return payload; +} + +async function loadRates(env: Env): Promise { + const cached = await env.PRICES.get(KV_KEY, "json"); + if (cached) return cached as RatesPayload; + // Cold start / KV miss — fall back to a live fetch so the first user request + // after deploy doesn't return empty data. + return refreshPrices(env); +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + // Edge-cache for 30s so a thundering herd of identical requests gets coalesced. + "cache-control": "public, max-age=30", + "access-control-allow-origin": "*", + }, + }); +} + +export default { + async fetch(req: Request, env: Env): Promise { + const url = new URL(req.url); + + if (url.pathname === "/v1/prices") { + const payload = await loadRates(env); + return jsonResponse(payload); + } + + if (url.pathname === "/v2/rates") { + // Drop-in compatibility with Cake Wallet's API shape so the wallet client + // change is minimal. Wallet sends ?base=BTC"e=USD, expects + // { "results": { "": } }. + const base = (url.searchParams.get("base") ?? "").toUpperCase(); + const quote = (url.searchParams.get("quote") ?? "").toUpperCase(); + if (!base) return jsonResponse({ results: {} }, 400); + // USD-only for now. Other quotes silently return 0 — wallet treats as + // "price unavailable" and shows a placeholder. + if (quote !== "USD") return jsonResponse({ results: { [`${base}_${quote}`]: 0 } }); + + const payload = await loadRates(env); + const price = payload.rates[base] ?? 0; + return jsonResponse({ results: { [`${base}_${quote}`]: price } }); + } + + if (url.pathname === "/" || url.pathname === "/healthz") { + const payload = await loadRates(env); + return jsonResponse({ + ok: true, + coins: Object.keys(payload.rates).length, + fetched_at: payload.fetched_at, + }); + } + + return jsonResponse({ error: "not found" }, 404); + }, + + async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext): Promise { + ctx.waitUntil(refreshPrices(env).then( + (p) => console.log(`refreshed ${Object.keys(p.rates).length} coins`), + (e) => console.error("refresh failed", e), + )); + }, +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..66acb38 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "es2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "noEmit": true, + "lib": ["es2022"], + "types": ["@cloudflare/workers-types"] + }, + "include": ["src/**/*.ts"] +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..a4cb4b2 --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,22 @@ +name = "hash-wallet-prices" +main = "src/index.ts" +compatibility_date = "2026-05-14" +workers_dev = true + +# KV namespace for cached prices. +# Run `wrangler kv:namespace create PRICES` once, then paste the returned id below. +# (The script also works without KV — it falls back to fetching upstream on each request.) +[[kv_namespaces]] +binding = "PRICES" +id = "REPLACE_ME_WITH_KV_NAMESPACE_ID" + +# Run the refresh every 60 seconds. +# Cloudflare cron triggers fire once per minute at minimum. +[triggers] +crons = ["* * * * *"] + +# Optional: route this worker behind a custom domain like prices.suchsoftware.com. +# Uncomment + edit after pointing DNS at Cloudflare and creating the route. +# [[routes]] +# pattern = "prices.suchsoftware.com/*" +# custom_domain = true