mirror of
https://github.com/Such-Software/hash-wallet-prices.git
synced 2026-10-17 05:51:28 +02:00
Add Trocador webhook receiver, trade-status cache, and sparkline history
- POST /v1/trocador-webhook: Trocador hits this on every trade status change;
stored in KV under trade:<id> for 14 days (matching Trocador's retention).
- GET /v1/trade/🆔 returns the cached trade body so the wallet can poll us
instead of Trocador, dramatically reducing load on their infra per their
commercial team's request.
- GET /v1/sparkline?coin=BTC&hours=24: returns hourly history points for
rendering balance sparklines. Cron appends one entry per UTC hour per coin,
capped at 168 points (1 week).
This commit is contained in:
@@ -19,6 +19,9 @@ WOW USD price is the **average of whichever Nonlogs and cexswap routes returned
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `GET /v2/rates?base=BTC"e=USD` | `{ "results": { "BTC_USD": 67342.5 } }` — drop-in compatible with the wallet's existing fetch code |
|
| `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 /v1/prices` | `{ "rates": { ... }, "sources": { ... }, "fetched_at": "..." }` — preferred, returns everything in one call |
|
||||||
|
| `GET /v1/sparkline?coin=BTC&hours=24` | `{ coin, hours, points: [[ts_ms, price], ...] }` — hourly buckets, max 168 points (7 days) |
|
||||||
|
| `POST /v1/trocador-webhook` | Trocador POSTs the full trade body on every status change; we store it under `trade:<id>` for 14 days |
|
||||||
|
| `GET /v1/trade/:trade_id` | Returns the cached trade body (whatever Trocador last sent for that id), or 404 |
|
||||||
| `GET /healthz` | `{ ok: true, coins: N, fetched_at: ... }` |
|
| `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."
|
USD-only for now. Other `quote` values return 0 and the wallet falls back to "price unavailable."
|
||||||
|
|||||||
Generated
+1606
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,13 @@ export interface Env {
|
|||||||
const KV_KEY = "rates:v1";
|
const KV_KEY = "rates:v1";
|
||||||
const KV_TTL_SECONDS = 600; // soft expiry; cron writes fresh values every 60s
|
const KV_TTL_SECONDS = 600; // soft expiry; cron writes fresh values every 60s
|
||||||
|
|
||||||
|
// Sparkline history — one append per UTC hour, capped to a week per coin.
|
||||||
|
const HISTORY_MAX_POINTS = 168;
|
||||||
|
|
||||||
|
// Trade status cache lifetime. Trocador deletes trade data after 14 days
|
||||||
|
// (per their API docs), so anything longer is wasted KV.
|
||||||
|
const TRADE_TTL_SECONDS = 60 * 60 * 24 * 14;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map of wallet-side ticker → Kraken pair name (USD quote).
|
* Map of wallet-side ticker → Kraken pair name (USD quote).
|
||||||
* Kraken uses some legacy "X"/"Z" prefixes for older assets; the public Ticker
|
* Kraken uses some legacy "X"/"Z" prefixes for older assets; the public Ticker
|
||||||
@@ -187,9 +194,31 @@ async function refreshPrices(env: Env): Promise<RatesPayload> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await env.PRICES.put(KV_KEY, JSON.stringify(payload), { expirationTtl: KV_TTL_SECONDS });
|
await env.PRICES.put(KV_KEY, JSON.stringify(payload), { expirationTtl: KV_TTL_SECONDS });
|
||||||
|
await appendHistory(env, rates);
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the latest price to per-coin history KV — but only once per UTC hour.
|
||||||
|
* The cron fires every minute; we coalesce so each coin gets ~24 entries/day,
|
||||||
|
* staying well under KV value-size limits and giving a clean sparkline.
|
||||||
|
*/
|
||||||
|
async function appendHistory(env: Env, rates: Record<string, number>): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const currentHour = Math.floor(now / 3_600_000);
|
||||||
|
for (const [coin, price] of Object.entries(rates)) {
|
||||||
|
if (!Number.isFinite(price) || price <= 0) continue;
|
||||||
|
const key = `history:hourly:${coin}`;
|
||||||
|
const existing = ((await env.PRICES.get(key, "json")) ?? []) as Array<[number, number]>;
|
||||||
|
const lastEntry = existing[existing.length - 1];
|
||||||
|
const lastHour = lastEntry ? Math.floor(lastEntry[0] / 3_600_000) : -1;
|
||||||
|
if (lastHour >= currentHour) continue;
|
||||||
|
existing.push([now, price]);
|
||||||
|
while (existing.length > HISTORY_MAX_POINTS) existing.shift();
|
||||||
|
await env.PRICES.put(key, JSON.stringify(existing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadRates(env: Env): Promise<RatesPayload> {
|
async function loadRates(env: Env): Promise<RatesPayload> {
|
||||||
const cached = await env.PRICES.get(KV_KEY, "json");
|
const cached = await env.PRICES.get(KV_KEY, "json");
|
||||||
if (cached) return cached as RatesPayload;
|
if (cached) return cached as RatesPayload;
|
||||||
@@ -235,6 +264,51 @@ export default {
|
|||||||
return jsonResponse({ results: { [`${base}_${quote}`]: price } });
|
return jsonResponse({ results: { [`${base}_${quote}`]: price } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sparkline / history. ?coin=BTC&hours=24 returns hourly points for the
|
||||||
|
// last N hours (capped at HISTORY_MAX_POINTS = 168 = 7 days).
|
||||||
|
if (url.pathname === "/v1/sparkline") {
|
||||||
|
const coin = (url.searchParams.get("coin") ?? "").toUpperCase();
|
||||||
|
const hours = Math.min(
|
||||||
|
Math.max(parseInt(url.searchParams.get("hours") ?? "24", 10) || 24, 1),
|
||||||
|
HISTORY_MAX_POINTS,
|
||||||
|
);
|
||||||
|
if (!coin) return jsonResponse({ error: "missing coin" }, 400);
|
||||||
|
const all = ((await env.PRICES.get(`history:hourly:${coin}`, "json")) ?? []) as Array<
|
||||||
|
[number, number]
|
||||||
|
>;
|
||||||
|
const cutoff = Date.now() - hours * 3_600_000;
|
||||||
|
const points = all.filter(([t]) => t >= cutoff);
|
||||||
|
return jsonResponse({ coin, hours, points });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trocador webhook receiver. They POST the full trade body on every status
|
||||||
|
// change; we stash it under trade:<id> for the wallet to read.
|
||||||
|
if (url.pathname === "/v1/trocador-webhook" && req.method === "POST") {
|
||||||
|
let body: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return jsonResponse({ error: "bad json" }, 400);
|
||||||
|
}
|
||||||
|
const tradeId = (body.trade_id ?? body.id) as string | undefined;
|
||||||
|
if (!tradeId) return jsonResponse({ error: "missing trade_id" }, 400);
|
||||||
|
const stored = { ...body, received_at: new Date().toISOString() };
|
||||||
|
await env.PRICES.put(`trade:${tradeId}`, JSON.stringify(stored), {
|
||||||
|
expirationTtl: TRADE_TTL_SECONDS,
|
||||||
|
});
|
||||||
|
return jsonResponse({ ok: true, trade_id: tradeId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read cached trade status. Wallet polls this instead of Trocador directly,
|
||||||
|
// letting us aggregate webhook updates across multiple devices and reduce
|
||||||
|
// load on Trocador per their request.
|
||||||
|
const tradeMatch = url.pathname.match(/^\/v1\/trade\/(.+)$/);
|
||||||
|
if (tradeMatch && req.method === "GET") {
|
||||||
|
const cached = await env.PRICES.get(`trade:${tradeMatch[1]}`, "json");
|
||||||
|
if (cached) return jsonResponse(cached);
|
||||||
|
return jsonResponse({ error: "not found" }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
if (url.pathname === "/" || url.pathname === "/healthz") {
|
if (url.pathname === "/" || url.pathname === "/healthz") {
|
||||||
const payload = await loadRates(env);
|
const payload = await loadRates(env);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
|
|||||||
+1
-1
@@ -8,7 +8,7 @@ workers_dev = true
|
|||||||
# (The script also works without KV — it falls back to fetching upstream on each request.)
|
# (The script also works without KV — it falls back to fetching upstream on each request.)
|
||||||
[[kv_namespaces]]
|
[[kv_namespaces]]
|
||||||
binding = "PRICES"
|
binding = "PRICES"
|
||||||
id = "REPLACE_ME_WITH_KV_NAMESPACE_ID"
|
id = "ccd6f69c03f14d9b8317eeaed2bf9f23"
|
||||||
|
|
||||||
# Run the refresh every 60 seconds.
|
# Run the refresh every 60 seconds.
|
||||||
# Cloudflare cron triggers fire once per minute at minimum.
|
# Cloudflare cron triggers fire once per minute at minimum.
|
||||||
|
|||||||
Reference in New Issue
Block a user