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:
jwinterm
2026-05-14 15:57:17 -04:00
parent 8f374a6470
commit 4f7c515808
4 changed files with 1684 additions and 1 deletions
+3
View File
@@ -19,6 +19,9 @@ WOW USD price is the **average of whichever Nonlogs and cexswap routes returned
| --- | --- |
| `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 /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: ... }` |
USD-only for now. Other `quote` values return 0 and the wallet falls back to "price unavailable."
+1606
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -23,6 +23,13 @@ export interface Env {
const KV_KEY = "rates:v1";
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).
* 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 appendHistory(env, rates);
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> {
const cached = await env.PRICES.get(KV_KEY, "json");
if (cached) return cached as RatesPayload;
@@ -235,6 +264,51 @@ export default {
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") {
const payload = await loadRates(env);
return jsonResponse({
+1 -1
View File
@@ -8,7 +8,7 @@ workers_dev = true
# (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"
id = "ccd6f69c03f14d9b8317eeaed2bf9f23"
# Run the refresh every 60 seconds.
# Cloudflare cron triggers fire once per minute at minimum.