You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: README.md
+4-6Lines changed: 4 additions & 6 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -1,6 +1,6 @@
1
1
# price-service
2
2
3
-
Cloudflare Worker that serves spot and historical token prices for Yearn. It aggregates prices from DefiLlama, on-chain oracles, Curve, Bob's API, and Enso, and persists historical prices to a Neon Postgres database.
3
+
Cloudflare Worker that serves Enso spot prices and historical prices from Neon Postgres. Historical rows are written by offline warmup and backfill jobs, not by the worker.
4
4
5
5
## Requirements
6
6
@@ -49,16 +49,14 @@ Full route reference, request/response shapes, error codes, and caching behavior
49
49
50
50
## Price sources
51
51
52
-
Prices are fetched through a pluggable source layer that tries providers in priority order until one returns a result. Currently:
53
-
54
52
-**Spot prices**: Enso (live prices for any token on supported chains)
55
-
-**Historical prices**: DefiLlama → Chainlink → derived (on-chain) → DefiLlama alias, tried in that order on single-token lookups when the DB has no record
53
+
-**Historical prices**: read from `token_prices` only. No historical route calls an upstream provider; a row that is not in the table returns `404` (singletoken) or is omitted from the response (batch, range), and stays that way until an offline job writes it.
56
54
57
-
Batch and range historical endpoints remain DB-only (a registry fallback inside a large batch would generate many upstream requests).
55
+
Historical rows are written by `scripts/warmup-prices.ts` (hourly: DefiLlama, Curve, derived), `scripts/backfill-historical-gaps.ts` and `scripts/backfill-defillama-day-alignment.ts`. One-off migrations (`scripts/backfill-token-address-checksums.ts`) copy existing rows and add no new prices. `docs/routes.md` lists which job writes each `source` value.
58
56
59
57
### Adding a new price source
60
58
61
-
Sources are pluggable adapters under `src/sources/`, registered in `src/registries/spot.ts`or `src/registries/historical.ts`. See [`src/sources/README.md`](src/sources/README.md) for the full authoring guide (interface, registration steps, test checklist).
59
+
Sources are pluggable adapters under `src/sources/`. Spot sources register in `src/registries/spot.ts`and are served live. Historical sources register in `src/registries/historical.ts` but have no request-path consumer — extend a warmup or backfill job so rows reach the table. See [`src/sources/README.md`](src/sources/README.md).
`source` filters the stored price rows only. `chainlink` and `defillama-alias` are resolved live. When request-path persistence fills a gap for a closed past day, the result is stored under the resolving source's name, so filters match those rows afterward. Before any such fill, filtering returns `404` for the single-token route and `200` with an empty `coins` object for `batchHistorical` and `rangeHistorical`.
69
+
`source` filters the stored price rows only. Rows reach the table from the offline jobs:
70
+
71
+
-`defillama`: `scripts/warmup-prices.ts`, `scripts/backfill-historical-gaps.ts` (alias hits are stored as `defillama`) and `scripts/backfill-defillama-day-alignment.ts` (re-aligns existing rows).
72
+
-`curve`, `derived`: `scripts/warmup-prices.ts`.
73
+
-`chainlink`, `defillama-alias`: no writer. Only rows stored before the request-path registry was removed can match; a token priceable only through Chainlink has no price on any route.
74
+
-`on-chain-oracle`, `bobs-api`: no writer in this repo.
75
+
-`enso`: spot only; the spot route never writes `token_prices`, so no historical row carries this source.
76
+
77
+
Filtering on a source with no stored rows returns `404` for the single-token route and `200` with an empty `coins` object for `batchHistorical` and `rangeHistorical`.
70
78
71
79
When `source` is omitted, the API returns the first available row by priority:
72
80
@@ -135,13 +143,9 @@ Response:
135
143
}
136
144
```
137
145
138
-
If no stored row exists for the normalized timestamp and no `source` filter was given, the route falls back to the live historical source registry for that day. A fallback resolved for a closed past day is stored in `token_prices` under the normalized day key, so the next request is a table hit; current-day and future-day results are never persisted. A non-chainlink resolution is stored only when its underlying observation falls within the DeFiLlama search width of the day key; an out-of-window result is served but not stored, so it resolves upstream again on the next cache miss. A `source` filter disables the fallback: the route answers from stored rows only.
139
-
140
-
The fallback shares the request-path DeFiLlama client with `batchHistorical`: each upstream attempt is capped at 2.5 seconds and DeFiLlama `429` responses are not retried, so a rate-limited lookup fails on the first attempt instead of backing off. `5xx` responses still retry with backoff, so one degraded call can take up to three attempts. Offline jobs keep the retrying client.
146
+
If no stored row exists for the normalized timestamp, the route returns `NOT_FOUND`. It never resolves upstream on the request path. The only scheduled writer is the hourly warmup, which covers Kong `origin=yearn` vault and underlying tokens over the trailing 7 days. A token or day outside that set only gets a row when someone runs a backfill script by hand, so a `404` there does not resolve on its own.
141
147
142
-
If the fallback upstream is degraded, the request fails with `INTERNAL_ERROR` (`500`, `no-store`) rather than `NOT_FOUND` — a not-found response is cached for an hour, so a transient blip must not be recorded as "no price exists".
143
-
144
-
If neither the stored rows nor the fallback have a price, the route returns:
148
+
When no stored row exists, the route returns:
145
149
146
150
```json
147
151
{
@@ -281,9 +285,9 @@ Response:
281
285
}
282
286
```
283
287
284
-
When no `source` filter is given, up to `10` pairs missing from the table are resolved through the source registry. The initial DeFiLlama lookup uses its provider-native batch endpoint; unresolved pairs then fall through to the full source registry, so a pair the batch matcher drops is still tried against the single-coin lookup this route's exact counterpart uses. DeFiLlama `429` responses are not retried on the request path, and each upstream attempt is capped at 2.5 seconds (`5xx` responses retry with backoff inside the route deadline). A pair whose own payload group failed is not retried against the DeFiLlama single-coin lookup; pairs from groups that answered are, even when the group matched no sample for them. Payload groups are requested concurrently, and the whole batch stage is capped at 2.5 seconds, so a slow or retrying provider cannot consume the whole deadline and starve the fallback: pairs still pending at that cap fall through to the remaining sources, and only those whose own group never answered skip the single-coin lookup. Upstream resolution has a five-second deadline measured from route entry, after which prices already completed are returned and the rest stay absent. Results for closed past days are stored in `token_prices` and returned in the same response. A non-chainlink resolution is stored only when its underlying observation falls within the DeFiLlama search width of the day key; an out-of-window result is served but not stored, so it resolves upstream again on the next cache miss. A `source` filter disables that resolution: the route answers from stored rows only.
288
+
The route answers from stored rows only. It never resolves upstream on the request path. The hourly warmup covers Kong `origin=yearn` vault and underlying tokens over the trailing 7 days; any other token or older day only gets a row from a manually run backfill, so an omitted pair there stays omitted.
285
289
286
-
Only found prices are returned. Pairs that upstream cannot resolve, and misses past the `10` per-request limit, are omitted from the response.
290
+
Only found prices are returned. Pairs missing from the table are omitted from the response, and the batch is marked partial.
287
291
288
292
## `GET /api/prices/rangeHistorical`
289
293
@@ -384,14 +388,16 @@ Price responses set cache headers based on the requested timestamps and whether
A historical `404`, or a pair omitted from a partial batch or range, means the row is not in `token_prices` yet. It self-heals only for what the hourly warmup covers (Kong `origin=yearn` vault and underlying tokens, trailing 7 days); anything else waits on a manually run backfill. The 300s negative TTL applies to a `404` and to a partial batch or range made entirely of closed days — it is kept below the warmup cadence so a covered consumer sees the row soon after a job lands it. A batch or range that touches today's UTC day takes the today policy instead, so a missing closed day in that response carries the 1h browser `max-age` and 4h `stale-while-revalidate`.
396
+
391
397
## Edge caching
392
398
393
399
Worker-generated responses do not populate Cloudflare's edge cache from a `Cache-Control` header alone — that header only drives the client/browser cache. Successful `GET` responses for all price routes are therefore stored in the edge cache (`caches.default`) explicitly and served from it on subsequent requests, using the TTLs above. A request is served from the edge before any Enso fetch or database query runs.
394
400
395
401
Spot has no upstream cache policy (Enso sends only a weak `etag`), so its `s-maxage=120` is a chosen shared-cache TTL — short enough to keep prices fresh, long enough to absorb bursts — mirroring the Enso proxy already shipping in yearn.fi.
396
402
397
-
The store/TTL decision is delegated to the Cache API: `caches.default.put()` reads the response's `Cache-Control`, refusing `no-store`/`private` and deriving the edge TTL from `s-maxage` (falling back to `max-age`, then `Expires`). Only successful responses are offered to `put()` — errors return straight from the worker's catch block and are never edge-stored (generic errors additionally carry `no-store` for downstream caches; historical not-found is the deliberate exception, returning a browser-cacheable negative result). Today's data sets `s-maxage=300` so the shared edge refreshes every ~5min, tracking the hourly warmup far more closely than the 1h browser `max-age`. The cache key is the request URL canonicalized first (sorted query params, and `coins` re-serialized with sorted keys and lowercased addresses) so requests that differ only in JSON ordering, whitespace, or address casing share one entry. Positional arrays — a range's `[start, end]` and a batch token's timestamp list — are never reordered, so two requests that differ in those never collide.
403
+
The store/TTL decision is delegated to the Cache API: `caches.default.put()` reads the response's `Cache-Control`, refusing `no-store`/`private` and deriving the edge TTL from `s-maxage` (falling back to `max-age`, then `Expires`). Only successful responses are offered to `put()` — errors return straight from the worker's catch block and are never edge-stored (generic errors additionally carry `no-store` for downstream caches; historical not-found is the deliberate exception, returning a short-lived cacheable negative result). Today's data sets `s-maxage=300` so the shared edge refreshes every ~5min, tracking the hourly warmup far more closely than the 1h browser `max-age`. The cache key is the request URL canonicalized first (sorted query params, and `coins` re-serialized with sorted keys and lowercased addresses) so requests that differ only in JSON ordering, whitespace, or address casing share one entry. Positional arrays — a range's `[start, end]` and a batch token's timestamp list — are never reordered, so two requests that differ in those never collide.
<li>Historical prices for past days: <code>public, max-age=31536000, immutable</code>.</li>
217
217
<li>Historical requests for today's UTC day: <code>public, s-maxage=300, max-age=3600, stale-while-revalidate=14400</code>.</li>
218
-
<li>Partially resolved batch or range for past days: <code>public, max-age=3600</code>.</li>
218
+
<li>Partially resolved batch or range for past days, and historical not-found: <code>public, s-maxage=300, max-age=300</code>.</li>
219
219
<li>Responses are edge-cached at Cloudflare with canonicalized URLs, so requests that differ only in query order, whitespace, or address casing share one entry.</li>
0 commit comments