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.
This commit is contained in:
jwinterm
2026-05-14 08:58:58 -04:00
commit f4bf98f6fc
6 changed files with 302 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
.wrangler/
.dev.vars
dist/
*.log
.DS_Store
+51
View File
@@ -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&quote=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.<your-cloudflare-subdomain>.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&quote=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.
+16
View File
@@ -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"
}
}
+192
View File
@@ -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&quote=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<string, string> = {
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<string, string> = {
WOW: "WOW_USDT",
};
interface RatesPayload {
rates: Record<string, number>;
sources: Record<string, string>;
fetched_at: string;
}
async function fetchKraken(): Promise<Record<string, number>> {
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<string, { c: string[] }> };
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<string, number> = {};
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<Record<string, number>> {
const out: Record<string, number> = {};
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<RatesPayload> {
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<string, number> = { ...kraken, ...nonkyc };
const sources: Record<string, string> = {};
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<RatesPayload> {
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<Response> {
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&quote=USD, expects
// { "results": { "<KEY>": <price as number> } }.
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<void> {
ctx.waitUntil(refreshPrices(env).then(
(p) => console.log(`refreshed ${Object.keys(p.rates).length} coins`),
(e) => console.error("refresh failed", e),
));
},
};
+15
View File
@@ -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"]
}
+22
View File
@@ -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