mirror of
https://github.com/Such-Software/hash-wallet-prices.git
synced 2026-10-17 05:51:28 +02:00
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:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -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.<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"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.
|
||||
@@ -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
@@ -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<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"e=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),
|
||||
));
|
||||
},
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user