diff --git a/.github/workflows/generate-pro-changelog.yml b/.github/workflows/generate-pro-changelog.yml new file mode 100644 index 0000000000..582312c854 --- /dev/null +++ b/.github/workflows/generate-pro-changelog.yml @@ -0,0 +1,68 @@ +name: Generate Pro Price Feed Changelog + +on: + schedule: + - cron: "30 0 * * *" + workflow_dispatch: + inputs: + full_rebuild: + type: boolean + default: false + +concurrency: + group: pro-changelog + cancel-in-progress: true + +jobs: + generate: + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/developer-hub + permissions: + contents: write + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + ref: main + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + repository: pyth-network/pyth-lazer-governance + token: ${{ secrets.GOVERNANCE_REPO_TOKEN }} + path: .governance-repo + fetch-depth: 1 + sparse-checkout: | + */after.json + sparse-checkout-cone-mode: false + + - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - name: Generate changelog + env: + GOVERNANCE_REPO_PATH: ${{ github.workspace }}/.governance-repo + FULL_REBUILD: ${{ github.event.inputs.full_rebuild || 'false' }} + run: pnpm run generate:pro-price-feed-changelog + + - name: Validate output + run: pnpm tsx ./scripts/validate-pro-changelog-output.ts + + - name: Commit and push + run: | + cd ${{ github.workspace }} + git config user.email 'github-actions[bot]@users.noreply.github.com' + git config user.name 'github-actions[bot]' + git add apps/developer-hub/public/data/pro-price-feed-changelog/daily-rollups.json + if git diff --staged --quiet; then + echo 'No changes to commit' + else + git commit -m 'chore(developer-hub): update Pro price feed changelog' + git push + fi diff --git a/apps/developer-hub/content/docs/price-feeds/pro/meta.json b/apps/developer-hub/content/docs/price-feeds/pro/meta.json index 79d1d648f4..22361be6f4 100644 --- a/apps/developer-hub/content/docs/price-feeds/pro/meta.json +++ b/apps/developer-hub/content/docs/price-feeds/pro/meta.json @@ -15,6 +15,7 @@ "error-codes", "faq", "price-feed-ids", + "price-feed-id-changelog", "contract-addresses", "market-hours", "futures-terminology", diff --git a/apps/developer-hub/content/docs/price-feeds/pro/price-feed-id-changelog.mdx b/apps/developer-hub/content/docs/price-feeds/pro/price-feed-id-changelog.mdx new file mode 100644 index 0000000000..7424b50914 --- /dev/null +++ b/apps/developer-hub/content/docs/price-feeds/pro/price-feed-id-changelog.mdx @@ -0,0 +1,22 @@ +--- +title: Price Feed ID Changelog +description: Daily UTC changelog for Pyth Pro Price Feed ID updates +slug: /price-feeds/pro/price-feed-id-changelog +--- + +import { Suspense } from "react"; +import { PriceFeedIdsProChangelog } from "../../../../src/components/PriceFeedIdsProChangelog"; +import { Callout } from "fumadocs-ui/components/callout"; + + + This page is derived from governance proposals in the Pyth Pro feed registry, + grouped by UTC date. + + The default view focuses on status transitions (including `Coming Soon -> + Stable`). Turn on `Include all property changes` to inspect every detected + field diff. + + + + + diff --git a/apps/developer-hub/package.json b/apps/developer-hub/package.json index eb31932f9a..99af434a58 100644 --- a/apps/developer-hub/package.json +++ b/apps/developer-hub/package.json @@ -75,8 +75,10 @@ "count:llm-tokens": "tsx ./scripts/count-llm-tokens.ts", "fix:lint:stylelint": "stylelint --fix 'src/**/*.scss'", "generate:docs": "tsx ./scripts/generate-docs.ts", + "generate:pro-price-feed-changelog": "tsx ./scripts/generate-pro-price-feed-changelog.ts", "start:dev": "next dev --port 3627", "start:prod": "next start --port 3627", + "test": "NODE_OPTIONS='--experimental-vm-modules' jest", "test:lint:stylelint": "stylelint 'src/**/*.scss' --max-warnings 0", "test:types": "tsc" }, diff --git a/apps/developer-hub/public/data/pro-price-feed-changelog/daily-rollups.json b/apps/developer-hub/public/data/pro-price-feed-changelog/daily-rollups.json new file mode 100644 index 0000000000..10b7e5f67d --- /dev/null +++ b/apps/developer-hub/public/data/pro-price-feed-changelog/daily-rollups.json @@ -0,0 +1 @@ +{"generatedAt":"2026-03-09T21:03:54.124Z","source":"pyth-network/pyth-lazer-governance","days":[]} diff --git a/apps/developer-hub/scripts/__tests__/generate-pro-price-feed-changelog.test.ts b/apps/developer-hub/scripts/__tests__/generate-pro-price-feed-changelog.test.ts new file mode 100644 index 0000000000..21f8ef26aa --- /dev/null +++ b/apps/developer-hub/scripts/__tests__/generate-pro-price-feed-changelog.test.ts @@ -0,0 +1,430 @@ +/** + * @jest-environment node + */ + +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; + +import type { + DailyRollupFile, +} from "../../src/data/pro-price-feed-changelog/types"; +import { + checkShrinkage, + diffStates, + groupByDate, + listProposalDirs, + loadAfterFeeds, + loadExistingRollup, + sanitizeDirName, + transformFeeds, +} from "../lib/pro-price-feed-changelog"; +import type { GovernanceFeed } from "../lib/pro-price-feed-changelog"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const GOVERNANCE_FEED_MINIMAL: GovernanceFeed = { + feedId: 1, + symbol: "BTC/USD", + state: "Stable", + exponent: -8, + minPublishers: 3, + marketSchedule: "24/7", + metadata: { + name: "Bitcoin", + description: "Bitcoin price", + asset_type: "crypto", + quote_currency: "USD", + cmc_id: "1", + }, +}; + +const GOVERNANCE_FEED_WITH_SENSITIVE: GovernanceFeed = { + feedId: 2, + symbol: "ETH/USD", + state: "ComingSoon", + exponent: -8, + metadata: { + name: "Ethereum", + description: "Ethereum price", + asset_type: "crypto", + quote_currency: "USD", + cmc_id: 1027, + // Sensitive fields that should NOT appear in output + hermes_id: "0xabcdef1234567890", + internal_notes: "do not expose", + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function makeTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), "changelog-test-")); +} + +async function writeAfterJson( + base: string, + dirName: string, + feeds: GovernanceFeed[], +): Promise { + const dir = path.join(base, dirName); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile( + path.join(dir, "after.json"), + JSON.stringify({ + feeds, + publishers: [{ id: "secret-pub-1" }], + governanceSources: [{ key: "secret-key" }], + }), + ); +} + +// --------------------------------------------------------------------------- +// Tests: listProposalDirs +// --------------------------------------------------------------------------- + +describe("listProposalDirs", () => { + it("returns sorted valid dirs and skips invalid names", async () => { + const tmp = await makeTempDir(); + await fs.mkdir(path.join(tmp, "2026-03-10-T120000-add-btc")); + await fs.mkdir(path.join(tmp, "2026-03-09-T080000-add-eth")); + await fs.mkdir(path.join(tmp, "not-a-valid-dir")); + await fs.writeFile(path.join(tmp, "some-file.txt"), ""); + + const dirs = await listProposalDirs(tmp); + expect(dirs).toEqual([ + "2026-03-09-T080000-add-eth", + "2026-03-10-T120000-add-btc", + ]); + }); + + it("returns empty array for empty directory", async () => { + const tmp = await makeTempDir(); + expect(await listProposalDirs(tmp)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: groupByDate +// --------------------------------------------------------------------------- + +describe("groupByDate", () => { + it("groups dirs by date, last dir wins", () => { + const dirs = [ + "2026-03-09-T080000-proposal-a", + "2026-03-09-T120000-proposal-b", + "2026-03-10-T060000-proposal-c", + ]; + const result = groupByDate(dirs); + expect(result.get("2026-03-09")).toBe("2026-03-09-T120000-proposal-b"); + expect(result.get("2026-03-10")).toBe("2026-03-10-T060000-proposal-c"); + expect(result.size).toBe(2); + }); + + it("skips dirs that cannot extract date", () => { + const result = groupByDate(["invalid-dir-name"]); + expect(result.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: sanitizeDirName +// --------------------------------------------------------------------------- + +describe("sanitizeDirName", () => { + it("extracts date from valid dir name", () => { + expect(sanitizeDirName("2026-03-10-T120000-add-btc")).toBe("2026-03-10"); + }); + + it("returns [invalid] for non-conforming names", () => { + expect(sanitizeDirName("not-a-date")).toBe("[invalid]"); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: loadAfterFeeds +// --------------------------------------------------------------------------- + +describe("loadAfterFeeds", () => { + it("loads and parses feeds from after.json, ignoring publishers", async () => { + const tmp = await makeTempDir(); + await writeAfterJson(tmp, "2026-03-10-T120000-test", [ + GOVERNANCE_FEED_MINIMAL, + ]); + + const feeds = await loadAfterFeeds(tmp, "2026-03-10-T120000-test"); + expect(feeds).toHaveLength(1); + expect(feeds[0]?.feedId).toBe(1); + }); + + it("throws on missing after.json", async () => { + const tmp = await makeTempDir(); + await fs.mkdir(path.join(tmp, "2026-03-10-T120000-empty")); + + await expect( + loadAfterFeeds(tmp, "2026-03-10-T120000-empty"), + ).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: transformFeeds — whitelist enforcement +// --------------------------------------------------------------------------- + +describe("transformFeeds", () => { + it("transforms governance feed to public format with only whitelisted fields", () => { + const result = transformFeeds([GOVERNANCE_FEED_MINIMAL]); + expect(result).toHaveLength(1); + + const record = result[0]!; + expect(record.pyth_lazer_id).toBe(1); + expect(record.symbol).toBe("BTC/USD"); + expect(record.state).toBe("stable"); // lowercased + expect(record.exponent).toBe(-8); + expect(record.min_publishers).toBe(3); + expect(record.schedule).toBe("24/7"); + expect(record.name).toBe("Bitcoin"); + expect(record.description).toBe("Bitcoin price"); + expect(record.asset_type).toBe("crypto"); + expect(record.quote_currency).toBe("USD"); + expect(record.cmc_id).toBe("1"); + }); + + it("excludes sensitive metadata fields", () => { + const result = transformFeeds([GOVERNANCE_FEED_WITH_SENSITIVE]); + const record = result[0]!; + const json = JSON.stringify(record); + + expect(json).not.toContain("hermes_id"); + expect(json).not.toContain("internal_notes"); + expect(json).not.toContain("0xabcdef"); + expect(json).not.toContain("do not expose"); + }); + + it("handles missing optional fields with null", () => { + const minimal: GovernanceFeed = { feedId: 99 }; + const result = transformFeeds([minimal]); + const record = result[0]!; + + expect(record.pyth_lazer_id).toBe(99); + expect(record.symbol).toBeNull(); + expect(record.state).toBeNull(); + expect(record.name).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: diffStates +// --------------------------------------------------------------------------- + +describe("diffStates", () => { + it("detects added feeds", () => { + const after = transformFeeds([GOVERNANCE_FEED_MINIMAL]); + const day = diffStates("2026-03-10", [], after); + + expect(day.changes).toHaveLength(1); + expect(day.changes[0]?.changeType).toBe("added"); + expect(day.totals.added).toBe(1); + }); + + it("detects removed feeds", () => { + const before = transformFeeds([GOVERNANCE_FEED_MINIMAL]); + const day = diffStates("2026-03-10", before, []); + + expect(day.changes).toHaveLength(1); + expect(day.changes[0]?.changeType).toBe("removed"); + expect(day.totals.removed).toBe(1); + }); + + it("detects went_live transition", () => { + const feedBefore: GovernanceFeed = { + ...GOVERNANCE_FEED_MINIMAL, + state: "ComingSoon", + }; + const feedAfter: GovernanceFeed = { + ...GOVERNANCE_FEED_MINIMAL, + state: "Stable", + }; + + const before = transformFeeds([feedBefore]); + const after = transformFeeds([feedAfter]); + const day = diffStates("2026-03-10", before, after); + + expect(day.changes).toHaveLength(1); + expect(day.changes[0]?.changeType).toBe("went_live"); + expect(day.totals.went_live).toBe(1); + }); + + it("detects changed fields", () => { + const feedBefore: GovernanceFeed = { + ...GOVERNANCE_FEED_MINIMAL, + exponent: -8, + }; + const feedAfter: GovernanceFeed = { + ...GOVERNANCE_FEED_MINIMAL, + exponent: -6, + }; + + const before = transformFeeds([feedBefore]); + const after = transformFeeds([feedAfter]); + const day = diffStates("2026-03-10", before, after); + + expect(day.changes).toHaveLength(1); + expect(day.changes[0]?.changeType).toBe("changed"); + expect(day.changes[0]?.changedFields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "exponent", before: -8, after: -6 }), + ]), + ); + }); + + it("reports no changes for identical states", () => { + const feeds = transformFeeds([GOVERNANCE_FEED_MINIMAL]); + const day = diffStates("2026-03-10", feeds, feeds); + + expect(day.changes).toHaveLength(0); + }); + + it("produces only scalar values in FieldDiff.before/after", () => { + const feedBefore: GovernanceFeed = { ...GOVERNANCE_FEED_MINIMAL }; + const feedAfter: GovernanceFeed = { + ...GOVERNANCE_FEED_MINIMAL, + metadata: { + ...GOVERNANCE_FEED_MINIMAL.metadata, + name: "Bitcoin Updated", + }, + }; + + const before = transformFeeds([feedBefore]); + const after = transformFeeds([feedAfter]); + const day = diffStates("2026-03-10", before, after); + + for (const change of day.changes) { + for (const field of change.changedFields) { + expect( + field.before === null || + typeof field.before === "string" || + typeof field.before === "number" || + typeof field.before === "boolean", + ).toBe(true); + expect( + field.after === null || + typeof field.after === "string" || + typeof field.after === "number" || + typeof field.after === "boolean", + ).toBe(true); + } + } + }); +}); + +// --------------------------------------------------------------------------- +// Tests: loadExistingRollup +// --------------------------------------------------------------------------- + +describe("loadExistingRollup", () => { + it("returns null for missing file", async () => { + const result = await loadExistingRollup("/nonexistent/path.json"); + expect(result).toBeNull(); + }); + + it("loads and parses existing rollup", async () => { + const tmp = await makeTempDir(); + const filePath = path.join(tmp, "rollup.json"); + const data: DailyRollupFile = { + generatedAt: "2026-03-09T00:00:00Z", + source: "pyth-network/pyth-lazer-governance", + days: [], + }; + await fs.writeFile(filePath, JSON.stringify(data)); + + const result = await loadExistingRollup(filePath); + expect(result).toEqual(data); + }); + + it("throws on malformed rollup file", async () => { + const tmp = await makeTempDir(); + const filePath = path.join(tmp, "rollup.json"); + await fs.writeFile( + filePath, + JSON.stringify({ invalid: true }), + ); + + await expect(loadExistingRollup(filePath)).rejects.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: checkShrinkage +// --------------------------------------------------------------------------- + +describe("checkShrinkage", () => { + const makeRollup = (dayCount: number): DailyRollupFile => ({ + generatedAt: "2026-03-09T00:00:00Z", + source: "test", + days: Array.from({ length: dayCount }, (_, i) => ({ + date: `2026-03-${String(i + 1).padStart(2, "0")}`, + totals: { went_live: 0, added: 0, changed: 0, removed: 0 }, + changes: [], + })), + }); + + it("does not throw when days count is stable", () => { + expect(() => checkShrinkage(makeRollup(10), makeRollup(10))).not.toThrow(); + }); + + it("does not throw for empty existing rollup", () => { + expect(() => checkShrinkage(makeRollup(0), makeRollup(5))).not.toThrow(); + }); + + it("throws when >50% entries lost", () => { + expect(() => checkShrinkage(makeRollup(10), makeRollup(4))).toThrow( + /Shrinkage guard/, + ); + }); + + it("does not throw at exactly 50%", () => { + expect(() => checkShrinkage(makeRollup(10), makeRollup(5))).not.toThrow(); + }); +}); + +// --------------------------------------------------------------------------- +// Tests: FULL_REBUILD behavior (integration-style) +// --------------------------------------------------------------------------- + +describe("incremental append", () => { + it("merges new days into existing rollup without duplicates", () => { + const existingDay: DailyRollupFile["days"][number] = { + date: "2026-03-09", + totals: { went_live: 1, added: 0, changed: 0, removed: 0 }, + changes: [ + { + changeType: "went_live", + pythLazerId: 1, + symbol: "BTC/USD", + name: "Bitcoin", + statusBefore: "coming_soon", + statusAfter: "stable", + changedFields: [], + }, + ], + }; + + const newDay = diffStates( + "2026-03-10", + transformFeeds([GOVERNANCE_FEED_MINIMAL]), + transformFeeds([{ ...GOVERNANCE_FEED_MINIMAL, exponent: -6 }]), + ); + + const merged = [existingDay, newDay].sort((a, b) => + b.date.localeCompare(a.date), + ); + expect(merged).toHaveLength(2); + expect(merged[0]?.date).toBe("2026-03-10"); + expect(merged[1]?.date).toBe("2026-03-09"); + }); +}); diff --git a/apps/developer-hub/scripts/generate-pro-price-feed-changelog.ts b/apps/developer-hub/scripts/generate-pro-price-feed-changelog.ts new file mode 100644 index 0000000000..a24ff8eae3 --- /dev/null +++ b/apps/developer-hub/scripts/generate-pro-price-feed-changelog.ts @@ -0,0 +1,175 @@ +/** + * CLI entrypoint: generates daily-rollups.json from the governance repo. + * + * Usage: + * GOVERNANCE_REPO_PATH=/path/to/clone pnpm run generate:pro-price-feed-changelog + * + * Environment variables: + * GOVERNANCE_REPO_PATH — local path to pyth-lazer-governance checkout (required) + * FULL_REBUILD — set to "true" to ignore existing rollup and rebuild from scratch + */ + +import * as fs from "node:fs/promises"; +import path from "node:path"; + +import type { DailyRollupFile } from "../src/data/pro-price-feed-changelog/types"; +import { + checkShrinkage, + diffStates, + groupByDate, + listProposalDirs, + loadAfterFeeds, + loadExistingRollup, + transformFeeds, +} from "./lib/pro-price-feed-changelog"; + +const SOURCE = "pyth-network/pyth-lazer-governance"; +const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname); +const ROLLUPS_PATH = path.join( + SCRIPT_DIR, + "..", + "public", + "data", + "pro-price-feed-changelog", + "daily-rollups.json", +); + +async function main() { + const repoPath = process.env.GOVERNANCE_REPO_PATH; + if (!repoPath) { + console.error( + "Error: GOVERNANCE_REPO_PATH environment variable is required.\n" + + "Set it to the local path of the pyth-lazer-governance checkout.", + ); + process.exit(1); + } + + // Verify the path exists and is a directory + try { + const stat = await fs.stat(repoPath); + if (!stat.isDirectory()) { + console.error(`Error: GOVERNANCE_REPO_PATH is not a directory: ${repoPath}`); + process.exit(1); + } + } catch { + console.error(`Error: GOVERNANCE_REPO_PATH does not exist: ${repoPath}`); + process.exit(1); + } + + const fullRebuild = process.env.FULL_REBUILD === "true"; + + // 1. List and group proposal dirs by date + const dirs = await listProposalDirs(repoPath); + console.log(`Found ${String(dirs.length)} proposal directories.`); + + const dateToDir = groupByDate(dirs); + const sortedDates = [...dateToDir.keys()].sort(); + console.log(`Grouped into ${String(sortedDates.length)} unique dates.`); + + if (sortedDates.length === 0) { + console.log("No proposals found. Nothing to generate."); + return; + } + + // 2. Load existing rollup (unless full rebuild) + await fs.mkdir(path.dirname(ROLLUPS_PATH), { recursive: true }); + const existing = + !fullRebuild ? await loadExistingRollup(ROLLUPS_PATH) : null; + const existingDates = new Set(existing?.days.map((d) => d.date) ?? []); + + // 3. Determine which dates need processing + const datesToProcessSet = new Set( + sortedDates.filter((d) => !existingDates.has(d)), + ); + console.log( + `${String(datesToProcessSet.size)} new date(s) to process${fullRebuild ? " (full rebuild)" : ""}.`, + ); + + // 4. Load and transform feeds for each date, then diff consecutive pairs. + // Cache the previous date's transformed feeds to avoid redundant I/O. + const newDays = []; + let cachedPublic: { date: string; feeds: ReturnType } | null = null; + + for (let i = 0; i < sortedDates.length; i++) { + const currentDate = sortedDates[i]; + if (!currentDate) continue; + if (!datesToProcessSet.has(currentDate)) { + // Invalidate cache — the next new date needs to re-load this date's feeds + cachedPublic = null; + continue; + } + + const currDir = dateToDir.get(currentDate); + if (!currDir) continue; + + // Find the previous date in the sequence + const prevDate = i > 0 ? sortedDates[i - 1] : undefined; + + try { + // Load previous date's feeds — use cache if available + let prevPublic: ReturnType; + if (!prevDate) { + prevPublic = []; + } else if (cachedPublic?.date === prevDate) { + prevPublic = cachedPublic.feeds; + } else { + const prevDir = dateToDir.get(prevDate); + if (!prevDir) { + cachedPublic = null; + continue; + } + const prevRaw = await loadAfterFeeds(repoPath, prevDir); + prevPublic = transformFeeds(prevRaw); + } + + // Load current date's feeds + const currRaw = await loadAfterFeeds(repoPath, currDir); + const currPublic = transformFeeds(currRaw); + + const day = diffStates(currentDate, prevPublic, currPublic); + if (day.changes.length > 0) { + newDays.push(day); + } + + // Cache current feeds for the next iteration + cachedPublic = { date: currentDate, feeds: currPublic }; + + const label = !prevDate + ? `${String(currPublic.length)} feeds (first snapshot, ${String(day.changes.length)} added)` + : `${String(day.changes.length)} change(s)`; + console.log(` ${currentDate}: ${label}`); + } catch (error: unknown) { + cachedPublic = null; + const code = + error instanceof Error ? error.message.slice(0, 80) : "unknown"; + console.error(` ${currentDate}: failed — ${code}`); + } + } + + // 5. Merge new days into existing rollup + const mergedDays = fullRebuild + ? newDays + : [...(existing?.days ?? []), ...newDays]; + + // Sort by date descending (newest first) + mergedDays.sort((a, b) => b.date.localeCompare(a.date)); + + const proposed: DailyRollupFile = { + generatedAt: new Date().toISOString(), + source: SOURCE, + days: mergedDays, + }; + + // 6. Shrinkage guard + if (existing && !fullRebuild) { + checkShrinkage(existing, proposed); + } + + // 7. Write output + await fs.writeFile(ROLLUPS_PATH, `${JSON.stringify(proposed)}\n`); + console.log( + `\nDone. ${String(proposed.days.length)} day(s) in rollup, ${String(newDays.length)} new.`, + ); +} + +await main(); diff --git a/apps/developer-hub/scripts/lib/pro-price-feed-changelog.ts b/apps/developer-hub/scripts/lib/pro-price-feed-changelog.ts new file mode 100644 index 0000000000..c99cb25e4d --- /dev/null +++ b/apps/developer-hub/scripts/lib/pro-price-feed-changelog.ts @@ -0,0 +1,338 @@ +/** + * Shared helpers for the Pro Price Feed Changelog generator. + * + * All functions are pure (or filesystem-only) and importable by both + * the CLI entrypoint and unit tests. + */ + +import * as fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; + +import { + dailyRollupFileSchema, + type ChangeEntry, + type ChangeType, + type DailyRollup, + type DailyRollupFile, + type FieldDiff, +} from "../../src/data/pro-price-feed-changelog/types"; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DIR_NAME_REGEX = /^\d{4}-\d{2}-\d{2}-T\d{6}-[a-zA-Z0-9_-]+$/; +const DATE_EXTRACT_REGEX = /^(\d{4}-\d{2}-\d{2})/; + +// --------------------------------------------------------------------------- +// Governance feed schema (input from after.json) +// --------------------------------------------------------------------------- + +const governanceMetadataSchema = z.record(z.string(), z.unknown()); + +export const governanceFeedSchema = z.object({ + feedId: z.number().int(), + symbol: z.string().optional(), + state: z.string().optional(), + exponent: z.number().int().optional(), + minPublishers: z.number().int().optional(), + marketSchedule: z.string().optional(), + metadata: governanceMetadataSchema.optional(), +}); +export type GovernanceFeed = z.infer; + +const afterJsonSchema = z.object({ + feeds: z.array(governanceFeedSchema), +}); + +// --------------------------------------------------------------------------- +// Public feed record schema (output — strict whitelist) +// --------------------------------------------------------------------------- + +export const publicFeedRecordSchema = z + .object({ + pyth_lazer_id: z.number().int(), + symbol: z.string().nullable(), + state: z.string().nullable(), + exponent: z.number().int().nullable(), + min_publishers: z.number().int().nullable(), + schedule: z.string().nullable(), + name: z.string().nullable(), + description: z.string().nullable(), + asset_type: z.string().nullable(), + quote_currency: z.string().nullable(), + cmc_id: z.union([z.string(), z.number()]).nullable(), + }) + .strict(); +export type PublicFeedRecord = z.infer; + +// --------------------------------------------------------------------------- +// listProposalDirs +// --------------------------------------------------------------------------- + +export async function listProposalDirs(repoPath: string): Promise { + const entries = await fs.readdir(repoPath, { withFileTypes: true }); + const dirs: string[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (!DIR_NAME_REGEX.test(entry.name)) { + console.warn( + `[warn] Skipping non-conforming directory name: ${sanitizeDirName(entry.name)}`, + ); + continue; + } + dirs.push(entry.name); + } + + return dirs.sort(); +} + +// --------------------------------------------------------------------------- +// groupByDate +// --------------------------------------------------------------------------- + +export function groupByDate(dirs: string[]): Map { + const dateToLastDir = new Map(); + + for (const dir of dirs) { + const match = dir.match(DATE_EXTRACT_REGEX); + if (!match?.[1]) { + console.warn( + `[warn] Cannot extract date from dir: ${sanitizeDirName(dir)}`, + ); + continue; + } + // Sorted input means last write per date wins (= last proposal of that day) + dateToLastDir.set(match[1], dir); + } + + return dateToLastDir; +} + +// --------------------------------------------------------------------------- +// loadAfterFeeds +// --------------------------------------------------------------------------- + +export async function loadAfterFeeds( + repoPath: string, + dir: string, +): Promise { + const filePath = path.join(repoPath, dir, "after.json"); + const content = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(content) as unknown; + + // Destructure only `feeds` — publishers/governanceSources are never bound + const { feeds } = afterJsonSchema.parse(parsed); + return feeds; +} + +// --------------------------------------------------------------------------- +// transformFeeds — strict whitelist from governance → public format +// --------------------------------------------------------------------------- + +export function transformFeeds(feeds: GovernanceFeed[]): PublicFeedRecord[] { + return feeds.map((feed) => { + const metadata = feed.metadata ?? {}; + + const record: PublicFeedRecord = { + pyth_lazer_id: feed.feedId, + symbol: feed.symbol ?? null, + state: feed.state?.toLowerCase() ?? null, + exponent: feed.exponent ?? null, + min_publishers: feed.minPublishers ?? null, + schedule: feed.marketSchedule ?? null, + name: + typeof metadata.name === "string" ? metadata.name : null, + description: + typeof metadata.description === "string" + ? metadata.description + : null, + asset_type: + typeof metadata.asset_type === "string" + ? metadata.asset_type + : null, + quote_currency: + typeof metadata.quote_currency === "string" + ? metadata.quote_currency + : null, + cmc_id: + typeof metadata.cmc_id === "string" || + typeof metadata.cmc_id === "number" + ? metadata.cmc_id + : null, + }; + + // Validate with strict schema — unknown keys cause hard failure + return publicFeedRecordSchema.parse(record); + }); +} + +// --------------------------------------------------------------------------- +// diffStates — operates only on transformed public records +// --------------------------------------------------------------------------- + +export function diffStates( + date: string, + before: PublicFeedRecord[], + after: PublicFeedRecord[], +): DailyRollup { + const beforeById = new Map(before.map((r) => [r.pyth_lazer_id, r])); + const afterById = new Map(after.map((r) => [r.pyth_lazer_id, r])); + + const allIds = new Set([...beforeById.keys(), ...afterById.keys()]); + const changes: ChangeEntry[] = []; + const totals: Record = { + went_live: 0, + added: 0, + changed: 0, + removed: 0, + }; + + for (const id of [...allIds].sort((a, b) => a - b)) { + const prev = beforeById.get(id); + const curr = afterById.get(id); + + if (!prev && curr) { + changes.push({ + changeType: "added", + pythLazerId: id, + symbol: curr.symbol ?? "unknown", + name: curr.name ?? "unknown", + statusBefore: null, + statusAfter: curr.state, + changedFields: [], + }); + totals.added++; + continue; + } + + if (prev && !curr) { + changes.push({ + changeType: "removed", + pythLazerId: id, + symbol: prev.symbol ?? "unknown", + name: prev.name ?? "unknown", + statusBefore: prev.state, + statusAfter: null, + changedFields: [], + }); + totals.removed++; + continue; + } + + if (!prev || !curr) continue; + + const changedFields = diffRecordFields(prev, curr); + if (changedFields.length === 0) continue; + + const wentLive = + curr.state === "stable" && + prev.state !== null && + prev.state !== "stable"; + + const changeType: ChangeType = wentLive ? "went_live" : "changed"; + changes.push({ + changeType, + pythLazerId: id, + symbol: curr.symbol ?? "unknown", + name: curr.name ?? "unknown", + statusBefore: prev.state, + statusAfter: curr.state, + changedFields, + }); + totals[changeType]++; + } + + return { date, totals, changes }; +} + +// --------------------------------------------------------------------------- +// diffRecordFields — field-level diff between two public records +// --------------------------------------------------------------------------- + +/** Keys of PublicFeedRecord that are diffable (excludes the ID key). */ +const DIFFABLE_KEYS = [ + "asset_type", + "cmc_id", + "description", + "exponent", + "min_publishers", + "name", + "quote_currency", + "schedule", + "state", + "symbol", +] as const satisfies Exclude[]; + +function diffRecordFields( + before: PublicFeedRecord, + after: PublicFeedRecord, +): FieldDiff[] { + const fields: FieldDiff[] = []; + + for (const key of DIFFABLE_KEYS) { + const bVal = before[key]; + const aVal = after[key]; + + if (bVal === aVal) continue; + + fields.push({ path: key, before: bVal ?? null, after: aVal ?? null }); + } + + return fields; +} + +// --------------------------------------------------------------------------- +// sanitizeDirName +// --------------------------------------------------------------------------- + +export function sanitizeDirName(dir: string): string { + const match = dir.match(DATE_EXTRACT_REGEX); + return match?.[1] ?? "[invalid]"; +} + +// --------------------------------------------------------------------------- +// loadExistingRollup +// --------------------------------------------------------------------------- + +export async function loadExistingRollup( + rollupPath: string, +): Promise { + let content: string; + try { + content = await fs.readFile(rollupPath, "utf8"); + } catch (error: unknown) { + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code: string }).code === "ENOENT" + ) { + return null; + } + throw error; + } + + const parsed = JSON.parse(content) as unknown; + return dailyRollupFileSchema.parse(parsed); +} + +// --------------------------------------------------------------------------- +// checkShrinkage — abort if >50% entries lost +// --------------------------------------------------------------------------- + +export function checkShrinkage( + existing: DailyRollupFile, + proposed: DailyRollupFile, +): void { + if (existing.days.length === 0) return; + + const ratio = proposed.days.length / existing.days.length; + if (ratio < 0.5) { + throw new Error( + `Shrinkage guard: proposed rollup has ${String(proposed.days.length)} days vs existing ${String(existing.days.length)} (${String(Math.round(ratio * 100))}%). Aborting to prevent data loss.`, + ); + } +} diff --git a/apps/developer-hub/scripts/validate-pro-changelog-output.ts b/apps/developer-hub/scripts/validate-pro-changelog-output.ts new file mode 100644 index 0000000000..32f47dda3e --- /dev/null +++ b/apps/developer-hub/scripts/validate-pro-changelog-output.ts @@ -0,0 +1,121 @@ +/** + * Validates daily-rollups.json against the canonical schema and checks + * for sensitive keywords that should never appear in public output. + * + * Usage: pnpm tsx ./scripts/validate-pro-changelog-output.ts + */ + +import * as fs from "node:fs/promises"; +import path from "node:path"; + +import { dailyRollupFileSchema } from "../src/data/pro-price-feed-changelog/types"; + +const SCRIPT_DIR = path.dirname(new URL(import.meta.url).pathname); +const ROLLUPS_PATH = path.join( + SCRIPT_DIR, + "..", + "public", + "data", + "pro-price-feed-changelog", + "daily-rollups.json", +); + +const SENSITIVE_PATTERNS = [ + "hermes_id", + "publickey", + "governancesource", + "allowedpublisher", + "signingkey", + "privatekey", + "secret", + "credential", + "apikey", + "auth_token", + "encryption", +] as const; + +/** Keys that are allowed in the output JSON — anything else is suspicious. */ +const ALLOWED_KEYS = new Set([ + "generatedAt", + "source", + "days", + "date", + "totals", + "went_live", + "added", + "changed", + "removed", + "changes", + "changeType", + "pythLazerId", + "symbol", + "name", + "statusBefore", + "statusAfter", + "changedFields", + "path", + "before", + "after", +]); + +function collectKeys(value: unknown, keys: Set): void { + if (typeof value !== "object" || value === null) return; + if (Array.isArray(value)) { + for (const item of value) collectKeys(item, keys); + return; + } + for (const [key, child] of Object.entries(value)) { + keys.add(key); + collectKeys(child, keys); + } +} + +async function main() { + const content = await fs.readFile(ROLLUPS_PATH, "utf8"); + const data = JSON.parse(content) as unknown; + + // Strict schema validation + const result = dailyRollupFileSchema.strict().safeParse(data); + if (!result.success) { + console.error("Schema validation failed:"); + console.error(result.error.format()); + process.exit(1); + } + + console.log( + `Schema OK: ${String(result.data.days.length)} day(s) in rollup.`, + ); + + // Structural key check — flag any unexpected keys in the output + const allKeys = new Set(); + collectKeys(data, allKeys); + const unexpectedKeys = [...allKeys].filter((k) => !ALLOWED_KEYS.has(k)); + if (unexpectedKeys.length > 0) { + console.error( + `Unexpected key(s) found in output: ${unexpectedKeys.join(", ")}`, + ); + process.exit(1); + } + + console.log("Structural key check passed."); + + // Sensitive keyword check on raw content (defense-in-depth) + const lower = content.toLowerCase(); + const found: string[] = []; + for (const pattern of SENSITIVE_PATTERNS) { + if (lower.includes(pattern)) { + found.push(pattern); + } + } + + if (found.length > 0) { + console.error( + `Sensitive keyword(s) found in output: ${found.join(", ")}`, + ); + process.exit(1); + } + + console.log("Sensitive keyword check passed."); +} + +await main(); diff --git a/apps/developer-hub/src/components/PriceFeedIdsCoreTable/index.tsx b/apps/developer-hub/src/components/PriceFeedIdsCoreTable/index.tsx index 48950ed677..8341f49aea 100644 --- a/apps/developer-hub/src/components/PriceFeedIdsCoreTable/index.tsx +++ b/apps/developer-hub/src/components/PriceFeedIdsCoreTable/index.tsx @@ -13,6 +13,7 @@ import { matchSorter } from "match-sorter"; import { useEffect, useState } from "react"; import { z } from "zod"; +import { errorToString } from "../../lib/error-to-string"; import CopyAddress from "../CopyAddress"; import styles from "./index.module.scss"; @@ -121,15 +122,6 @@ export const PriceFeedIdsCoreTable = () => { ); }; -const errorToString = (error: unknown) => { - if (error instanceof Error) { - return error.message; - } else if (typeof error === "string") { - return error; - } else { - return "An error occurred, please try again"; - } -}; enum StateType { NotLoaded, diff --git a/apps/developer-hub/src/components/PriceFeedIdsProChangelog/index.module.scss b/apps/developer-hub/src/components/PriceFeedIdsProChangelog/index.module.scss new file mode 100644 index 0000000000..fe68a15af3 --- /dev/null +++ b/apps/developer-hub/src/components/PriceFeedIdsProChangelog/index.module.scss @@ -0,0 +1,42 @@ +@use "@pythnetwork/component-library/theme"; + +.wrapper { + display: flex; + flex-direction: column; + gap: theme.spacing(4); +} + +.controls { + display: flex; + flex-direction: column; + gap: theme.spacing(3); +} + +.switch { + width: fit-content; +} + +.dayList { + display: flex; + flex-direction: column; + gap: theme.spacing(4); + margin-bottom: theme.spacing(8); +} + +.dayCard { + display: flex; + flex-direction: column; + gap: theme.spacing(3); +} + +.badges { + display: flex; + flex-wrap: wrap; + gap: theme.spacing(2); +} + +.details { + display: flex; + flex-direction: column; + gap: theme.spacing(3); +} diff --git a/apps/developer-hub/src/components/PriceFeedIdsProChangelog/index.tsx b/apps/developer-hub/src/components/PriceFeedIdsProChangelog/index.tsx new file mode 100644 index 0000000000..4b42e66651 --- /dev/null +++ b/apps/developer-hub/src/components/PriceFeedIdsProChangelog/index.tsx @@ -0,0 +1,366 @@ +"use client"; + +import { CaretDown } from "@phosphor-icons/react/dist/ssr/CaretDown"; +import { CaretUp } from "@phosphor-icons/react/dist/ssr/CaretUp"; +import { Info } from "@phosphor-icons/react/dist/ssr/Info"; +import { Badge } from "@pythnetwork/component-library/Badge"; +import { Button } from "@pythnetwork/component-library/Button"; +import { Card } from "@pythnetwork/component-library/Card"; +import { NoResults } from "@pythnetwork/component-library/NoResults"; +import { Paginator } from "@pythnetwork/component-library/Paginator"; +import { SearchInput } from "@pythnetwork/component-library/SearchInput"; +import { Switch } from "@pythnetwork/component-library/Switch"; +import type { ColumnConfig } from "@pythnetwork/component-library/Table"; +import { Table } from "@pythnetwork/component-library/Table"; +import { Callout } from "fumadocs-ui/components/callout"; +import { matchSorter } from "match-sorter"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +import { + dailyRollupFileSchema, + type ChangeEntry, + type ChangeType, + type DailyRollupFile, + type FieldDiff, +} from "../../data/pro-price-feed-changelog/types"; +import { errorToString } from "../../lib/error-to-string"; +import styles from "./index.module.scss"; + +const DEFAULT_PAGE_SIZE = 10; +const SEARCH_DEBOUNCE_MS = 300; +const ROLLUP_DATA_URL = "/data/pro-price-feed-changelog/daily-rollups.json"; + +const CHANGE_TYPE_LABELS: Record = { + went_live: "Went Live", + added: "Added", + changed: "Changed", + removed: "Removed", +}; + +const CHANGE_TYPE_VARIANTS: Record< + ChangeType, + "success" | "info" | "warning" | "error" +> = { + went_live: "success", + added: "info", + changed: "warning", + removed: "error", +}; + +const dayFormatter = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + +enum StateType { + NotLoaded = "NotLoaded", + Loading = "Loading", + Loaded = "Loaded", + Error = "Error", +} + +const State = { + NotLoaded: () => ({ type: StateType.NotLoaded as const }), + Loading: () => ({ type: StateType.Loading as const }), + Loaded: (data: DailyRollupFile) => ({ + type: StateType.Loaded as const, + data, + }), + Error: (error: unknown) => ({ type: StateType.Error as const, error }), +}; +type State = ReturnType<(typeof State)[keyof typeof State]>; + +type Col = + | "symbol" + | "pythLazerId" + | "changeType" + | "status" + | "changedFields"; + +const columns: ColumnConfig[] = [ + { id: "symbol", name: "Symbol", isRowHeader: true }, + { id: "pythLazerId", name: "Pyth Pro ID" }, + { id: "changeType", name: "Change" }, + { id: "status", name: "Status" }, + { id: "changedFields", name: "Changed Fields" }, +]; + +export const PriceFeedIdsProChangelog = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + + const [state, setState] = useState(State.NotLoaded()); + const [search, setSearch] = useState(searchParams.get("q") ?? ""); + const [debouncedSearch, setDebouncedSearch] = useState(search); + const [includeAllFieldDiffs, setIncludeAllFieldDiffs] = useState(false); + const [expandedDay, setExpandedDay] = useState(null); + const [pageByDay, setPageByDay] = useState>({}); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + useEffect(() => { + setState(State.Loading()); + fetch(ROLLUP_DATA_URL) + .then(async (response) => { + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const data = dailyRollupFileSchema.parse(await response.json()); + setState(State.Loaded(data)); + if (data.days[0]) { + setExpandedDay(data.days[0].date); + } + }) + .catch((error: unknown) => { + setState(State.Error(error)); + }); + }, []); + + useEffect(() => { + const timer = setTimeout( + () => setDebouncedSearch(search), + SEARCH_DEBOUNCE_MS, + ); + return () => clearTimeout(timer); + }, [search]); + + useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (debouncedSearch) { + params.set("q", debouncedSearch); + } else { + params.delete("q"); + } + const qs = params.toString(); + router.replace(qs ? `?${qs}` : "?", { scroll: false }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- reading searchParams here would cause a re-render loop + }, [debouncedSearch, router]); + + const filteredDays = useMemo(() => { + if (state.type !== StateType.Loaded) return []; + + return state.data.days.map((day) => { + let visibleChanges = day.changes.filter((change) => { + if ( + !includeAllFieldDiffs && + change.changeType !== "went_live" && + change.changeType !== "added" && + change.changeType !== "removed" && + change.statusBefore === change.statusAfter + ) { + return false; + } + return true; + }); + + if (debouncedSearch) { + visibleChanges = matchSorter(visibleChanges, debouncedSearch, { + keys: ["symbol", "name", (item) => String(item.pythLazerId)], + }); + } + + return { ...day, visibleChanges }; + }); + }, [state, includeAllFieldDiffs, debouncedSearch]); + + const hasRollupHistory = + state.type === StateType.Loaded && state.data.days.length > 0; + const hasAnyVisibleData = filteredDays.some( + (d) => d.visibleChanges.length > 0, + ); + + return ( +
+ {state.type === StateType.Loaded && ( +
+ + + Include all property changes + +
+ )} + + {(state.type === StateType.NotLoaded || + state.type === StateType.Loading) && ( + + label="Pyth Pro Price Feed ID Changelog" + columns={columns} + rows={[]} + isLoading + rounded + fill + /> + )} + + {state.type === StateType.Error && ( + + Failed to load changelog data: {errorToString(state.error)} + + )} + + {state.type === StateType.Loaded && !hasRollupHistory && ( + } + header="No changelog entries yet" + body={ +

+ The first snapshot has been recorded. Daily changelog entries will + appear after the next UTC snapshot comparison. +

+ } + variant="info" + /> + )} + + {state.type === StateType.Loaded && + hasRollupHistory && + !hasAnyVisibleData && ( + { + setSearch(""); + }} + /> + )} + + {state.type === StateType.Loaded && + hasRollupHistory && + hasAnyVisibleData && ( +
+ {filteredDays.map((day) => { + if (day.visibleChanges.length === 0) return null; + + const isExpanded = expandedDay === day.date; + const page = pageByDay[day.date] ?? 1; + const numPages = Math.max( + 1, + Math.ceil(day.visibleChanges.length / pageSize), + ); + const safePage = Math.min(page, numPages); + const offset = (safePage - 1) * pageSize; + const pagedChanges = day.visibleChanges.slice( + offset, + offset + pageSize, + ); + + const rows = pagedChanges.map((change) => ({ + id: `${day.date}-${change.pythLazerId}-${change.changeType}`, + data: { + symbol: change.symbol, + pythLazerId: change.pythLazerId, + changeType: ( + + {CHANGE_TYPE_LABELS[change.changeType]} + + ), + status: formatStatus(change), + changedFields: summarizeChangedFields(change.changedFields), + }, + })); + + return ( + { + setExpandedDay((previous) => + previous === day.date ? null : day.date, + ); + }} + afterIcon={isExpanded ? : } + > + {isExpanded ? "Hide details" : "Show details"} + + } + > +
+ + Went Live: {day.totals.went_live} + + + Added: {day.totals.added} + + + Changed: {day.totals.changed} + + + Removed: {day.totals.removed} + + + Visible: {day.visibleChanges.length} + +
+ + {isExpanded && ( +
+ + label={`Pyth Pro changelog for ${formatDayLabel(day.date)}`} + columns={columns} + rows={rows} + rounded + fill + /> + { + setPageByDay((previous) => ({ + ...previous, + [day.date]: nextPage, + })); + }} + pageSize={pageSize} + onPageSizeChange={(nextSize: number) => { + setPageByDay({}); + setPageSize(nextSize); + }} + /> +
+ )} +
+ ); + })} +
+ )} +
+ ); +}; + +const formatDayLabel = (day: string) => { + const parsed = new Date(`${day}T00:00:00.000Z`); + if (Number.isNaN(parsed.getTime())) return `${day} (UTC)`; + return `${dayFormatter.format(parsed)} (UTC)`; +}; + +const formatStatus = (change: ChangeEntry) => { + if (change.statusBefore === change.statusAfter) + return change.statusAfter ?? "-"; + return `${change.statusBefore ?? "-"} -> ${change.statusAfter ?? "-"}`; +}; + +const summarizeChangedFields = (changedFields: FieldDiff[]) => { + if (changedFields.length === 0) return "-"; + const topFields = changedFields.slice(0, 3).map((field) => field.path); + if (changedFields.length <= 3) return topFields.join(", "); + return `${topFields.join(", ")}, +${String(changedFields.length - 3)} more`; +}; + diff --git a/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx b/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx index d0c3651683..3627d8fda6 100644 --- a/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx +++ b/apps/developer-hub/src/components/PriceFeedIdsProTable/index.tsx @@ -12,6 +12,7 @@ import { matchSorter } from "match-sorter"; import { useCallback, useEffect, useMemo, useState } from "react"; import { z } from "zod"; +import { errorToString } from "../../lib/error-to-string"; import styles from "./index.module.scss"; const FEED_STATES = ["stable", "coming_soon", "inactive"] as const; @@ -371,12 +372,3 @@ type Col = | "exponent" | "state"; -const errorToString = (error: unknown) => { - if (error instanceof Error) { - return error.message; - } else if (typeof error === "string") { - return error; - } else { - return "An error occurred, please try again"; - } -}; diff --git a/apps/developer-hub/src/data/pro-price-feed-changelog/types.ts b/apps/developer-hub/src/data/pro-price-feed-changelog/types.ts new file mode 100644 index 0000000000..fc798e36c0 --- /dev/null +++ b/apps/developer-hub/src/data/pro-price-feed-changelog/types.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; + +export const scalarValueSchema = z.union([ + z.string(), + z.number(), + z.boolean(), + z.null(), +]); +export type ScalarValue = z.infer; + +export const changeTypeSchema = z.enum([ + "went_live", + "added", + "changed", + "removed", +]); +export type ChangeType = z.infer; + +export const fieldDiffSchema = z.object({ + path: z.string(), + before: scalarValueSchema, + after: scalarValueSchema, +}); +export type FieldDiff = z.infer; + +export const changeEntrySchema = z.object({ + changeType: changeTypeSchema, + pythLazerId: z.number(), + symbol: z.string(), + name: z.string(), + statusBefore: z.string().nullable(), + statusAfter: z.string().nullable(), + changedFields: z.array(fieldDiffSchema), +}); +export type ChangeEntry = z.infer; + +export const dailyRollupSchema = z.object({ + date: z.string(), + totals: z.object({ + went_live: z.number(), + added: z.number(), + changed: z.number(), + removed: z.number(), + }), + changes: z.array(changeEntrySchema), +}); +export type DailyRollup = z.infer; + +export const dailyRollupFileSchema = z.object({ + generatedAt: z.string(), + source: z.string(), + days: z.array(dailyRollupSchema), +}); +export type DailyRollupFile = z.infer; diff --git a/apps/developer-hub/src/lib/error-to-string.ts b/apps/developer-hub/src/lib/error-to-string.ts new file mode 100644 index 0000000000..4b69ac0f61 --- /dev/null +++ b/apps/developer-hub/src/lib/error-to-string.ts @@ -0,0 +1,5 @@ +export const errorToString = (error: unknown): string => { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return "An error occurred, please try again"; +};