From 223b68cd678ec12b6f1458be5386f72eb78606a8 Mon Sep 17 00:00:00 2001 From: Satya Date: Sat, 17 Oct 2026 00:58:16 +0800 Subject: [PATCH 1/3] Viewer: new design system, app shell, search and live dashboard - Design tokens (light and dark) on Tailwind, matching the DevKit brand; self-hosted Inter, Space Grotesk and JetBrains Mono for offline devnets - App shell: collapsible sidebar grouped by task, top bar with search, network and live status, theme toggle, page progress bar, view transitions; replaces the full-screen spinner on every navigation - Search everywhere (Cmd/Ctrl+K, /, or paste): classifies the input and checks ambiguous hashes against the store - API client for server loads and the browser (honours PUBLIC_INDEXER_CLIENT_BASE_URL), BigInt-safe formatting - Live layer: WebSocket with reconnect and backoff, polling fallback for stores without the live module; reads aggregate_data correctly - Dashboard: live block conveyor with a forging countdown, block time, tx and block-load trends, latest transactions, epoch ring, supply - Upgrade Svelte 5.16 -> 5.57 and Kit 2.15 -> 2.70 (hydration fixes) Co-Authored-By: Claude Opus 5.5 --- applications/viewer/package-lock.json | 660 ++++++++++++------ applications/viewer/package.json | 14 +- applications/viewer/src/app.html | 19 +- applications/viewer/src/app.postcss | 182 ++++- applications/viewer/src/lib/api/client.ts | 111 +++ .../src/lib/dashboard/BlockConveyor.svelte | 81 +++ .../viewer/src/lib/dashboard/EpochCard.svelte | 43 ++ .../src/lib/dashboard/SupplyCard.svelte | 43 ++ .../viewer/src/lib/dashboard/TxFeed.svelte | 42 ++ applications/viewer/src/lib/format.test.ts | 34 + applications/viewer/src/lib/format.ts | 150 ++++ .../viewer/src/lib/live/chain.svelte.ts | 300 ++++++++ .../viewer/src/lib/search/classify.test.ts | 38 + .../viewer/src/lib/search/classify.ts | 122 ++++ .../src/lib/shell/CommandPalette.svelte | 209 ++++++ .../viewer/src/lib/shell/GithubIcon.svelte | 9 + .../viewer/src/lib/shell/KindIcon.svelte | 37 + .../viewer/src/lib/shell/LiveBadge.svelte | 87 +++ applications/viewer/src/lib/shell/Logo.svelte | 15 + .../viewer/src/lib/shell/NavProgress.svelte | 54 ++ .../viewer/src/lib/shell/Sidebar.svelte | 96 +++ .../viewer/src/lib/shell/ThemeToggle.svelte | 9 + .../viewer/src/lib/shell/Topbar.svelte | 34 + applications/viewer/src/lib/shell/nav.ts | 89 +++ .../viewer/src/lib/shell/ui.svelte.ts | 31 + applications/viewer/src/lib/ui/Ada.svelte | 20 + .../viewer/src/lib/ui/AssetChip.svelte | 22 + applications/viewer/src/lib/ui/Badge.svelte | 20 + .../viewer/src/lib/ui/CopyButton.svelte | 31 + .../viewer/src/lib/ui/DataTable.svelte | 85 +++ applications/viewer/src/lib/ui/Empty.svelte | 21 + .../viewer/src/lib/ui/ErrorBox.svelte | 24 + applications/viewer/src/lib/ui/Hash.svelte | 41 ++ .../viewer/src/lib/ui/JsonView.svelte | 51 ++ applications/viewer/src/lib/ui/KV.svelte | 11 + .../viewer/src/lib/ui/PageHeader.svelte | 28 + applications/viewer/src/lib/ui/Pager.svelte | 42 ++ applications/viewer/src/lib/ui/Panel.svelte | 40 ++ applications/viewer/src/lib/ui/Ring.svelte | 42 ++ .../viewer/src/lib/ui/Skeleton.svelte | 13 + .../viewer/src/lib/ui/Sparkline.svelte | 38 + applications/viewer/src/lib/ui/Stat.svelte | 49 ++ applications/viewer/src/lib/ui/Tabs.svelte | 48 ++ applications/viewer/src/lib/ui/TimeAgo.svelte | 14 + applications/viewer/src/lib/ui/Toaster.svelte | 20 + .../viewer/src/lib/ui/toast.svelte.ts | 30 + applications/viewer/src/routes/+layout.svelte | 110 ++- .../viewer/src/routes/+page.server.ts | 24 + applications/viewer/src/routes/+page.svelte | 214 +++--- applications/viewer/static/favicon.svg | 1 + applications/viewer/tailwind.config.cjs | 109 ++- 51 files changed, 3281 insertions(+), 376 deletions(-) create mode 100644 applications/viewer/src/lib/api/client.ts create mode 100644 applications/viewer/src/lib/dashboard/BlockConveyor.svelte create mode 100644 applications/viewer/src/lib/dashboard/EpochCard.svelte create mode 100644 applications/viewer/src/lib/dashboard/SupplyCard.svelte create mode 100644 applications/viewer/src/lib/dashboard/TxFeed.svelte create mode 100644 applications/viewer/src/lib/format.test.ts create mode 100644 applications/viewer/src/lib/format.ts create mode 100644 applications/viewer/src/lib/live/chain.svelte.ts create mode 100644 applications/viewer/src/lib/search/classify.test.ts create mode 100644 applications/viewer/src/lib/search/classify.ts create mode 100644 applications/viewer/src/lib/shell/CommandPalette.svelte create mode 100644 applications/viewer/src/lib/shell/GithubIcon.svelte create mode 100644 applications/viewer/src/lib/shell/KindIcon.svelte create mode 100644 applications/viewer/src/lib/shell/LiveBadge.svelte create mode 100644 applications/viewer/src/lib/shell/Logo.svelte create mode 100644 applications/viewer/src/lib/shell/NavProgress.svelte create mode 100644 applications/viewer/src/lib/shell/Sidebar.svelte create mode 100644 applications/viewer/src/lib/shell/ThemeToggle.svelte create mode 100644 applications/viewer/src/lib/shell/Topbar.svelte create mode 100644 applications/viewer/src/lib/shell/nav.ts create mode 100644 applications/viewer/src/lib/shell/ui.svelte.ts create mode 100644 applications/viewer/src/lib/ui/Ada.svelte create mode 100644 applications/viewer/src/lib/ui/AssetChip.svelte create mode 100644 applications/viewer/src/lib/ui/Badge.svelte create mode 100644 applications/viewer/src/lib/ui/CopyButton.svelte create mode 100644 applications/viewer/src/lib/ui/DataTable.svelte create mode 100644 applications/viewer/src/lib/ui/Empty.svelte create mode 100644 applications/viewer/src/lib/ui/ErrorBox.svelte create mode 100644 applications/viewer/src/lib/ui/Hash.svelte create mode 100644 applications/viewer/src/lib/ui/JsonView.svelte create mode 100644 applications/viewer/src/lib/ui/KV.svelte create mode 100644 applications/viewer/src/lib/ui/PageHeader.svelte create mode 100644 applications/viewer/src/lib/ui/Pager.svelte create mode 100644 applications/viewer/src/lib/ui/Panel.svelte create mode 100644 applications/viewer/src/lib/ui/Ring.svelte create mode 100644 applications/viewer/src/lib/ui/Skeleton.svelte create mode 100644 applications/viewer/src/lib/ui/Sparkline.svelte create mode 100644 applications/viewer/src/lib/ui/Stat.svelte create mode 100644 applications/viewer/src/lib/ui/Tabs.svelte create mode 100644 applications/viewer/src/lib/ui/TimeAgo.svelte create mode 100644 applications/viewer/src/lib/ui/Toaster.svelte create mode 100644 applications/viewer/src/lib/ui/toast.svelte.ts create mode 100644 applications/viewer/src/routes/+page.server.ts create mode 100644 applications/viewer/static/favicon.svg diff --git a/applications/viewer/package-lock.json b/applications/viewer/package-lock.json index d5da6c7c..9b4d5497 100644 --- a/applications/viewer/package-lock.json +++ b/applications/viewer/package-lock.json @@ -18,11 +18,15 @@ "yaci-viewer": "cli.js" }, "devDependencies": { + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/space-grotesk": "5.3.0", + "@lucide/svelte": "1.47.0", "@playwright/test": "^1.46.0", "@popperjs/core": "^2.11.8", - "@sveltejs/adapter-node": "^5.2.11", - "@sveltejs/kit": "^2.15.1", - "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@sveltejs/adapter-node": "5.5.7", + "@sveltejs/kit": "2.70.3", + "@sveltejs/vite-plugin-svelte": "5.1.1", "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "autoprefixer": "^10.4.20", @@ -34,8 +38,8 @@ "postcss-load-config": "^6.0.1", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", - "svelte": "^5.16.0", - "svelte-check": "^4.1.1", + "svelte": "5.57.1", + "svelte-check": "4.7.6", "svelte-preprocess": "^6.0.3", "tailwindcss": "^3.4.8", "tslib": "^2.6.3", @@ -56,19 +60,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@esbuild/aix-ppc64": { "version": "0.24.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", @@ -617,6 +608,36 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -701,6 +722,17 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", @@ -720,10 +752,11 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", - "dev": true + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -735,6 +768,33 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/svelte": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-1.47.0.tgz", + "integrity": "sha512-tpuE7JIK9FzYDWsJx6PxHj9ROyCpnDxZfvHu5q5yHGKS2CFNjzSyf1o9/Ypu2A27GLvkPGqZcIMrF6idM+eQZQ==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -813,9 +873,9 @@ } }, "node_modules/@rollup/plugin-commonjs": { - "version": "28.0.2", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.2.tgz", - "integrity": "sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==", + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", "dev": true, "license": "MIT", "dependencies": { @@ -847,11 +907,14 @@ "license": "MIT" }, "node_modules/@rollup/plugin-commonjs/node_modules/fdir": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.2.tgz", - "integrity": "sha512-KnhMXsKSPZlAhp7+IjUkRZKPb4fUyccpDrdFXbi4QL1qkmFh9kVY09Yox+n4MaOb3lHZ1Tv829C3oaaXoMYPDQ==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -872,9 +935,9 @@ } }, "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -930,6 +993,28 @@ } } }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz", @@ -974,9 +1059,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.29.1.tgz", - "integrity": "sha512-ssKhA8RNltTZLpG6/QNkCSge+7mBQGUqJRisZ2MDQcEGaK93QESEgWK2iOpIDZ7k9zPVkG5AS3ksvD5ZWxmItw==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", "cpu": [ "arm" ], @@ -988,9 +1073,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.29.1.tgz", - "integrity": "sha512-CaRfrV0cd+NIIcVVN/jx+hVLN+VRqnuzLRmfmlzpOzB87ajixsN/+9L5xNmkaUUvEbI5BmIKS+XTwXsHEb65Ew==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", "cpu": [ "arm64" ], @@ -1002,9 +1087,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.29.1.tgz", - "integrity": "sha512-2ORr7T31Y0Mnk6qNuwtyNmy14MunTAMx06VAPI6/Ju52W10zk1i7i5U3vlDRWjhOI5quBcrvhkCHyF76bI7kEw==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", "cpu": [ "arm64" ], @@ -1016,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.29.1.tgz", - "integrity": "sha512-j/Ej1oanzPjmN0tirRd5K2/nncAhS9W6ICzgxV+9Y5ZsP0hiGhHJXZ2JQ53iSSjj8m6cRY6oB1GMzNn2EUt6Ng==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", "cpu": [ "x64" ], @@ -1030,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.29.1.tgz", - "integrity": "sha512-91C//G6Dm/cv724tpt7nTyP+JdN12iqeXGFM1SqnljCmi5yTXriH7B1r8AD9dAZByHpKAumqP1Qy2vVNIdLZqw==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", "cpu": [ "arm64" ], @@ -1044,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.29.1.tgz", - "integrity": "sha512-hEioiEQ9Dec2nIRoeHUP6hr1PSkXzQaCUyqBDQ9I9ik4gCXQZjJMIVzoNLBRGet+hIUb3CISMh9KXuCcWVW/8w==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", "cpu": [ "x64" ], @@ -1058,9 +1143,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.29.1.tgz", - "integrity": "sha512-Py5vFd5HWYN9zxBv3WMrLAXY3yYJ6Q/aVERoeUFwiDGiMOWsMs7FokXihSOaT/PMWUty/Pj60XDQndK3eAfE6A==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", "cpu": [ "arm" ], @@ -1072,9 +1157,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.29.1.tgz", - "integrity": "sha512-RiWpGgbayf7LUcuSNIbahr0ys2YnEERD4gYdISA06wa0i8RALrnzflh9Wxii7zQJEB2/Eh74dX4y/sHKLWp5uQ==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", "cpu": [ "arm" ], @@ -1086,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.29.1.tgz", - "integrity": "sha512-Z80O+taYxTQITWMjm/YqNoe9d10OX6kDh8X5/rFCMuPqsKsSyDilvfg+vd3iXIqtfmp+cnfL1UrYirkaF8SBZA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", "cpu": [ "arm64" ], @@ -1100,9 +1185,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.29.1.tgz", - "integrity": "sha512-fOHRtF9gahwJk3QVp01a/GqS4hBEZCV1oKglVVq13kcK3NeVlS4BwIFzOHDbmKzt3i0OuHG4zfRP0YoG5OF/rA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", "cpu": [ "arm64" ], @@ -1113,10 +1198,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.29.1.tgz", - "integrity": "sha512-5a7q3tnlbcg0OodyxcAdrrCxFi0DgXJSoOuidFUzHZ2GixZXQs6Tc3CHmlvqKAmOs5eRde+JJxeIf9DonkmYkw==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", "cpu": [ "loong64" ], @@ -1127,10 +1226,24 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.29.1.tgz", - "integrity": "sha512-9b4Mg5Yfz6mRnlSPIdROcfw1BU22FQxmfjlp/CShWwO3LilKQuMISMTtAu/bxmmrE6A902W2cZJuzx8+gJ8e9w==", + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", "cpu": [ "ppc64" ], @@ -1142,9 +1255,23 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.29.1.tgz", - "integrity": "sha512-G5pn0NChlbRM8OJWpJFMX4/i8OEU538uiSv0P6roZcbpe/WfhEO+AT8SHVKfp8qhDQzaz7Q+1/ixMy7hBRidnQ==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", "cpu": [ "riscv64" ], @@ -1156,9 +1283,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.29.1.tgz", - "integrity": "sha512-WM9lIkNdkhVwiArmLxFXpWndFGuOka4oJOZh8EP3Vb8q5lzdSCBuhjavJsw68Q9AKDGeOOIHYzYm4ZFvmWez5g==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", "cpu": [ "s390x" ], @@ -1170,9 +1297,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.29.1.tgz", - "integrity": "sha512-87xYCwb0cPGZFoGiErT1eDcssByaLX4fc0z2nRM6eMtV9njAfEE6OW3UniAoDhX4Iq5xQVpE6qO9aJbCFumKYQ==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", "cpu": [ "x64" ], @@ -1184,9 +1311,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.29.1.tgz", - "integrity": "sha512-xufkSNppNOdVRCEC4WKvlR1FBDyqCSCpQeMMgv9ZyXqqtKBfkw1yfGMTUTs9Qsl6WQbJnsGboWCp7pJGkeMhKA==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", "cpu": [ "x64" ], @@ -1197,10 +1324,38 @@ "linux" ] }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.29.1.tgz", - "integrity": "sha512-F2OiJ42m77lSkizZQLuC+jiZ2cgueWQL5YC9tjo3AgaEw+KJmVxHGSyQfDUoYR9cci0lAywv2Clmckzulcq6ig==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", "cpu": [ "arm64" ], @@ -1212,9 +1367,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.29.1.tgz", - "integrity": "sha512-rYRe5S0FcjlOBZQHgbTKNrqxCBUmgDJem/VQTCcTnA2KCabYSWQDrytOzX7avb79cAAweNmMUb/Zw18RNd4mng==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", "cpu": [ "ia32" ], @@ -1225,10 +1380,24 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.29.1.tgz", - "integrity": "sha512-+10CMg9vt1MoHj6x1pxyjPSMjHTIlqs8/tBztXvPAx24SKs9jwVnKqHJumlH/IzhaPUaj3T6T6wfZr8okdXaIg==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", "cpu": [ "x64" ], @@ -1239,42 +1408,59 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@sveltejs/adapter-node": { - "version": "5.2.11", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.11.tgz", - "integrity": "sha512-lR7/dfUaKFf3aI408KRDy/BVDYoqUws7zNOJz2Hl4JoshlTnMgdha3brXBRFXB+cWtYvJjjPhvmq3xqpbioi4w==", + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", "dev": true, "license": "MIT", "dependencies": { - "@rollup/plugin-commonjs": "^28.0.1", + "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.0", - "rollup": "^4.9.5" + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0" } }, "node_modules/@sveltejs/kit": { - "version": "2.15.1", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.15.1.tgz", - "integrity": "sha512-8t7D3hQHbUDMiaQ2RVnjJJ/+Ur4Fn/tkeySJCsHtX346Q9cp3LAnav8xXdfuqYNJwpUGX0x3BqF1uvbmXQw93A==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "dev": true, - "hasInstallScript": true, "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", "cookie": "^0.6.0", - "devalue": "^5.1.0", - "esm-env": "^1.2.1", - "import-meta-resolve": "^4.1.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", - "sade": "^1.8.1", - "set-cookie-parser": "^2.6.0", - "sirv": "^3.0.0", - "tiny-glob": "^0.2.9" + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -1283,24 +1469,44 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3 || ^6.0.0" + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.3.tgz", + "integrity": "sha512-VT3qmUb8pRV2QrZjd8iAmtg8lf4W0TIjZbvXtz5MKei/q96teWZgGJyyidJzOjzZzvdq616eSRVeMYIQChUTAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.0.3.tgz", - "integrity": "sha512-MCFS6CrQDu1yGwspm4qtli0e63vaPCehf6V7pIMP15AsWgMKrqDGCPFF/0kn4SP0ii4aySu4Pa62+fIRGFMjgw==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", + "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", "dev": true, "license": "MIT", "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.0", + "debug": "^4.4.1", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.15", - "vitefu": "^1.0.4" + "magic-string": "^0.30.17", + "vitefu": "^1.0.6" }, "engines": { "node": "^18.0.0 || ^20.0.0 || >=22" @@ -1335,9 +1541,9 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "dev": true, "license": "MIT" }, @@ -1617,10 +1823,11 @@ } }, "node_modules/acorn": { - "version": "8.12.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", - "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", "dev": true, + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -1637,16 +1844,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-typescript": { - "version": "1.4.13", - "resolved": "https://registry.npmjs.org/acorn-typescript/-/acorn-typescript-1.4.13.tgz", - "integrity": "sha512-xsc9Xv0xlVfwp2o7sQ+GCQ1PgbkdcpWdTzrwXxO3xDMTAywVS3oXVOcOHuRjAPkS4P9b+yc/qNF15460v+jp4Q==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": ">=8.9.0" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1717,9 +1914,9 @@ "dev": true }, "node_modules/aria-query": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", - "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2168,9 +2365,9 @@ } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, "license": "MIT", "dependencies": { @@ -2211,9 +2408,9 @@ } }, "node_modules/devalue": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", - "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", "dev": true, "license": "MIT" }, @@ -2465,9 +2662,9 @@ } }, "node_modules/esm-env": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.1.tgz", - "integrity": "sha512-U9JedYYjCnadUlXk7e1Kr+aENQhtUaoaV9+gZm1T8LC/YBAPJx3NSPIAurFOC0U5vrdSevnUJS2/wUVxGwPhng==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "dev": true, "license": "MIT" }, @@ -2512,16 +2709,6 @@ "node": ">=0.10" } }, - "node_modules/esrap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-1.3.2.tgz", - "integrity": "sha512-C4PXusxYhFT98GjLSmb20k9PREuUdporer50dhzGuJu9IJXktbMddVCMLAERl5dAHyAi73GWWCE4FVHGP1794g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -2794,12 +2981,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -2820,12 +3001,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -2878,16 +3053,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -3780,13 +3945,13 @@ } }, "node_modules/rollup": { - "version": "4.29.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.29.1.tgz", - "integrity": "sha512-RaJ45M/kmJUzSWDs1Nnd5DdV4eerC98idtUOVr6FfKcgxqvjwHmxc5upLF9qZU9EpsVzzhleFahrT3shLuJzIw==", + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -3796,25 +3961,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.29.1", - "@rollup/rollup-android-arm64": "4.29.1", - "@rollup/rollup-darwin-arm64": "4.29.1", - "@rollup/rollup-darwin-x64": "4.29.1", - "@rollup/rollup-freebsd-arm64": "4.29.1", - "@rollup/rollup-freebsd-x64": "4.29.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.29.1", - "@rollup/rollup-linux-arm-musleabihf": "4.29.1", - "@rollup/rollup-linux-arm64-gnu": "4.29.1", - "@rollup/rollup-linux-arm64-musl": "4.29.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.29.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.29.1", - "@rollup/rollup-linux-riscv64-gnu": "4.29.1", - "@rollup/rollup-linux-s390x-gnu": "4.29.1", - "@rollup/rollup-linux-x64-gnu": "4.29.1", - "@rollup/rollup-linux-x64-musl": "4.29.1", - "@rollup/rollup-win32-arm64-msvc": "4.29.1", - "@rollup/rollup-win32-ia32-msvc": "4.29.1", - "@rollup/rollup-win32-x64-msvc": "4.29.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", "fsevents": "~2.3.2" } }, @@ -3866,10 +4038,11 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.0.tgz", - "integrity": "sha512-lXLOiqpkUumhRdFF3k1osNXCy9akgx/dyPZ5p8qAg9seJzXr5ZrlqZuWIMuY6ejOsVLE6flJ5/h3lsn57fQ/PQ==", - "dev": true + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.3.tgz", + "integrity": "sha512-xletSdAI5efyVJjsatBbzDkXl3M/Zmad3xfnQe7ZXuBB0yNwW7C2WFsNGjdRNfZunmNHQuX79xs/ACMTLCdcew==", + "dev": true, + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -4105,22 +4278,23 @@ } }, "node_modules/svelte": { - "version": "5.16.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.16.0.tgz", - "integrity": "sha512-Ygqsiac6UogVED2ruKclU+pOeMThxWtp9LG+li7BXeDKC2paVIsRTMkNmcON4Zejerd1s5sZHWx6ZtU85xklVg==", + "version": "5.57.1", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.1.tgz", + "integrity": "sha512-Uqj49lWKB+iSSnneuwiYYJ7MZgkB+eXr0LXBhv4uDuAkXqnWmq65Sxflfvp0Lc6MdKjMUxGaeOKWJqz5SNiVIA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@types/estree": "^1.0.5", - "acorn": "^8.12.1", - "acorn-typescript": "^1.4.13", - "aria-query": "^5.3.1", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", + "devalue": "^5.9.2", "esm-env": "^1.2.1", - "esrap": "^1.3.2", + "esrap": "^2.3.6", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -4131,13 +4305,14 @@ } }, "node_modules/svelte-check": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.1.1.tgz", - "integrity": "sha512-NfaX+6Qtc8W/CyVGS/F7/XdiSSyXz+WGYA9ZWV3z8tso14V2vzjfXviKaTFEzB7g8TqfgO2FOzP6XT4ApSTUTw==", + "version": "4.7.6", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.6.tgz", + "integrity": "sha512-t2scM//ZuVbSY/T2w6FSBw1v9s2NEmh/g+sy1lqtosW5ylBV5AF4wFb1Ts9Kf3MbfPDUDJDZ9L436YT0SPTdvw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.3", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", @@ -4151,7 +4326,7 @@ }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": ">=5.0.0" + "typescript": "^5.0.0 || ^6.0.0" } }, "node_modules/svelte-check/node_modules/chokidar": { @@ -4286,6 +4461,40 @@ } } }, + "node_modules/svelte/node_modules/@typescript-eslint/types": { + "version": "8.71.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz", + "integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/svelte/node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/tailwindcss": { "version": "3.4.8", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.8.tgz", @@ -4406,16 +4615,6 @@ "node": ">=0.8" } }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dev": true, - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -5190,17 +5389,18 @@ } }, "node_modules/vitefu": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.4.tgz", - "integrity": "sha512-y6zEE3PQf6uu/Mt6DTJ9ih+kyJLr4XcSgHR2zUkM8SWDhuixEJxfJ6CZGMHh1Ec3vPLoEA0IHU5oWzVqw8ulow==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", "dev": true, "license": "MIT", "workspaces": [ "tests/deps/*", - "tests/projects/*" + "tests/projects/*", + "tests/projects/workspace/packages/*" ], "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "vite": { diff --git a/applications/viewer/package.json b/applications/viewer/package.json index 4370de28..a6d99ba3 100644 --- a/applications/viewer/package.json +++ b/applications/viewer/package.json @@ -29,11 +29,15 @@ "cli.js" ], "devDependencies": { + "@fontsource-variable/inter": "5.3.0", + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/space-grotesk": "5.3.0", + "@lucide/svelte": "1.47.0", "@playwright/test": "^1.46.0", "@popperjs/core": "^2.11.8", - "@sveltejs/adapter-node": "^5.2.11", - "@sveltejs/kit": "^2.15.1", - "@sveltejs/vite-plugin-svelte": "^5.0.3", + "@sveltejs/adapter-node": "5.5.7", + "@sveltejs/kit": "2.70.3", + "@sveltejs/vite-plugin-svelte": "5.1.1", "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "autoprefixer": "^10.4.20", @@ -45,8 +49,8 @@ "postcss-load-config": "^6.0.1", "prettier": "^3.3.3", "prettier-plugin-svelte": "^3.2.6", - "svelte": "^5.16.0", - "svelte-check": "^4.1.1", + "svelte": "5.57.1", + "svelte-check": "4.7.6", "svelte-preprocess": "^6.0.3", "tailwindcss": "^3.4.8", "tslib": "^2.6.3", diff --git a/applications/viewer/src/app.html b/applications/viewer/src/app.html index effe0d0d..7e3cf082 100644 --- a/applications/viewer/src/app.html +++ b/applications/viewer/src/app.html @@ -1,9 +1,22 @@ - + - - + + + + %sveltekit.head% diff --git a/applications/viewer/src/app.postcss b/applications/viewer/src/app.postcss index 9d4ead6e..ccbd3d8f 100644 --- a/applications/viewer/src/app.postcss +++ b/applications/viewer/src/app.postcss @@ -1,11 +1,179 @@ -/* Write your global styles here, in PostCSS syntax */ @tailwind base; @tailwind components; @tailwind utilities; -@media (max-width: 640px) { - .break-words { - white-space: normal; - word-break: break-word; - } - } +/* ========================================================================== + Design tokens. Colours are RGB channels so Tailwind can add alpha (bg-brand/10). + Dark is html[data-theme="yaci-dark"]; light is the default. + ========================================================================== */ +@layer base { + :root { + --c-bg: 250 248 246; + --c-bg-2: 243 240 237; + --c-surface: 255 255 255; + --c-surface-2: 247 245 243; + --c-line: 230 226 234; + --c-ink: 20 18 28; + --c-muted: 84 80 100; + --c-faint: 138 134 152; + --c-brand: 232 80 28; + --c-brand-2: 228 106 18; + --c-amber: 185 120 6; + --c-blue: 31 107 255; + --c-cyan: 8 145 178; + --c-mint: 5 150 105; + --c-violet: 124 58 237; + --c-pink: 219 39 119; + --c-danger: 220 38 38; + --grad: linear-gradient(115deg, #e8401c 0%, #f06a1a 50%, #d99a06 100%); + --glow-1: rgb(255 90 54 / 0.14); + --glow-2: rgb(124 58 237 / 0.08); + --sidebar-w: 248px; + color-scheme: light; + } + + html[data-theme='yaci-dark'] { + --c-bg: 7 8 13; + --c-bg-2: 12 14 21; + --c-surface: 16 18 28; + --c-surface-2: 22 25 38; + --c-line: 34 37 54; + --c-ink: 238 240 248; + --c-muted: 161 166 189; + --c-faint: 106 112 140; + --c-brand: 255 106 52; + --c-brand-2: 255 138 61; + --c-amber: 251 191 36; + --c-blue: 59 139 255; + --c-cyan: 34 211 238; + --c-mint: 52 211 153; + --c-violet: 167 139 250; + --c-pink: 244 114 182; + --c-danger: 248 113 113; + --grad: linear-gradient(115deg, #ff4e2d 0%, #ff8a3d 45%, #fbbf24 100%); + --glow-1: rgb(255 78 45 / 0.12); + --glow-2: rgb(167 139 250 / 0.08); + color-scheme: dark; + } + + html { + -webkit-tap-highlight-color: transparent; + } + + body { + @apply bg-bg font-sans text-ink antialiased; + font-feature-settings: 'cv11', 'ss01'; + text-rendering: optimizeLegibility; + } + + h1, + h2, + h3 { + @apply font-display tracking-tight; + } + + code, + kbd, + .mono { + @apply font-mono; + font-feature-settings: 'zero'; + } + + :focus-visible { + outline: 2px solid rgb(var(--c-brand)); + outline-offset: 2px; + border-radius: 6px; + } + + ::selection { + background: rgb(var(--c-brand) / 0.25); + } +} + +/* ========================================================================== + Building blocks shared by the components. Prefixed y- so they don't clash with DaisyUI while it's still here. + ========================================================================== */ +@layer components { + .y-panel { + @apply rounded-2xl border border-line bg-surface shadow-card; + } + + .y-panel-head { + @apply flex items-center justify-between gap-3 border-b border-line px-5 py-3.5; + } + + .y-label { + @apply text-[11px] font-semibold uppercase tracking-[0.08em] text-faint; + } + + .y-link { + @apply text-brand underline-offset-4 transition-colors hover:text-brand-2 hover:underline; + } + + .y-btn { + @apply inline-flex h-9 items-center justify-center gap-2 rounded-xl border border-line bg-surface px-3.5 text-sm font-medium text-ink transition-all hover:border-brand/40 hover:bg-surface-2 active:scale-[0.98] disabled:pointer-events-none disabled:opacity-40; + } + + .y-btn-primary { + @apply border-transparent text-white shadow-glow hover:brightness-110; + background-image: var(--grad); + } + + .y-icon-btn { + @apply inline-grid h-9 w-9 place-items-center rounded-xl border border-line bg-surface text-muted transition-all hover:border-brand/40 hover:text-ink active:scale-95; + } + + .y-input { + @apply h-10 w-full rounded-xl border border-line bg-surface px-3.5 text-sm text-ink placeholder:text-faint transition focus:border-brand/60 focus:outline-none focus:ring-4 focus:ring-brand/15; + } + + .y-pill { + @apply inline-flex items-center gap-1.5 whitespace-nowrap rounded-full border px-2.5 py-0.5 text-xs font-medium; + } + + .y-skel { + @apply rounded-md; + background: linear-gradient( + 90deg, + rgb(var(--c-line) / 0.55) 0%, + rgb(var(--c-line) / 0.95) 50%, + rgb(var(--c-line) / 0.55) 100% + ); + background-size: 200% 100%; + animation: shimmer 1.6s linear infinite; + } + + .y-grad-text { + background-image: var(--grad); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + + .y-kbd { + @apply inline-flex h-5 min-w-5 items-center justify-center rounded-md border border-line bg-surface-2 px-1 font-mono text-[10px] font-medium text-muted; + } +} + +@keyframes shimmer { + to { + background-position: -200% 0; + } +} + +/* Smooth page changes where the browser supports view transitions */ +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 0.18s; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + scroll-behavior: auto !important; + } +} diff --git a/applications/viewer/src/lib/api/client.ts b/applications/viewer/src/lib/api/client.ts new file mode 100644 index 00000000..4e152357 --- /dev/null +++ b/applications/viewer/src/lib/api/client.ts @@ -0,0 +1,111 @@ +import { env } from '$env/dynamic/public'; +import { browser } from '$app/environment'; + +/* + * One way to call Yaci Store, from server loads and from the browser. + * In Docker the server reaches the store by its service name, while the browser needs + * PUBLIC_INDEXER_CLIENT_BASE_URL (localhost), so the base depends on where the code runs. + */ + +const DEFAULT_BASE = 'http://localhost:8080/api/v1'; + +export function apiBase(): string { + const base = browser + ? env.PUBLIC_INDEXER_CLIENT_BASE_URL || env.PUBLIC_INDEXER_BASE_URL + : env.PUBLIC_INDEXER_BASE_URL; + return (base || DEFAULT_BASE).replace(/\/+$/, ''); +} + +export function wsUrl(): string | null { + return env.PUBLIC_INDEXER_WS_URL || null; +} + +export class ApiError extends Error { + constructor( + message: string, + public status: number, + public path: string + ) { + super(message); + } +} + +type Params = Record; + +export interface ApiOptions { + fetch?: typeof fetch; + params?: Params; + timeoutMs?: number; + signal?: AbortSignal; +} + +function buildUrl(path: string, params?: Params): string { + const url = new URL(apiBase() + (path.startsWith('/') ? path : `/${path}`)); + for (const [k, v] of Object.entries(params ?? {})) { + if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); + } + return url.toString(); +} + +/** GET a JSON resource. Throws ApiError on HTTP errors and timeouts. */ +export async function api(path: string, opts: ApiOptions = {}): Promise { + const f = opts.fetch ?? fetch; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 20000); + opts.signal?.addEventListener('abort', () => ctrl.abort()); + const url = buildUrl(path, opts.params); + try { + const res = await f(url, { signal: ctrl.signal, headers: { accept: 'application/json' } }); + if (!res.ok) { + let detail = res.statusText; + try { + const body = await res.json(); + detail = body?.detail || body?.message || body?.error || detail; + } catch { + /* not JSON */ + } + throw new ApiError(detail || `HTTP ${res.status}`, res.status, path); + } + const text = await res.text(); + if (!text) return null as T; + try { + return JSON.parse(text) as T; + } catch { + return text as unknown as T; + } + } catch (e) { + if (e instanceof ApiError) throw e; + const aborted = (e as Error)?.name === 'AbortError'; + throw new ApiError(aborted ? 'The request timed out' : 'Yaci Store is not reachable', 0, path); + } finally { + clearTimeout(timer); + } +} + +/** Like api(), but returns null instead of throwing. For optional sections of a page. */ +export async function tryApi(path: string, opts: ApiOptions = {}): Promise { + try { + return await api(path, opts); + } catch { + return null; + } +} + +/** Read ?page=&count= from a URL. Pages are 1-based in the UI. */ +export function paging(url: URL, defaultCount = 20, maxCount = 100): { page: number; count: number } { + const page = Math.max(1, parseInt(url.searchParams.get('page') ?? '1', 10) || 1); + const count = Math.min(maxCount, Math.max(1, parseInt(url.searchParams.get('count') ?? '', 10) || defaultCount)); + return { page, count }; +} + +/** Normalise list responses: plain arrays, or objects wrapping the list under a known key. */ +export function listOf(data: unknown, ...keys: string[]): T[] { + if (Array.isArray(data)) return data as T[]; + if (data && typeof data === 'object') { + for (const k of keys) { + const v = (data as Record)[k]; + if (Array.isArray(v)) return v as T[]; + } + } + return []; +} diff --git a/applications/viewer/src/lib/dashboard/BlockConveyor.svelte b/applications/viewer/src/lib/dashboard/BlockConveyor.svelte new file mode 100644 index 00000000..530c9f47 --- /dev/null +++ b/applications/viewer/src/lib/dashboard/BlockConveyor.svelte @@ -0,0 +1,81 @@ + + +
+
    + {#if tip} + +
  1. +
    +
    + Next + +
    +
    #{num(tip.number + 1)}
    +
    +
    +
    +
    +
    + {#if overdue}waiting for a slot leader…{:else if interval - elapsed < 1}any moment…{:else}~{Math.ceil(interval - elapsed)}s · forging{/if} +
    +
    +
    +
  2. + {/if} + + {#each shown as b, i (b.number)} +
  3. + + + + + +
    + + {timeAgo(b.time, chain.now)} +
    +
    #{num(b.number)}
    +
    + {b.txCount} tx{b.txCount === 1 ? '' : 's'} + {kb(b.size)} +
    +
    +
  4. + {/each} +
+
diff --git a/applications/viewer/src/lib/dashboard/EpochCard.svelte b/applications/viewer/src/lib/dashboard/EpochCard.svelte new file mode 100644 index 00000000..2f07eafe --- /dev/null +++ b/applications/viewer/src/lib/dashboard/EpochCard.svelte @@ -0,0 +1,43 @@ + + +
+ +
+
{(progress * 100).toFixed(1)}%
+
of epoch
+
+
+
+
Epoch
+ {e ? num(e.epoch) : '—'} +
+
+
Ends in
+
{e ? duration(left) : '—'}
+
+
+
Slot in epoch
+
+ {#if chain.tip?.epochSlot != null && span}{num(chain.tip.epochSlot)} / {num(span)}{:else}—{/if} +
+
+
+
Ends
+
{e ? dateTime(e.end) : '—'}
+
+
+
+
diff --git a/applications/viewer/src/lib/dashboard/SupplyCard.svelte b/applications/viewer/src/lib/dashboard/SupplyCard.svelte new file mode 100644 index 00000000..332b05c5 --- /dev/null +++ b/applications/viewer/src/lib/dashboard/SupplyCard.svelte @@ -0,0 +1,43 @@ + + +{#if parts.length} +
+ {#each parts as p (p.label)} +
+ {/each} +
+
+ {#each parts as p (p.label)} +
+
{p.label}
+
₳{adaCompact(p.value)}
+
{percent(p.value, max)} of max
+
+ {/each} +
+
Active stake
+
₳{adaCompact(active)}
+
{percent(active, Number(s?.circulating) || 1)} of circulating
+
+
+{:else} +

Supply figures appear after the first epoch boundary.

+{/if} diff --git a/applications/viewer/src/lib/dashboard/TxFeed.svelte b/applications/viewer/src/lib/dashboard/TxFeed.svelte new file mode 100644 index 00000000..9a464ef1 --- /dev/null +++ b/applications/viewer/src/lib/dashboard/TxFeed.svelte @@ -0,0 +1,42 @@ + + +{#if txs.length === 0} + +{:else} + +{/if} diff --git a/applications/viewer/src/lib/format.test.ts b/applications/viewer/src/lib/format.test.ts new file mode 100644 index 00000000..78b46d88 --- /dev/null +++ b/applications/viewer/src/lib/format.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { ada, adaCompact, compact, duration, hexToText, parseUnit, shorten, timeAgo } from './format'; + +describe('format', () => { + it('formats lovelace past the safe integer range exactly', () => { + expect(ada('45000000000000000')).toBe('45,000,000,000.00'); + expect(ada('9000683336928985', 6)).toBe('9,000,683,336.928985'); + expect(ada(1_500_000, 'auto')).toBe('1.50'); + expect(ada(-2_000_000)).toBe('−2.00'); + expect(ada(null)).toBe('0.00'); + }); + + it('compacts numbers', () => { + expect(compact(1234)).toBe('1.2K'); + expect(compact(45_000_000_000)).toBe('45B'); + expect(adaCompact('288009970500')).toBe('288K'); + }); + + it('describes time', () => { + const now = 1_000_000_000_000; + expect(timeAgo(now / 1000 - 30, now)).toBe('30s ago'); + expect(timeAgo(now / 1000 - 3 * 3600, now)).toBe('3h ago'); + expect(duration(192)).toBe('3m 12s'); + expect(duration(90061)).toBe('1d 1h'); + }); + + it('shortens and decodes', () => { + expect(shorten('abcdefghijklmnopqrstuvwxyz', 4, 3)).toBe('abcd…xyz'); + expect(hexToText('4d494e54')).toBe('MINT'); + expect(hexToText('00ff')).toBeNull(); + expect(parseUnit('lovelace').name).toBe('ADA'); + expect(parseUnit('c'.repeat(56) + '4d494e54')).toMatchObject({ policy: 'c'.repeat(56), name: 'MINT' }); + }); +}); diff --git a/applications/viewer/src/lib/format.ts b/applications/viewer/src/lib/format.ts new file mode 100644 index 00000000..728304f7 --- /dev/null +++ b/applications/viewer/src/lib/format.ts @@ -0,0 +1,150 @@ +/* + * Formatting helpers. Lovelace amounts go through BigInt: Mainnet supply (4.5e16) is past Number's safe range. + */ + +export type Amountish = number | string | bigint | null | undefined; + +const LOVELACE = 1_000_000n; + +function toBig(v: Amountish): bigint { + if (v === null || v === undefined || v === '') return 0n; + if (typeof v === 'bigint') return v; + if (typeof v === 'number') return BigInt(Math.trunc(v)); + try { + return BigInt(v.split('.')[0]); + } catch { + return 0n; + } +} + +const group = (s: string) => s.replace(/\B(?=(\d{3})+(?!\d))/g, ','); + +/** Lovelace → ADA split into whole and fractional parts, e.g. { whole: "1,234", frac: "567891" }. */ +export function adaParts(lovelace: Amountish): { neg: boolean; whole: string; frac: string } { + let v = toBig(lovelace); + const neg = v < 0n; + if (neg) v = -v; + return { neg, whole: group((v / LOVELACE).toString()), frac: (v % LOVELACE).toString().padStart(6, '0') }; +} + +/** Lovelace → "1,234.56" (decimals: fixed places; 'auto' trims trailing zeros, keeping at least 2). */ +export function ada(lovelace: Amountish, decimals: number | 'auto' = 2): string { + const { neg, whole, frac } = adaParts(lovelace); + let f: string; + if (decimals === 'auto') { + f = frac.replace(/0+$/, ''); + if (f.length < 2) f = f.padEnd(2, '0'); + } else { + f = frac.slice(0, decimals); + } + return (neg ? '−' : '') + whole + (f ? '.' + f : ''); +} + +/** Lovelace → compact ADA, e.g. 45B, 1.2M, 950.4K. */ +export function adaCompact(lovelace: Amountish): string { + const v = toBig(lovelace); + const whole = Number(v / LOVELACE) + Number(v % LOVELACE) / 1e6; + return compact(whole); +} + +export function compact(n: number | string | null | undefined, digits = 1): string { + const v = Number(n ?? 0); + if (!Number.isFinite(v)) return '—'; + const abs = Math.abs(v); + const units: [number, string][] = [ + [1e12, 'T'], + [1e9, 'B'], + [1e6, 'M'], + [1e3, 'K'] + ]; + for (const [size, unit] of units) { + if (abs >= size) return (v / size).toFixed(digits).replace(/\.0+$/, '') + unit; + } + return v % 1 === 0 ? String(v) : v.toFixed(2); +} + +/** Integer-ish values with thousands separators. Accepts big strings. */ +export function num(n: number | string | bigint | null | undefined): string { + if (n === null || n === undefined || n === '') return '—'; + if (typeof n === 'number' && !Number.isInteger(n)) return n.toLocaleString('en-US', { maximumFractionDigits: 2 }); + return group(String(n)); +} + +/** Seconds (or milliseconds, detected) since epoch → "12s ago", "3m ago", "2h ago", "4d ago". */ +export function timeAgo(ts: number | null | undefined, now = Date.now()): string { + if (!ts) return '—'; + const ms = ts > 1e12 ? ts : ts * 1000; + const s = Math.round((now - ms) / 1000); + if (s < 0) return 'just now'; + if (s < 5) return 'just now'; + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d = Math.floor(h / 24); + if (d < 30) return `${d}d ago`; + const mo = Math.floor(d / 30); + if (mo < 12) return `${mo}mo ago`; + return `${Math.floor(mo / 12)}y ago`; +} + +/** Seconds → "3m 12s", "2h 05m", "4d 3h". */ +export function duration(totalSeconds: number): string { + const s = Math.max(0, Math.round(totalSeconds)); + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (d > 0) return `${d}d ${h}h`; + if (h > 0) return `${h}h ${String(m).padStart(2, '0')}m`; + if (m > 0) return `${m}m ${String(sec).padStart(2, '0')}s`; + return `${sec}s`; +} + +/** Seconds since epoch → "Oct 17, 2026, 14:03:22". */ +export function dateTime(ts: number | null | undefined): string { + if (!ts) return '—'; + const ms = ts > 1e12 ? ts : ts * 1000; + return new Date(ms).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); +} + +/** Middle-truncate: "addr_test1qz…k8ujs". */ +export function shorten(s: string | null | undefined, head = 8, tail = 6): string { + if (!s) return ''; + if (s.length <= head + tail + 1) return s; + return `${s.slice(0, head)}…${s.slice(-tail)}`; +} + +export function percent(part: number, whole: number, digits = 1): string { + if (!whole) return '0%'; + return `${((part / whole) * 100).toFixed(digits)}%`; +} + +/** Hex asset name → readable text when it is printable UTF-8, otherwise null. */ +export function hexToText(hex: string | null | undefined): string | null { + if (!hex || hex.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(hex)) return null; + try { + const bytes = new Uint8Array(hex.match(/../g)!.map((b) => parseInt(b, 16))); + const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + return /^[\p{L}\p{N}\p{P}\p{S} ]+$/u.test(text) ? text : null; + } catch { + return null; + } +} + +/** Asset unit (policy + hex name) → { policy, nameHex, name }. "lovelace" → ADA. */ +export function parseUnit(unit: string): { policy: string; nameHex: string; name: string } { + if (!unit || unit === 'lovelace') return { policy: '', nameHex: '', name: 'ADA' }; + const policy = unit.slice(0, 56); + const nameHex = unit.slice(56); + return { policy, nameHex, name: hexToText(nameHex) ?? (nameHex ? shorten(nameHex, 6, 4) : shorten(policy, 6, 4)) }; +} diff --git a/applications/viewer/src/lib/live/chain.svelte.ts b/applications/viewer/src/lib/live/chain.svelte.ts new file mode 100644 index 00000000..5c7678d7 --- /dev/null +++ b/applications/viewer/src/lib/live/chain.svelte.ts @@ -0,0 +1,300 @@ +import { api, tryApi, wsUrl } from '$lib/api/client'; + +/* + * Live chain state shared by the whole app: tip, recent blocks and transactions, network and epoch. + * Uses Yaci Store's WebSocket when there is one, reconnecting with backoff, and falls back to polling + * /blocks/latest when there isn't (for example a public Yaci Store without the live module). + */ + +export interface LiveBlock { + number: number; + hash?: string; + slot: number; + epoch: number; + epochSlot?: number; + time: number; // seconds + txCount: number; + size: number; + leader?: string; + fees?: number; + receivedAt: number; // ms, when the viewer first saw it +} + +export interface LiveTx { + hash: string; + block: number; + slot: number; + output: number; + fee?: number; + outputAddresses: string[]; + receivedAt: number; +} + +export interface NetworkInfo { + magic: number | null; + name: string; + kind: 'mainnet' | 'preprod' | 'preview' | 'sanchonet' | 'devnet' | 'custom'; + era?: string; + synced?: boolean; + syncPercent?: number; + lagBlocks?: number; +} + +export interface EpochInfo { + epoch: number; + start: number; // seconds + end: number; // seconds +} + +export type LiveStatus = 'idle' | 'connecting' | 'live' | 'polling' | 'offline'; + +const MAX_BLOCKS = 40; +const MAX_TXS = 40; +const POLL_MS = 4000; + +export function networkFromMagic(magic: number | null | undefined): Pick { + switch (magic) { + case 764824073: + return { name: 'Mainnet', kind: 'mainnet' }; + case 1: + return { name: 'Preprod', kind: 'preprod' }; + case 2: + return { name: 'Preview', kind: 'preview' }; + case 4: + return { name: 'SanchoNet', kind: 'sanchonet' }; + case 42: + return { name: 'Devnet', kind: 'devnet' }; + default: + return { name: magic == null ? 'Unknown network' : `Network ${magic}`, kind: 'custom' }; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export function toBlock(b: Any, receivedAt = Date.now()): LiveBlock { + const time = b.block_time ?? (b.time > 1e12 ? Math.round(b.time / 1000) : b.time); + return { + number: Number(b.number ?? b.height), + hash: b.hash ?? undefined, + slot: Number(b.slot), + epoch: Number(b.epoch), + epochSlot: b.epoch_slot ?? undefined, + time: Number(time), + txCount: Number(b.ntx ?? b.tx_count ?? 0), + size: Number(b.size ?? 0), + leader: b.slot_leader ?? undefined, + fees: b.fees != null ? Number(b.fees) : b.fee != null ? Number(b.fee) : undefined, + receivedAt + }; +} + +export function toTx(t: Any, receivedAt = Date.now()): LiveTx { + return { + hash: t.hash ?? t.tx_hash, + block: Number(t.block ?? t.block_number ?? t.block_height), + slot: Number(t.slot), + output: Number(t.output ?? t.total_output ?? 0), + fee: t.fee != null ? Number(t.fee) : undefined, + outputAddresses: t.output_addresses ?? [], + receivedAt + }; +} + +class Chain { + status = $state('idle'); + blocks = $state([]); + txs = $state([]); + network = $state(null); + epoch = $state(null); + /** Ticks every second so relative times ("12s ago") stay current. */ + now = $state(Date.now()); + /** Set when the store can't be reached at all. */ + error = $state(null); + + tip = $derived(this.blocks[0] ?? null); + + /** Average seconds between the recent blocks. */ + blockTime = $derived.by(() => { + const b = this.blocks.slice(0, 20); + if (b.length < 2) return null; + const span = b[0].time - b[b.length - 1].time; + return span > 0 ? span / (b.length - 1) : null; + }); + + private started = false; + private ws: WebSocket | null = null; + private wsOpened = false; + private wsAttempts = 0; + private pollTimer: ReturnType | null = null; + private retryTimer: ReturnType | null = null; + private pingTimer: ReturnType | null = null; + + start() { + if (this.started || typeof window === 'undefined') return; + this.started = true; + this.status = 'connecting'; + setInterval(() => (this.now = Date.now()), 1000); + setInterval(() => this.refreshNetwork(), 30000); + document.addEventListener('visibilitychange', () => { + if (!document.hidden && this.status === 'polling') this.poll(); + }); + this.refreshNetwork(); + this.seed().finally(() => this.connect()); + } + + /** Initial recent blocks and txs over REST, so the UI isn't empty while the socket connects. */ + private async seed() { + try { + const [blocks, txs] = await Promise.all([ + api('/blocks', { params: { page: 1, count: 16 } }), + tryApi('/txs', { params: { page: 1, count: 16 } }) + ]); + const seeded = (blocks?.blocks ?? []).map((b: Any) => toBlock(b, 0)); + this.mergeBlocks(seeded); + this.mergeTxs((txs?.transaction_summaries ?? []).map((t: Any) => toTx(t, 0))); + this.error = null; + } catch (e) { + this.error = (e as Error).message; + } + } + + async refreshNetwork() { + const [sync, epoch] = await Promise.all([tryApi('/sync/status'), tryApi('/epochs/latest')]); + if (sync) { + const magic = sync.protocol_magic ?? null; + this.network = { + magic, + ...networkFromMagic(magic), + era: sync.era, + synced: sync.synced, + syncPercent: sync.sync_percentage, + lagBlocks: sync.lag_blocks + }; + } else if (!this.network) { + const genesis = await tryApi('/genesis'); + const magic = genesis?.network_magic ?? null; + this.network = { magic, ...networkFromMagic(magic) }; + } + if (epoch?.start_time) this.epoch = { epoch: epoch.epoch, start: epoch.start_time, end: epoch.end_time }; + } + + private connect() { + const url = wsUrl(); + if (!url) return this.startPolling(); + try { + this.ws = new WebSocket(url); + } catch { + return this.startPolling(); + } + this.wsAttempts++; + this.ws.onopen = () => { + this.wsOpened = true; + this.wsAttempts = 0; + this.status = 'live'; + this.error = null; + this.stopPolling(); + this.pingTimer = setInterval(() => this.ws?.readyState === 1 && this.ws.send('PING'), 30000); + }; + this.ws.onmessage = (e) => this.onMessage(e.data); + this.ws.onclose = () => { + if (this.pingTimer) clearInterval(this.pingTimer); + this.ws = null; + // No socket on this store (never opened after two tries): poll, and look for a socket now and then. + if (!this.wsOpened && this.wsAttempts >= 2) { + this.startPolling(); + this.retryTimer = setTimeout(() => this.connect(), 60000); + return; + } + this.wsOpened = false; + this.status = this.status === 'polling' ? 'polling' : 'connecting'; + const delay = Math.min(15000, 1000 * 2 ** Math.min(this.wsAttempts, 4)); + this.retryTimer = setTimeout(() => this.connect(), delay); + }; + } + + private onMessage(raw: string) { + let d: Any; + try { + d = JSON.parse(raw); + } catch { + return; + } + switch (d.res_type) { + case 'INIT_DATA': + this.mergeBlocks((d.blocks ?? []).map((b: Any) => toBlock(b, 0))); + this.mergeTxs((d.recent_txs ?? []).map((t: Any) => toTx(t, 0))); + break; + case 'BLOCK_DATA': + this.addBlock(toBlock(d)); + break; + case 'RECENT_TXS_DATA': + this.mergeTxs((d.recent_txs ?? []).map((t: Any) => toTx(t))); + break; + } + } + + private addBlock(b: LiveBlock) { + const prevEpoch = this.tip?.epoch; + this.mergeBlocks([b]); + if (prevEpoch != null && b.epoch !== prevEpoch) this.refreshNetwork(); + } + + private mergeBlocks(list: LiveBlock[]) { + if (!list.length) return; + const byNumber = new Map(); + for (const b of [...this.blocks, ...list]) { + const prev = byNumber.get(b.number); + // Keep the first-seen time, but fill in fields (like hash) a later message has. + byNumber.set(b.number, prev ? { ...prev, ...b, hash: b.hash ?? prev.hash, receivedAt: prev.receivedAt || b.receivedAt } : b); + } + this.blocks = [...byNumber.values()].sort((a, b) => b.number - a.number).slice(0, MAX_BLOCKS); + } + + private mergeTxs(list: LiveTx[]) { + if (!list.length) return; + const seen = new Map(); + for (const t of [...this.txs, ...list]) if (t.hash && !seen.has(t.hash)) seen.set(t.hash, t); + this.txs = [...seen.values()].sort((a, b) => b.slot - a.slot).slice(0, MAX_TXS); + } + + private startPolling() { + if (this.status !== 'polling') this.status = 'polling'; + this.poll(); + } + + private stopPolling() { + if (this.pollTimer) clearTimeout(this.pollTimer); + this.pollTimer = null; + } + + private async poll() { + this.stopPolling(); + if (this.status !== 'polling') return; + if (!document.hidden) { + try { + const latest = await api('/blocks/latest', { timeoutMs: 8000 }); + const block = toBlock(latest); + const tip = this.tip?.number ?? 0; + if (block.number > tip) { + // Catch up a short gap so the conveyor doesn't skip blocks. + if (tip && block.number - tip > 1 && block.number - tip < 16) { + const gap = await tryApi('/blocks', { params: { page: 1, count: block.number - tip } }); + this.mergeBlocks((gap?.blocks ?? []).filter((b: Any) => b.number > tip).map((b: Any) => toBlock(b))); + } + this.addBlock(block); + if (block.txCount > 0) { + const txs = await tryApi(`/blocks/${block.number}/txs`); + this.mergeTxs((txs ?? []).map((t) => toTx(t))); + } + } + this.error = null; + } catch (e) { + this.error = (e as Error).message; + } + } + this.pollTimer = setTimeout(() => this.poll(), POLL_MS); + } +} + +export const chain = new Chain(); diff --git a/applications/viewer/src/lib/search/classify.test.ts b/applications/viewer/src/lib/search/classify.test.ts new file mode 100644 index 00000000..778cd58a --- /dev/null +++ b/applications/viewer/src/lib/search/classify.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { classify } from './classify'; + +const kinds = (q: string) => classify(q).map((c) => c.kind); + +describe('classify', () => { + it('recognises bech32 entities with certainty', () => { + expect(classify('addr_test1vzs0r2nae22szlq3ul3h8t4u7rz9dr850mqjh98caddm4zcypzjjt')[0]).toMatchObject({ + kind: 'address', + certain: true + }); + expect(kinds('stake_test1uqfu74w3wh4gfzu8m6e7j987h4lq9r3t7ef5gaw497uu85qsqfy27')).toEqual(['stake']); + expect(kinds('pool1pu5jlj4q9w9jlxeu370a3c9myx47md5j5m2str0naunn2q3lkdy')).toEqual(['pool']); + expect(kinds('drep1ygr9tuapcanc3kpeyy4dc3vmrz9cfe5q7v9wj3x9j0ap3tswtre9j')).toEqual(['drep']); + expect(kinds('asset1rjklcrnsdzqp65wjgrg55sy9723kw09mlgvlc3')).toEqual(['fingerprint']); + }); + + it('offers block and epoch for a number', () => { + expect(kinds('1234')).toEqual(['block', 'epoch']); + expect(classify('epoch 12')[0]).toMatchObject({ kind: 'epoch', href: '/epochs/12' }); + }); + + it('treats 64-hex as tx, block, datum or asset, and 56-hex as policy, script, pool or DRep', () => { + expect(kinds('a'.repeat(64))).toEqual(['tx', 'block', 'datum', 'asset']); + expect(kinds('b'.repeat(56))).toEqual(['policy', 'script', 'pool', 'drep']); + expect(kinds('c'.repeat(56) + '59616369546f6b656e')).toEqual(['asset']); + }); + + it('reads # as a UTxO reference or a governance action', () => { + const h = 'd'.repeat(64); + expect(classify(`${h}#0`).map((c) => c.href)).toEqual([`/transactions/${h}`, `/governance/govactions/${h}_0`]); + }); + + it('ignores junk', () => { + expect(classify(' ')).toEqual([]); + expect(classify('hello world')).toEqual([]); + }); +}); diff --git a/applications/viewer/src/lib/search/classify.ts b/applications/viewer/src/lib/search/classify.ts new file mode 100644 index 00000000..780ac139 --- /dev/null +++ b/applications/viewer/src/lib/search/classify.ts @@ -0,0 +1,122 @@ +/* + * Turn whatever someone pastes into the search box into places to go. Some inputs are certain from their shape + * (bech32 prefixes); hex hashes are ambiguous, so they come back as several candidates that `resolve` checks. + */ + +export type EntityKind = + | 'block' + | 'tx' + | 'address' + | 'stake' + | 'pool' + | 'drep' + | 'asset' + | 'policy' + | 'fingerprint' + | 'script' + | 'datum' + | 'epoch' + | 'govaction'; + +export interface Candidate { + kind: EntityKind; + value: string; + href: string; + /** API path to confirm the entity exists, for ambiguous inputs. */ + check?: string; + /** True when the shape alone is proof enough. */ + certain?: boolean; +} + +export const KIND_LABEL: Record = { + block: 'Block', + tx: 'Transaction', + address: 'Address', + stake: 'Stake account', + pool: 'Stake pool', + drep: 'DRep', + asset: 'Asset', + policy: 'Policy', + fingerprint: 'Asset fingerprint', + script: 'Script', + datum: 'Datum', + epoch: 'Epoch', + govaction: 'Governance action' +}; + +const HEX = /^[0-9a-f]+$/i; + +export function classify(raw: string): Candidate[] { + const q = raw.trim().replace(/^["']|["']$/g, ''); + if (!q) return []; + const lower = q.toLowerCase(); + + // "epoch 512" / "e512" + const epochMatch = lower.match(/^(?:epoch\s*|e)(\d{1,7})$/); + if (epochMatch) return [{ kind: 'epoch', value: epochMatch[1], href: `/epochs/${epochMatch[1]}`, certain: true }]; + + if (/^\d+$/.test(q)) { + return [ + { kind: 'block', value: q, href: `/blocks/${q}`, check: `/blocks/${q}` }, + { kind: 'epoch', value: q, href: `/epochs/${q}`, check: `/epochs/${q}` } + ]; + } + + if (/^(addr1|addr_test1)[0-9a-z]+$/.test(lower) || /^(Ae2|DdzFF)[1-9A-HJ-NP-Za-km-z]+$/.test(q)) { + return [{ kind: 'address', value: q, href: `/addresses/${q}`, certain: true }]; + } + if (/^(stake1|stake_test1)[0-9a-z]+$/.test(lower)) { + return [{ kind: 'stake', value: q, href: `/stake/${q}`, certain: true }]; + } + if (/^pool1[0-9a-z]+$/.test(lower)) return [{ kind: 'pool', value: q, href: `/pools/${q}`, certain: true }]; + if (/^drep1[0-9a-z]+$/.test(lower) || /^drep_script1[0-9a-z]+$/.test(lower)) { + return [{ kind: 'drep', value: q, href: `/governance/dreps/${q}`, certain: true }]; + } + if (/^asset1[0-9a-z]+$/.test(lower)) { + return [{ kind: 'fingerprint', value: q, href: `/assets/fingerprint/${q}`, certain: true }]; + } + + // #: a UTxO reference (go to the tx) or a governance action id. + const ref = q.match(/^([0-9a-f]{64})[#_](\d+)$/i); + if (ref) { + const [, h, i] = ref; + return [ + { kind: 'tx', value: h.toLowerCase(), href: `/transactions/${h.toLowerCase()}`, check: `/txs/${h.toLowerCase()}` }, + { + kind: 'govaction', + value: q, + href: `/governance/govactions/${h.toLowerCase()}_${i}`, + check: `/governance/proposals/${h.toLowerCase()}` + } + ]; + } + + if (HEX.test(q)) { + const h = lower; + if (h.length === 64) { + return [ + { kind: 'tx', value: h, href: `/transactions/${h}`, check: `/txs/${h}` }, + { kind: 'block', value: h, href: `/blocks/${h}`, check: `/blocks/${h}` }, + { kind: 'datum', value: h, href: `/datums/${h}`, check: `/scripts/datum/${h}` }, + // A policy id plus a 4-byte asset name is also 64 hex characters. + { kind: 'asset', value: h, href: `/assets/unit/${h}`, check: `/assets/${h}/supply` } + ]; + } + if (h.length === 56) { + return [ + { kind: 'policy', value: h, href: `/assets/policy/${h}`, check: `/assets/policy/${h}/supply` }, + { kind: 'script', value: h, href: `/scripts/${h}`, check: `/scripts/${h}` }, + { kind: 'pool', value: h, href: `/pools/${h}` }, + { kind: 'drep', value: h, href: `/governance/dreps/${h}`, check: `/governance-state/dreps/${h}` } + ]; + } + if (h.length > 56 && h.length <= 120) { + return [{ kind: 'asset', value: h, href: `/assets/unit/${h}`, check: `/assets/${h}/supply` }]; + } + } + return []; +} + +/** Hint shown under the box while typing. */ +export const SEARCH_HINT = + 'Block number or hash, transaction, address, stake address, pool, DRep, policy, asset or fingerprint, script, datum, or "epoch 12"'; diff --git a/applications/viewer/src/lib/shell/CommandPalette.svelte b/applications/viewer/src/lib/shell/CommandPalette.svelte new file mode 100644 index 00000000..735a4028 --- /dev/null +++ b/applications/viewer/src/lib/shell/CommandPalette.svelte @@ -0,0 +1,209 @@ + + + + +{#if shell.paletteOpen} + +{/if} diff --git a/applications/viewer/src/lib/shell/GithubIcon.svelte b/applications/viewer/src/lib/shell/GithubIcon.svelte new file mode 100644 index 00000000..2c70f873 --- /dev/null +++ b/applications/viewer/src/lib/shell/GithubIcon.svelte @@ -0,0 +1,9 @@ + + + diff --git a/applications/viewer/src/lib/shell/KindIcon.svelte b/applications/viewer/src/lib/shell/KindIcon.svelte new file mode 100644 index 00000000..20996f0c --- /dev/null +++ b/applications/viewer/src/lib/shell/KindIcon.svelte @@ -0,0 +1,37 @@ + + + diff --git a/applications/viewer/src/lib/shell/LiveBadge.svelte b/applications/viewer/src/lib/shell/LiveBadge.svelte new file mode 100644 index 00000000..ad5b2b62 --- /dev/null +++ b/applications/viewer/src/lib/shell/LiveBadge.svelte @@ -0,0 +1,87 @@ + + + e.key === 'Escape' && (open = false)} /> + +
+ + + {#if open} +
+
+ {chain.network?.name ?? 'Network'} + {tone.label} +
+
+
Tip
+
{chain.tip ? '#' + num(chain.tip.number) : '—'}
+
Last block
+
{#if chain.tip}{:else}—{/if}
+
Epoch
+
{chain.tip?.epoch ?? chain.epoch?.epoch ?? '—'}
+
Era
+
{chain.network?.era ?? '—'}
+
Store sync
+
+ {#if chain.network?.syncPercent != null}{chain.network.syncPercent.toFixed(chain.network.syncPercent === 100 ? 0 : 2)}%{:else}—{/if} +
+
Protocol magic
+
{chain.network?.magic ?? '—'}
+
Updates
+
{chain.status === 'live' ? 'WebSocket' : chain.status === 'polling' ? 'Polling every 4s' : '—'}
+
+
{apiBase()}
+ {#if chain.error}

{chain.error}

{/if} +
+ {/if} +
diff --git a/applications/viewer/src/lib/shell/Logo.svelte b/applications/viewer/src/lib/shell/Logo.svelte new file mode 100644 index 00000000..f24cc896 --- /dev/null +++ b/applications/viewer/src/lib/shell/Logo.svelte @@ -0,0 +1,15 @@ + + + + + {#if !compact} + YaciViewer + {/if} + diff --git a/applications/viewer/src/lib/shell/NavProgress.svelte b/applications/viewer/src/lib/shell/NavProgress.svelte new file mode 100644 index 00000000..6cececd5 --- /dev/null +++ b/applications/viewer/src/lib/shell/NavProgress.svelte @@ -0,0 +1,54 @@ + + +{#if visible} + +{/if} + + diff --git a/applications/viewer/src/lib/shell/Sidebar.svelte b/applications/viewer/src/lib/shell/Sidebar.svelte new file mode 100644 index 00000000..730b062b --- /dev/null +++ b/applications/viewer/src/lib/shell/Sidebar.svelte @@ -0,0 +1,96 @@ + + +{#snippet links(compact: boolean)} + +{/snippet} + +{#snippet footer(compact: boolean)} + +{/snippet} + + + + + +{#if shell.drawerOpen} +
+ + +
+{/if} diff --git a/applications/viewer/src/lib/shell/ThemeToggle.svelte b/applications/viewer/src/lib/shell/ThemeToggle.svelte new file mode 100644 index 00000000..d76c7143 --- /dev/null +++ b/applications/viewer/src/lib/shell/ThemeToggle.svelte @@ -0,0 +1,9 @@ + + + diff --git a/applications/viewer/src/lib/shell/Topbar.svelte b/applications/viewer/src/lib/shell/Topbar.svelte new file mode 100644 index 00000000..a8cf0656 --- /dev/null +++ b/applications/viewer/src/lib/shell/Topbar.svelte @@ -0,0 +1,34 @@ + + +
+
+ +
+ + + +
+ + +
+
+
diff --git a/applications/viewer/src/lib/shell/nav.ts b/applications/viewer/src/lib/shell/nav.ts new file mode 100644 index 00000000..138b5439 --- /dev/null +++ b/applications/viewer/src/lib/shell/nav.ts @@ -0,0 +1,89 @@ +import type { Component } from 'svelte'; +import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard'; +import Blocks from '@lucide/svelte/icons/blocks'; +import ArrowLeftRight from '@lucide/svelte/icons/arrow-left-right'; +import Wallet from '@lucide/svelte/icons/wallet'; +import Coins from '@lucide/svelte/icons/coins'; +import Server from '@lucide/svelte/icons/server'; +import Gift from '@lucide/svelte/icons/gift'; +import Layers from '@lucide/svelte/icons/layers'; +import ArrowDownLeft from '@lucide/svelte/icons/arrow-down-left'; +import PiggyBank from '@lucide/svelte/icons/piggy-bank'; +import BadgeCheck from '@lucide/svelte/icons/badge-check'; +import Landmark from '@lucide/svelte/icons/landmark'; +import ScrollText from '@lucide/svelte/icons/scroll-text'; +import Users from '@lucide/svelte/icons/users'; +import Vote from '@lucide/svelte/icons/vote'; +import Scale from '@lucide/svelte/icons/scale'; +import Gavel from '@lucide/svelte/icons/gavel'; +import Link2 from '@lucide/svelte/icons/link-2'; + +export interface NavItem { + label: string; + href: string; + icon: Component; + /** Other paths that should highlight this item. */ + match?: string[]; + keywords?: string; +} + +export interface NavGroup { + label: string; + items: NavItem[]; +} + +export const NAV: NavGroup[] = [ + { + label: 'Explore', + items: [ + { label: 'Dashboard', href: '/', icon: LayoutDashboard, keywords: 'home live overview' }, + { label: 'Blocks', href: '/blocks', icon: Blocks }, + { label: 'Transactions', href: '/transactions', icon: ArrowLeftRight, keywords: 'txs' }, + { label: 'Addresses', href: '/addresses', icon: Wallet, match: ['/stake'], keywords: 'wallet balance utxo' }, + { label: 'Assets', href: '/assets', icon: Coins, keywords: 'tokens nft policy mint' } + ] + }, + { + label: 'Staking', + items: [ + { label: 'Pools', href: '/pools/search', icon: Server, match: ['/pools'], keywords: 'stake pool spo' }, + { label: 'Delegations', href: '/certificates/delegations', icon: Link2 }, + { label: 'Rewards', href: '/rewards', icon: Gift }, + { label: 'Epoch stakes', href: '/epoch-stakes', icon: Layers }, + { label: 'Withdrawals', href: '/withdrawals', icon: ArrowDownLeft }, + { label: 'Ada pots', href: '/adapot/current', icon: PiggyBank, match: ['/adapot'], keywords: 'treasury reserves supply' }, + { + label: 'Certificates', + href: '/certificates/stakekey-registrations', + icon: BadgeCheck, + match: ['/certificates/stakekey', '/certificates/pool'], + keywords: 'registrations deregistrations retirements' + } + ] + }, + { + label: 'Governance', + items: [ + { label: 'Overview', href: '/governance', icon: Landmark, keywords: 'conway' }, + { label: 'Proposals', href: '/governance/proposals', icon: ScrollText, match: ['/governance/govactions'], keywords: 'gov actions' }, + { label: 'DReps', href: '/governance/dreps', icon: Users, match: ['/governance/drep-'], keywords: 'delegated representatives' }, + { label: 'Votes', href: '/governance/votes', icon: Vote }, + { label: 'Committee', href: '/governance/committee', icon: Gavel, keywords: 'cc constitutional' }, + { label: 'Constitution', href: '/governance/constitution', icon: Scale } + ] + } +]; + +export const ALL_PAGES: NavItem[] = NAV.flatMap((g) => g.items); + +/** The nav item for a path: the one whose href or match prefix is the longest match. */ +export function activeHref(path: string): string | null { + let best: { href: string; len: number } | null = null; + for (const item of ALL_PAGES) { + for (const root of [item.href, ...(item.match ?? [])]) { + const hit = root === '/' ? path === '/' : path.startsWith(root); + if (hit && (!best || root.length > best.len)) best = { href: item.href, len: root.length }; + } + } + return best?.href ?? null; +} diff --git a/applications/viewer/src/lib/shell/ui.svelte.ts b/applications/viewer/src/lib/shell/ui.svelte.ts new file mode 100644 index 00000000..14d3a52d --- /dev/null +++ b/applications/viewer/src/lib/shell/ui.svelte.ts @@ -0,0 +1,31 @@ +/* Shell state: the mobile drawer, the collapsed sidebar and the search palette. */ +class ShellUi { + drawerOpen = $state(false); + collapsed = $state(false); + paletteOpen = $state(false); + paletteQuery = $state(''); + + openPalette(query = '') { + this.paletteQuery = query; + this.paletteOpen = true; + } + + toggleCollapsed() { + this.collapsed = !this.collapsed; + try { + localStorage.setItem('viewer:sidebar', this.collapsed ? 'collapsed' : 'open'); + } catch { + /* storage unavailable */ + } + } + + restore() { + try { + this.collapsed = localStorage.getItem('viewer:sidebar') === 'collapsed'; + } catch { + /* storage unavailable */ + } + } +} + +export const shell = new ShellUi(); diff --git a/applications/viewer/src/lib/ui/Ada.svelte b/applications/viewer/src/lib/ui/Ada.svelte new file mode 100644 index 00000000..099d4974 --- /dev/null +++ b/applications/viewer/src/lib/ui/Ada.svelte @@ -0,0 +1,20 @@ + + +{#if symbol}₳{/if}{p.neg ? '−' : ''}{p.whole}{#if frac}.{frac}{/if} diff --git a/applications/viewer/src/lib/ui/AssetChip.svelte b/applications/viewer/src/lib/ui/AssetChip.svelte new file mode 100644 index 00000000..fe29ed9d --- /dev/null +++ b/applications/viewer/src/lib/ui/AssetChip.svelte @@ -0,0 +1,22 @@ + + + + {info.name.slice(0, 1).toUpperCase()} + {info.name} + {#if quantity != null}×{num(quantity)}{/if} + diff --git a/applications/viewer/src/lib/ui/Badge.svelte b/applications/viewer/src/lib/ui/Badge.svelte new file mode 100644 index 00000000..24ed527f --- /dev/null +++ b/applications/viewer/src/lib/ui/Badge.svelte @@ -0,0 +1,20 @@ + + +{@render children()} diff --git a/applications/viewer/src/lib/ui/CopyButton.svelte b/applications/viewer/src/lib/ui/CopyButton.svelte new file mode 100644 index 00000000..b2008dd1 --- /dev/null +++ b/applications/viewer/src/lib/ui/CopyButton.svelte @@ -0,0 +1,31 @@ + + + diff --git a/applications/viewer/src/lib/ui/DataTable.svelte b/applications/viewer/src/lib/ui/DataTable.svelte new file mode 100644 index 00000000..1266e055 --- /dev/null +++ b/applications/viewer/src/lib/ui/DataTable.svelte @@ -0,0 +1,85 @@ + + + + +{#if rows.length === 0} + +{:else} +
+ + + + {#each columns as c (c.key)} + + {/each} + + + + {#each rows as row, i (key ? key(row) : i)} + onRowClick(e, row)} + > + {#each columns as c (c.key)} + + {/each} + + {/each} + +
{c.label}
+ {@render cell(row, c.key)} +
+
+{/if} diff --git a/applications/viewer/src/lib/ui/Empty.svelte b/applications/viewer/src/lib/ui/Empty.svelte new file mode 100644 index 00000000..85d60092 --- /dev/null +++ b/applications/viewer/src/lib/ui/Empty.svelte @@ -0,0 +1,21 @@ + + +
+
+ +
+
+

{title}

+ {#if text}

{text}

{/if} +
+ {#if children}{@render children()}{/if} +
diff --git a/applications/viewer/src/lib/ui/ErrorBox.svelte b/applications/viewer/src/lib/ui/ErrorBox.svelte new file mode 100644 index 00000000..42bd875e --- /dev/null +++ b/applications/viewer/src/lib/ui/ErrorBox.svelte @@ -0,0 +1,24 @@ + + +
+
+ +
+
+

{title}

+ {#if message}

{message}

{/if} +
+ {#if retry} + + {/if} +
diff --git a/applications/viewer/src/lib/ui/Hash.svelte b/applications/viewer/src/lib/ui/Hash.svelte new file mode 100644 index 00000000..ac1c0b1d --- /dev/null +++ b/applications/viewer/src/lib/ui/Hash.svelte @@ -0,0 +1,41 @@ + + +{#if value} + + {#if href} + {text} + {:else} + {text} + {/if} + {#if copy}{/if} + +{:else} + — +{/if} diff --git a/applications/viewer/src/lib/ui/JsonView.svelte b/applications/viewer/src/lib/ui/JsonView.svelte new file mode 100644 index 00000000..36630b1a --- /dev/null +++ b/applications/viewer/src/lib/ui/JsonView.svelte @@ -0,0 +1,51 @@ + + +
+
+ {label ?? 'JSON'} +
+ {#if lines > 18} + + {/if} + +
+
+
{@html html}
+
diff --git a/applications/viewer/src/lib/ui/KV.svelte b/applications/viewer/src/lib/ui/KV.svelte new file mode 100644 index 00000000..4eb3c01e --- /dev/null +++ b/applications/viewer/src/lib/ui/KV.svelte @@ -0,0 +1,11 @@ + + +
+
{label}
+
{@render children()}
+
diff --git a/applications/viewer/src/lib/ui/PageHeader.svelte b/applications/viewer/src/lib/ui/PageHeader.svelte new file mode 100644 index 00000000..6fc8499f --- /dev/null +++ b/applications/viewer/src/lib/ui/PageHeader.svelte @@ -0,0 +1,28 @@ + + +
+
+ {#if icon} +
+ {@render icon()} +
+ {/if} +
+ {#if kind}
{kind}
{/if} +

{title}

+ {#if children}
{@render children()}
{/if} +
+
+ {#if actions}
{@render actions()}
{/if} +
diff --git a/applications/viewer/src/lib/ui/Pager.svelte b/applications/viewer/src/lib/ui/Pager.svelte new file mode 100644 index 00000000..3b2f650f --- /dev/null +++ b/applications/viewer/src/lib/ui/Pager.svelte @@ -0,0 +1,42 @@ + + + diff --git a/applications/viewer/src/lib/ui/Panel.svelte b/applications/viewer/src/lib/ui/Panel.svelte new file mode 100644 index 00000000..feec912b --- /dev/null +++ b/applications/viewer/src/lib/ui/Panel.svelte @@ -0,0 +1,40 @@ + + +
+ {#if title || actions} +
+
+ {#if icon}{@render icon()}{/if} +
+ {#if title}

{title}

{/if} + {#if sub}

{sub}

{/if} +
+
+ {#if actions}
{@render actions()}
{/if} +
+ {/if} +
+ {@render children()} +
+
diff --git a/applications/viewer/src/lib/ui/Ring.svelte b/applications/viewer/src/lib/ui/Ring.svelte new file mode 100644 index 00000000..085b2721 --- /dev/null +++ b/applications/viewer/src/lib/ui/Ring.svelte @@ -0,0 +1,42 @@ + + +
+ + {#if children}
{@render children()}
{/if} +
diff --git a/applications/viewer/src/lib/ui/Skeleton.svelte b/applications/viewer/src/lib/ui/Skeleton.svelte new file mode 100644 index 00000000..634e1235 --- /dev/null +++ b/applications/viewer/src/lib/ui/Skeleton.svelte @@ -0,0 +1,13 @@ + + +
+ {#each Array(rows) as _, i} +
+
+
+
+
+ {/each} +
diff --git a/applications/viewer/src/lib/ui/Sparkline.svelte b/applications/viewer/src/lib/ui/Sparkline.svelte new file mode 100644 index 00000000..8fbea97a --- /dev/null +++ b/applications/viewer/src/lib/ui/Sparkline.svelte @@ -0,0 +1,38 @@ + + +{#if pts} + +{/if} diff --git a/applications/viewer/src/lib/ui/Stat.svelte b/applications/viewer/src/lib/ui/Stat.svelte new file mode 100644 index 00000000..cb50143b --- /dev/null +++ b/applications/viewer/src/lib/ui/Stat.svelte @@ -0,0 +1,49 @@ + + + +
+ {label} + {#if icon}{@render icon()}{/if} +
+
+
+ {#if children} + {@render children()} + {:else} +
{value ?? '—'}
+ {/if} + {#if sub}
{sub}
{/if} +
+ {#if trend && trend.length > 1} +
+ {/if} +
+
diff --git a/applications/viewer/src/lib/ui/Tabs.svelte b/applications/viewer/src/lib/ui/Tabs.svelte new file mode 100644 index 00000000..690ff0aa --- /dev/null +++ b/applications/viewer/src/lib/ui/Tabs.svelte @@ -0,0 +1,48 @@ + + + + +
+ {#each tabs as t (t.id)} + + {/each} +
diff --git a/applications/viewer/src/lib/ui/TimeAgo.svelte b/applications/viewer/src/lib/ui/TimeAgo.svelte new file mode 100644 index 00000000..6f1a498a --- /dev/null +++ b/applications/viewer/src/lib/ui/TimeAgo.svelte @@ -0,0 +1,14 @@ + + + diff --git a/applications/viewer/src/lib/ui/Toaster.svelte b/applications/viewer/src/lib/ui/Toaster.svelte new file mode 100644 index 00000000..59007ef9 --- /dev/null +++ b/applications/viewer/src/lib/ui/Toaster.svelte @@ -0,0 +1,20 @@ + + +
+ {#each toasts.items as t (t.id)} +
+ {#if t.tone === 'error'} + + {:else} + + {/if} + {t.text} +
+ {/each} +
diff --git a/applications/viewer/src/lib/ui/toast.svelte.ts b/applications/viewer/src/lib/ui/toast.svelte.ts new file mode 100644 index 00000000..119e4750 --- /dev/null +++ b/applications/viewer/src/lib/ui/toast.svelte.ts @@ -0,0 +1,30 @@ +export interface Toast { + id: number; + text: string; + tone: 'ok' | 'error' | 'info'; +} + +let next = 1; + +class Toasts { + items = $state([]); + + show(text: string, tone: Toast['tone'] = 'ok', ms = 1800) { + const id = next++; + this.items = [...this.items, { id, text, tone }].slice(-3); + setTimeout(() => (this.items = this.items.filter((t) => t.id !== id)), ms); + } +} + +export const toasts = new Toasts(); + +export async function copyText(text: string, label = 'Copied') { + try { + await navigator.clipboard.writeText(text); + toasts.show(label); + return true; + } catch { + toasts.show('Copy failed', 'error'); + return false; + } +} diff --git a/applications/viewer/src/routes/+layout.svelte b/applications/viewer/src/routes/+layout.svelte index 11b3ca06..473dcd81 100644 --- a/applications/viewer/src/routes/+layout.svelte +++ b/applications/viewer/src/routes/+layout.svelte @@ -1,39 +1,77 @@ - - -
- -
- - - {#if env.PUBLIC_MAINTENANCE_MSG} -
- - {env.PUBLIC_MAINTENANCE_MSG} -
- {/if} - - {#if $navigating} -
- -
- {:else} - - {/if} -
- - -
-
-

- Open-source with ❤️ by BloxBean -

-
-
+ + Yaci Viewer + + + + + + + + + +
+ + + {#if env.PUBLIC_MAINTENANCE_MSG} +
+
+ + {env.PUBLIC_MAINTENANCE_MSG} +
+
+ {/if} + +
+ {@render children()} +
+ +
+ + + diff --git a/applications/viewer/src/routes/+page.server.ts b/applications/viewer/src/routes/+page.server.ts new file mode 100644 index 00000000..8c5fdd6a --- /dev/null +++ b/applications/viewer/src/routes/+page.server.ts @@ -0,0 +1,24 @@ +import { tryApi } from '$lib/api/client'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* First paint for the dashboard. After that the live layer keeps it current. */ +export const load: PageServerLoad = async ({ fetch }) => { + const [blocks, txs, network, epoch, params] = await Promise.all([ + tryApi('/blocks', { fetch, params: { page: 1, count: 16 } }), + tryApi('/txs', { fetch, params: { page: 1, count: 12 } }), + tryApi('/network', { fetch }), + tryApi('/epochs/latest', { fetch }), + tryApi('/epochs/latest/parameters', { fetch }) + ]); + return { + blocks: blocks?.blocks ?? [], + txs: txs?.transaction_summaries ?? [], + network: network as { supply?: Record; stake?: { active?: number } } | null, + epoch: epoch as { epoch: number; start_time: number; end_time: number; tx_count?: number; block_count?: number } | null, + maxBlockSize: Number(params?.max_block_size) || 90112, + reachable: blocks !== null + }; +}; diff --git a/applications/viewer/src/routes/+page.svelte b/applications/viewer/src/routes/+page.svelte index e625013a..b974ab90 100644 --- a/applications/viewer/src/routes/+page.svelte +++ b/applications/viewer/src/routes/+page.svelte @@ -1,101 +1,133 @@ - -
-
-
-
- - - -
-

Blockchain DataViewer

-

Explore real-time blockchain data with our developer-friendly interface

- -
-
-
+Dashboard · Yaci Viewer -{#if blocks.length == 0 || (blocks.length > 0 && Object.keys(blocks[0]).length === 0)} -
-
-
- Waiting for live data - -
-
-
+{#if !data.reachable && !chain.blocks.length} +
+ +
{:else} - + +
+
+
+ {chain.network?.name ?? 'Cardano'} + {#if chain.network?.era}{chain.network.era} era{/if} + {#if chain.status === 'live'} + Live + {:else if chain.status === 'polling'} + Auto-refresh + {/if} +
+

+ Chain tip + {#if tip} + {#key tip.number} + #{num(tip.number)} + {/key} + {/if} +

+

+ {#if tip}Last block · slot {num(tip.slot)} · epoch {tip.epoch}{:else}Waiting for the first block…{/if} +

+
+ +
- -
-
- -
-
-
- - - -

Recent Blocks

-
- -
-
+ + + {#snippet icon()}{/snippet} + {#snippet actions()}All blocks {/snippet} + + - -
-
-
- - - -

Recent Transactions

-
- -
-
-
-
-{/if} + +
+ + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + +
- + +
+ + {#snippet icon()}{/snippet} + {#snippet actions()}All transactions {/snippet} + + + +
+ + {#snippet icon()}{/snippet} + + + + {#snippet icon()}{/snippet} + + +
+
+{/if} diff --git a/applications/viewer/static/favicon.svg b/applications/viewer/static/favicon.svg new file mode 100644 index 00000000..4647bd9d --- /dev/null +++ b/applications/viewer/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/applications/viewer/tailwind.config.cjs b/applications/viewer/tailwind.config.cjs index 722f170a..869d6908 100644 --- a/applications/viewer/tailwind.config.cjs +++ b/applications/viewer/tailwind.config.cjs @@ -1,20 +1,121 @@ +const themes = require('daisyui/src/theming/themes'); + +/* Colours come from CSS variables in app.postcss, so one class works in both themes. */ +const token = (name) => `rgb(var(--c-${name}) / )`; + const config = { content: ['./src/**/*.{html,js,svelte,ts}'], + darkMode: ['selector', '[data-theme="yaci-dark"]'], theme: { - extend: {} + extend: { + colors: { + bg: token('bg'), + 'bg-2': token('bg-2'), + surface: token('surface'), + 'surface-2': token('surface-2'), + line: token('line'), + ink: token('ink'), + muted: token('muted'), + faint: token('faint'), + brand: token('brand'), + 'brand-2': token('brand-2'), + amber: token('amber'), + blue: token('blue'), + cyan: token('cyan'), + mint: token('mint'), + violet: token('violet'), + pink: token('pink'), + danger: token('danger') + }, + fontFamily: { + display: ['"Space Grotesk Variable"', 'ui-sans-serif', 'system-ui', 'sans-serif'], + sans: ['"Inter Variable"', 'ui-sans-serif', 'system-ui', '-apple-system', '"Segoe UI"', 'sans-serif'], + mono: ['"JetBrains Mono Variable"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'] + }, + borderRadius: { + xl: '14px', + '2xl': '18px' + }, + boxShadow: { + card: '0 1px 0 0 rgb(var(--c-ink) / 0.03), 0 18px 40px -28px rgb(0 0 0 / 0.45)', + glow: '0 0 0 1px rgb(var(--c-brand) / 0.35), 0 10px 30px -10px rgb(var(--c-brand) / 0.45)' + }, + backgroundImage: { + grad: 'var(--grad)' + }, + keyframes: { + 'slide-in': { + from: { opacity: '0', transform: 'translateX(-18px) scale(0.96)' }, + to: { opacity: '1', transform: 'none' } + }, + 'fade-up': { + from: { opacity: '0', transform: 'translateY(8px)' }, + to: { opacity: '1', transform: 'none' } + }, + flash: { + from: { backgroundColor: 'rgb(var(--c-brand) / 0.16)' }, + to: { backgroundColor: 'transparent' } + }, + shimmer: { + to: { backgroundPosition: '-200% 0' } + }, + ping: { + '75%, 100%': { transform: 'scale(2.2)', opacity: '0' } + } + }, + animation: { + 'slide-in': 'slide-in 0.55s cubic-bezier(0.2, 0.8, 0.2, 1) both', + 'fade-up': 'fade-up 0.4s cubic-bezier(0.2, 0.8, 0.2, 1) both', + flash: 'flash 1.8s ease-out', + shimmer: 'shimmer 1.6s linear infinite', + ping: 'ping 1.6s cubic-bezier(0, 0, 0.2, 1) infinite' + } + } }, plugins: [require('daisyui')], - darkMode: 'class', + + /* DaisyUI stays only while pages move to the new components. Its themes follow the same palette. */ daisyui: { + logs: false, themes: [ { yaci: { - ...require('daisyui/src/theming/themes')['light'] + ...themes['light'], + primary: '#e8501c', + 'primary-content': '#ffffff', + secondary: '#7c3aed', + accent: '#0891b2', + neutral: '#14121c', + 'base-100': '#ffffff', + 'base-200': '#f6f4f2', + 'base-300': '#e6e2ea', + 'base-content': '#14121c', + info: '#1f6bff', + success: '#059669', + warning: '#b97806', + error: '#dc2626', + '--rounded-box': '14px', + '--rounded-btn': '10px' }, 'yaci-dark': { - ...require('daisyui/src/theming/themes')['dark'] + ...themes['dark'], + primary: '#ff6a34', + 'primary-content': '#ffffff', + secondary: '#a78bfa', + accent: '#22d3ee', + neutral: '#161926', + 'base-100': '#10121c', + 'base-200': '#0d0f17', + 'base-300': '#222536', + 'base-content': '#eef0f8', + info: '#3b8bff', + success: '#34d399', + warning: '#fbbf24', + error: '#f87171', + '--rounded-box': '14px', + '--rounded-btn': '10px' } } ] From 7f8456fd5909591d9f4a5c4b9bde094ed5f5b886 Mon Sep 17 00:00:00 2001 From: Satya Date: Sat, 17 Oct 2026 01:15:20 +0800 Subject: [PATCH 2/3] Viewer: redesigned blocks, transactions, addresses and stake accounts - Transaction page: visual UTxO flow (inputs -> tx -> outputs) with change detection and address highlighting, where-the-ADA-went breakdown, scripts with execution budget bars, decoded redeemers and datums (Plutus data CBOR decoder), mint/burn, metadata with CIP labels, collateral, reference inputs, required signers, raw JSON - Block page: prev/next with arrow keys, live confirmations, size gauge, consensus fields on demand; blocks list with size bars and live rows - Transactions list with live rows on page 1 - Address page: type from the header, balance (with /amounts fallback), stake account and pool derived via bech32, txs/UTxOs/tokens tabs - Stake account page: controlled stake, pool and DRep delegation, rewards, withdrawals, other rewards - Fix paging: these Yaci Store endpoints are 1-based, so page 2 of an address or stake account used to show page 1 again - CIP-68 asset names, signed mint quantities, address lookup page, friendly 404/503 page - Theme follows the OS until the user picks one - Remove the old home-page widgets and WebSocket store Co-Authored-By: Claude Opus 5.5 --- applications/viewer/src/lib/address.test.ts | 32 + applications/viewer/src/lib/address.ts | 40 ++ applications/viewer/src/lib/api/load.ts | 21 + applications/viewer/src/lib/bech32.ts | 73 +++ applications/viewer/src/lib/cbor.test.ts | 30 + applications/viewer/src/lib/cbor.ts | 146 +++++ applications/viewer/src/lib/format.test.ts | 1 + applications/viewer/src/lib/format.ts | 22 +- applications/viewer/src/lib/stores/theme.ts | 53 +- .../viewer/src/lib/tx/BalanceBar.svelte | 38 ++ .../viewer/src/lib/tx/DatumDialog.svelte | 26 + .../viewer/src/lib/tx/ScriptCard.svelte | 70 +++ .../viewer/src/lib/tx/UtxoCard.svelte | 89 +++ .../viewer/src/lib/tx/UtxoFlow.svelte | 91 +++ .../viewer/src/lib/tx/hover.svelte.ts | 6 + applications/viewer/src/lib/tx/types.ts | 26 + .../viewer/src/lib/ui/AssetChip.svelte | 8 +- .../viewer/src/lib/ui/LookupHero.svelte | 73 +++ .../viewer/src/lib/ui/PlutusTree.svelte | 51 ++ .../viewer/src/lib/ui/PlutusView.svelte | 38 ++ applications/viewer/src/lib/ui/Tabs.svelte | 40 +- applications/viewer/src/routes/+error.svelte | 25 + .../viewer/src/routes/NetworkInfo.svelte | 172 ------ .../viewer/src/routes/addresses/+page.svelte | 118 +--- .../addresses/[address]/+page.server.ts | 194 ++---- .../routes/addresses/[address]/+page.svelte | 554 ++++++------------ .../viewer/src/routes/blocks/+page.server.ts | 55 +- .../viewer/src/routes/blocks/+page.svelte | 334 +++-------- .../viewer/src/routes/blocks/Blocks.svelte | 39 -- .../src/routes/blocks/BlocksAggr.svelte | 51 -- .../src/routes/blocks/RecentBlocks.svelte | 39 -- .../routes/blocks/[number]/+page.server.ts | 40 +- .../src/routes/blocks/[number]/+page.svelte | 309 ++++------ .../viewer/src/routes/blocks/store.js | 92 --- .../routes/stake/[address]/+page.server.ts | 73 +-- .../src/routes/stake/[address]/+page.svelte | 400 ++++--------- .../src/routes/transactions/+page.server.ts | 51 +- .../src/routes/transactions/+page.svelte | 350 ++--------- .../src/routes/transactions/RecentTxs.svelte | 49 -- .../routes/transactions/Transactions.svelte | 31 - .../src/routes/transactions/TxSearch.js | 17 - .../src/routes/transactions/TxSearch.svelte | 13 - .../routes/transactions/[id]/+page.server.ts | 69 ++- .../src/routes/transactions/[id]/+page.svelte | 526 ++++++----------- applications/viewer/vite.config.js | 4 + 45 files changed, 1881 insertions(+), 2698 deletions(-) create mode 100644 applications/viewer/src/lib/address.test.ts create mode 100644 applications/viewer/src/lib/address.ts create mode 100644 applications/viewer/src/lib/api/load.ts create mode 100644 applications/viewer/src/lib/bech32.ts create mode 100644 applications/viewer/src/lib/cbor.test.ts create mode 100644 applications/viewer/src/lib/cbor.ts create mode 100644 applications/viewer/src/lib/tx/BalanceBar.svelte create mode 100644 applications/viewer/src/lib/tx/DatumDialog.svelte create mode 100644 applications/viewer/src/lib/tx/ScriptCard.svelte create mode 100644 applications/viewer/src/lib/tx/UtxoCard.svelte create mode 100644 applications/viewer/src/lib/tx/UtxoFlow.svelte create mode 100644 applications/viewer/src/lib/tx/hover.svelte.ts create mode 100644 applications/viewer/src/lib/tx/types.ts create mode 100644 applications/viewer/src/lib/ui/LookupHero.svelte create mode 100644 applications/viewer/src/lib/ui/PlutusTree.svelte create mode 100644 applications/viewer/src/lib/ui/PlutusView.svelte create mode 100644 applications/viewer/src/routes/+error.svelte delete mode 100644 applications/viewer/src/routes/NetworkInfo.svelte delete mode 100644 applications/viewer/src/routes/blocks/Blocks.svelte delete mode 100644 applications/viewer/src/routes/blocks/BlocksAggr.svelte delete mode 100644 applications/viewer/src/routes/blocks/RecentBlocks.svelte delete mode 100644 applications/viewer/src/routes/blocks/store.js delete mode 100644 applications/viewer/src/routes/transactions/RecentTxs.svelte delete mode 100644 applications/viewer/src/routes/transactions/Transactions.svelte delete mode 100644 applications/viewer/src/routes/transactions/TxSearch.js delete mode 100644 applications/viewer/src/routes/transactions/TxSearch.svelte diff --git a/applications/viewer/src/lib/address.test.ts b/applications/viewer/src/lib/address.test.ts new file mode 100644 index 00000000..d58c911f --- /dev/null +++ b/applications/viewer/src/lib/address.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { addressInfo } from './address'; + +describe('addressInfo', () => { + it('reads the address type from the header', () => { + expect(addressInfo('addr1qxzcr6xfgu4u5dwhruqml9yj0v9j8l2vl3qcwdla4famv3vlex2jvsm78g26gu6sjg7t95a2y5s6whydc2vh73k7arws7svqpg')).toMatchObject({ + label: 'Base address', + script: false, + network: 'mainnet' + }); + expect(addressInfo('addr1z8j6ytjvxwsx5w3zq').script).toBe(true); + expect(addressInfo('addr1w8abc').label).toBe('Script enterprise address'); + expect(addressInfo('addr_test1vzs0r2nae22szlq3ul3h8t4u7rz9dr850mqjh98caddm4zcypzjjt')).toMatchObject({ + label: 'Enterprise address', + delegates: false, + network: 'testnet' + }); + expect(addressInfo('stake1ux0un9fxgdlr59dywdgfy09j6w4z2gd8tjxu9xtlgm0w3hgp0qx48').era).toBe('stake'); + }); +}); + +import { stakeAddressOf } from './bech32'; + +describe('stakeAddressOf', () => { + it('derives the stake address of a base address', () => { + expect( + stakeAddressOf('addr1qxzcr6xfgu4u5dwhruqml9yj0v9j8l2vl3qcwdla4famv3vlex2jvsm78g26gu6sjg7t95a2y5s6whydc2vh73k7arws7svqpg') + ).toBe('stake1ux0un9fxgdlr59dywdgfy09j6w4z2gd8tjxu9xtlgm0w3hgp0qx48'); + expect(stakeAddressOf('addr_test1vzs0r2nae22szlq3ul3h8t4u7rz9dr850mqjh98caddm4zcypzjjt')).toBeNull(); + expect(stakeAddressOf('not an address')).toBeNull(); + }); +}); diff --git a/applications/viewer/src/lib/address.ts b/applications/viewer/src/lib/address.ts new file mode 100644 index 00000000..77bb4631 --- /dev/null +++ b/applications/viewer/src/lib/address.ts @@ -0,0 +1,40 @@ +/* + * What kind of address this is, from its header. Shelley addresses are bech32: the first data character after + * "addr1" / "addr_test1" carries the top five bits of the header byte, whose high nibble is the address type. + */ + +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; + +export interface AddressInfo { + era: 'shelley' | 'byron' | 'stake' | 'unknown'; + /** Payment part is a script (a contract address). */ + script: boolean; + /** Has a stake part that can delegate. */ + delegates: boolean; + label: string; + network: 'mainnet' | 'testnet' | null; +} + +const TYPES: Record> = { + 0: { script: false, delegates: true, label: 'Base address' }, + 1: { script: true, delegates: true, label: 'Script address' }, + 2: { script: false, delegates: true, label: 'Base address (script stake)' }, + 3: { script: true, delegates: true, label: 'Script address (script stake)' }, + 4: { script: false, delegates: true, label: 'Pointer address' }, + 5: { script: true, delegates: true, label: 'Script pointer address' }, + 6: { script: false, delegates: false, label: 'Enterprise address' }, + 7: { script: true, delegates: false, label: 'Script enterprise address' } +}; + +export function addressInfo(addr: string): AddressInfo { + const m = addr.match(/^(addr|addr_test|stake|stake_test)1(.)/); + if (!m) { + if (/^(Ae2|DdzFF)/.test(addr)) return { era: 'byron', script: false, delegates: false, label: 'Byron address', network: null }; + return { era: 'unknown', script: false, delegates: false, label: 'Address', network: null }; + } + const network = m[1].endsWith('_test') ? 'testnet' : 'mainnet'; + if (m[1].startsWith('stake')) return { era: 'stake', script: false, delegates: true, label: 'Stake address', network }; + const top5 = CHARSET.indexOf(m[2]); + const type = top5 >> 1; + return { era: 'shelley', network, ...(TYPES[type] ?? { script: false, delegates: false, label: 'Address' }) }; +} diff --git a/applications/viewer/src/lib/api/load.ts b/applications/viewer/src/lib/api/load.ts new file mode 100644 index 00000000..1b1794ba --- /dev/null +++ b/applications/viewer/src/lib/api/load.ts @@ -0,0 +1,21 @@ +import { error } from '@sveltejs/kit'; +import { ApiError, api, type ApiOptions } from './client'; + +/* + * For a page's main resource: 404 becomes a "not found" page, an unreachable store becomes a 503 with a clear + * message, anything else a 502. + */ +export async function must(path: string, opts: ApiOptions, notFound: string): Promise { + try { + const data = await api(path, opts); + if (data === null || data === undefined) error(404, notFound); + return data; + } catch (e) { + if (e instanceof ApiError) { + if (e.status === 404 || e.status === 400) error(404, notFound); + if (e.status === 0) error(503, `${e.message}. Check that Yaci Store is running.`); + error(502, `Yaci Store: ${e.message}`); + } + throw e; + } +} diff --git a/applications/viewer/src/lib/bech32.ts b/applications/viewer/src/lib/bech32.ts new file mode 100644 index 00000000..4d520dd3 --- /dev/null +++ b/applications/viewer/src/lib/bech32.ts @@ -0,0 +1,73 @@ +/* Minimal bech32 (BIP-173) decode/encode, enough to derive a stake address from a Shelley base address. */ + +const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; +const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]; + +function polymod(values: number[]) { + let chk = 1; + for (const v of values) { + const top = chk >> 25; + chk = ((chk & 0x1ffffff) << 5) ^ v; + for (let i = 0; i < 5; i++) if ((top >> i) & 1) chk ^= GEN[i]; + } + return chk; +} + +const hrpExpand = (hrp: string) => [...[...hrp].map((c) => c.charCodeAt(0) >> 5), 0, ...[...hrp].map((c) => c.charCodeAt(0) & 31)]; + +function convert(data: number[], from: number, to: number, pad: boolean): number[] | null { + let acc = 0; + let bits = 0; + const out: number[] = []; + const max = (1 << to) - 1; + for (const v of data) { + acc = (acc << from) | v; + bits += from; + while (bits >= to) { + bits -= to; + out.push((acc >> bits) & max); + } + } + if (pad) { + if (bits > 0) out.push((acc << (to - bits)) & max); + } else if (bits >= from || ((acc << (to - bits)) & max)) { + return null; + } + return out; +} + +export function bech32Decode(s: string): { hrp: string; bytes: Uint8Array } | null { + const str = s.toLowerCase(); + const pos = str.lastIndexOf('1'); + if (pos < 1 || pos + 7 > str.length) return null; + const hrp = str.slice(0, pos); + const data: number[] = []; + for (const c of str.slice(pos + 1)) { + const d = CHARSET.indexOf(c); + if (d < 0) return null; + data.push(d); + } + if (polymod([...hrpExpand(hrp), ...data]) !== 1) return null; + const bytes = convert(data.slice(0, -6), 5, 8, false); + return bytes ? { hrp, bytes: new Uint8Array(bytes) } : null; +} + +export function bech32Encode(hrp: string, bytes: Uint8Array): string { + const data = convert([...bytes], 8, 5, true)!; + const values = [...hrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]; + const mod = polymod(values) ^ 1; + const checksum = Array.from({ length: 6 }, (_, i) => (mod >> (5 * (5 - i))) & 31); + return `${hrp}1${[...data, ...checksum].map((d) => CHARSET[d]).join('')}`; +} + +/** The stake address of a Shelley base address (types 0-3), or null. */ +export function stakeAddressOf(address: string): string | null { + const d = bech32Decode(address); + if (!d || !d.hrp.startsWith('addr') || d.bytes.length !== 57) return null; + const type = d.bytes[0] >> 4; + if (type > 3) return null; + const network = d.bytes[0] & 0x0f; + const scriptStake = type === 2 || type === 3; + const header = (scriptStake ? 0xf0 : 0xe0) | network; + return bech32Encode(d.hrp === 'addr' ? 'stake' : 'stake_test', new Uint8Array([header, ...d.bytes.slice(29)])); +} diff --git a/applications/viewer/src/lib/cbor.test.ts b/applications/viewer/src/lib/cbor.test.ts new file mode 100644 index 00000000..25af6514 --- /dev/null +++ b/applications/viewer/src/lib/cbor.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { decodePlutusData, tryDecodePlutusData } from './cbor'; + +describe('decodePlutusData', () => { + it('decodes constructors', () => { + expect(decodePlutusData('d87980')).toEqual({ constructor: 0, fields: [] }); + expect(decodePlutusData('d87a80')).toEqual({ constructor: 1, fields: [] }); + // Constr 7 via tag 1280 + expect(decodePlutusData('d9050080')).toEqual({ constructor: 7, fields: [] }); + }); + + it('decodes ints, bytes, lists and maps', () => { + // Constr 0 [42, h'cafe', [1, -2], {1: 2}] using an indefinite list for the fields + expect(decodePlutusData('d8799f182a42cafe820121a10102ff')).toEqual({ + constructor: 0, + fields: [{ int: '42' }, { bytes: 'cafe' }, { list: [{ int: '1' }, { int: '-2' }] }, { map: [{ k: { int: '1' }, v: { int: '2' } }] }] + }); + }); + + it('decodes big integers and chunked bytes', () => { + expect(decodePlutusData('c249010000000000000000')).toEqual({ int: '18446744073709551616' }); + expect(decodePlutusData('5f4201024103ff')).toEqual({ bytes: '010203' }); + }); + + it('returns null for things that are not Plutus data', () => { + expect(tryDecodePlutusData('zz')).toBeNull(); + expect(tryDecodePlutusData('60')).toBeNull(); // text string + expect(tryDecodePlutusData(null)).toBeNull(); + }); +}); diff --git a/applications/viewer/src/lib/cbor.ts b/applications/viewer/src/lib/cbor.ts new file mode 100644 index 00000000..68b5785b --- /dev/null +++ b/applications/viewer/src/lib/cbor.ts @@ -0,0 +1,146 @@ +/* + * A small decoder for Plutus data (datums, redeemers) from CBOR hex, into the "detailed schema" JSON that + * cardano-cli and most tools use: { constructor, fields } | { int } | { bytes } | { list } | { map }. + */ + +export type PlutusData = + | { constructor: number; fields: PlutusData[] } + | { int: string } + | { bytes: string } + | { list: PlutusData[] } + | { map: { k: PlutusData; v: PlutusData }[] }; + +class Reader { + pos = 0; + constructor(private b: Uint8Array) {} + + byte() { + if (this.pos >= this.b.length) throw new Error('Unexpected end of CBOR'); + return this.b[this.pos++]; + } + + /** Reads a head: major type and argument (as bigint; null for indefinite length). */ + head(): { major: number; arg: bigint | null; info: number } { + const ib = this.byte(); + const major = ib >> 5; + const info = ib & 0x1f; + if (info < 24) return { major, arg: BigInt(info), info }; + if (info === 31) return { major, arg: null, info }; + const n = info === 24 ? 1 : info === 25 ? 2 : info === 26 ? 4 : info === 27 ? 8 : -1; + if (n < 0) throw new Error(`Bad CBOR additional info ${info}`); + let v = 0n; + for (let i = 0; i < n; i++) v = (v << 8n) | BigInt(this.byte()); + return { major, arg: v, info }; + } + + bytes(n: number) { + if (this.pos + n > this.b.length) throw new Error('Unexpected end of CBOR'); + const out = this.b.slice(this.pos, this.pos + n); + this.pos += n; + return out; + } + + isBreak() { + return this.b[this.pos] === 0xff; + } +} + +const toHex = (u: Uint8Array) => Array.from(u, (x) => x.toString(16).padStart(2, '0')).join(''); + +function bigFromBytes(u: Uint8Array) { + let v = 0n; + for (const x of u) v = (v << 8n) | BigInt(x); + return v; +} + +function readByteString(r: Reader, arg: bigint | null): Uint8Array { + if (arg !== null) return r.bytes(Number(arg)); + // Indefinite: chunks until break (Plutus splits bytes over 64 long). + const parts: Uint8Array[] = []; + while (!r.isBreak()) { + const h = r.head(); + if (h.major !== 2 || h.arg === null) throw new Error('Bad bytes chunk'); + parts.push(r.bytes(Number(h.arg))); + } + r.byte(); + const out = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} + +function readItems(r: Reader, arg: bigint | null, each: () => void) { + if (arg === null) { + while (!r.isBreak()) each(); + r.byte(); + } else { + for (let i = 0n; i < arg; i++) each(); + } +} + +function read(r: Reader): PlutusData { + const { major, arg } = r.head(); + switch (major) { + case 0: + return { int: String(arg) }; + case 1: + return { int: String(-1n - (arg ?? 0n)) }; + case 2: + return { bytes: toHex(readByteString(r, arg)) }; + case 4: { + const list: PlutusData[] = []; + readItems(r, arg, () => list.push(read(r))); + return { list }; + } + case 5: { + const map: { k: PlutusData; v: PlutusData }[] = []; + readItems(r, arg, () => map.push({ k: read(r), v: read(r) })); + return { map }; + } + case 6: { + const tag = Number(arg); + // Constructors 0-6 → tags 121-127; 7-127 → 1280-1400; any other via tag 102 [n, fields]. + if (tag >= 121 && tag <= 127) return { constructor: tag - 121, fields: listFields(read(r)) }; + if (tag >= 1280 && tag <= 1400) return { constructor: tag - 1280 + 7, fields: listFields(read(r)) }; + if (tag === 102) { + const inner = read(r); + if (!('list' in inner) || inner.list.length !== 2 || !('int' in inner.list[0])) throw new Error('Bad tag 102'); + return { constructor: Number(inner.list[0].int), fields: listFields(inner.list[1]) }; + } + if (tag === 2 || tag === 3) { + const inner = read(r); + if (!('bytes' in inner)) throw new Error('Bad bignum'); + const v = bigFromBytes(new Uint8Array(inner.bytes.match(/../g)?.map((h) => parseInt(h, 16)) ?? [])); + return { int: String(tag === 2 ? v : -1n - v) }; + } + throw new Error(`Unsupported CBOR tag ${tag}`); + } + default: + throw new Error(`Not Plutus data (CBOR major type ${major})`); + } +} + +function listFields(d: PlutusData): PlutusData[] { + if (!('list' in d)) throw new Error('Constructor fields must be a list'); + return d.list; +} + +export function decodePlutusData(hex: string): PlutusData { + const clean = hex.trim().toLowerCase(); + if (!/^([0-9a-f]{2})+$/.test(clean)) throw new Error('Not hex'); + const r = new Reader(new Uint8Array(clean.match(/../g)!.map((h) => parseInt(h, 16)))); + const out = read(r); + return out; +} + +export function tryDecodePlutusData(hex: string | null | undefined): PlutusData | null { + if (!hex) return null; + try { + return decodePlutusData(hex); + } catch { + return null; + } +} diff --git a/applications/viewer/src/lib/format.test.ts b/applications/viewer/src/lib/format.test.ts index 78b46d88..017d25db 100644 --- a/applications/viewer/src/lib/format.test.ts +++ b/applications/viewer/src/lib/format.test.ts @@ -30,5 +30,6 @@ describe('format', () => { expect(hexToText('00ff')).toBeNull(); expect(parseUnit('lovelace').name).toBe('ADA'); expect(parseUnit('c'.repeat(56) + '4d494e54')).toMatchObject({ policy: 'c'.repeat(56), name: 'MINT' }); + expect(parseUnit('c'.repeat(56) + '0014df105553444d')).toMatchObject({ name: 'USDM', label: 'FT' }); }); }); diff --git a/applications/viewer/src/lib/format.ts b/applications/viewer/src/lib/format.ts index 728304f7..181ad817 100644 --- a/applications/viewer/src/lib/format.ts +++ b/applications/viewer/src/lib/format.ts @@ -141,10 +141,26 @@ export function hexToText(hex: string | null | undefined): string | null { } } -/** Asset unit (policy + hex name) → { policy, nameHex, name }. "lovelace" → ADA. */ -export function parseUnit(unit: string): { policy: string; nameHex: string; name: string } { +/** CIP-68 asset name labels: a 4-byte prefix before the readable name. */ +export const CIP68_LABELS: Record = { + '000643b0': 'reference', + '000de140': 'NFT', + '0014df10': 'FT', + '001bc280': 'RFT' +}; + +/** Asset unit (policy + hex name) → { policy, nameHex, name, label }. "lovelace" → ADA. */ +export function parseUnit(unit: string): { policy: string; nameHex: string; name: string; label?: string } { if (!unit || unit === 'lovelace') return { policy: '', nameHex: '', name: 'ADA' }; const policy = unit.slice(0, 56); const nameHex = unit.slice(56); - return { policy, nameHex, name: hexToText(nameHex) ?? (nameHex ? shorten(nameHex, 6, 4) : shorten(policy, 6, 4)) }; + const label = CIP68_LABELS[nameHex.slice(0, 8)]; + const body = label ? nameHex.slice(8) : nameHex; + const text = hexToText(body); + return { + policy, + nameHex, + label, + name: text ?? (nameHex ? shorten(nameHex, 6, 4) : shorten(policy, 6, 4)) + }; } diff --git a/applications/viewer/src/lib/stores/theme.ts b/applications/viewer/src/lib/stores/theme.ts index 6d9bb520..de5c7dc3 100644 --- a/applications/viewer/src/lib/stores/theme.ts +++ b/applications/viewer/src/lib/stores/theme.ts @@ -4,33 +4,38 @@ export type Theme = 'yaci' | 'yaci-dark'; const STORAGE_KEY = 'viewer:theme'; -function detectInitial(): Theme { - if (typeof window === 'undefined') return 'yaci'; - try { - const saved = window.localStorage.getItem(STORAGE_KEY) as Theme | null; - if (saved === 'yaci' || saved === 'yaci-dark') return saved; - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'yaci-dark' : 'yaci'; - } catch { - return 'yaci'; - } +/* The theme app.html already applied before first paint: saved choice, else the OS preference. */ +function current(): Theme { + if (typeof document === 'undefined') return 'yaci'; + return document.documentElement.getAttribute('data-theme') === 'yaci-dark' ? 'yaci-dark' : 'yaci'; } -export const theme = writable(detectInitial()); - -// Apply to -if (typeof window !== 'undefined') { - const apply = (t: Theme) => { - document.documentElement.setAttribute('data-theme', t); - try { window.localStorage.setItem(STORAGE_KEY, t); } catch {} - }; - // initialize - apply(detectInitial()); - // subscribe updates - theme.subscribe(apply); -} +export const theme = writable(current()); +/** Only an explicit toggle is saved, so people who never choose keep following their OS. */ export function toggleTheme() { - theme.update((t) => (t === 'yaci' ? 'yaci-dark' : 'yaci')); + theme.update((t) => { + const next: Theme = t === 'yaci' ? 'yaci-dark' : 'yaci'; + document.documentElement.setAttribute('data-theme', next); + try { + localStorage.setItem(STORAGE_KEY, next); + } catch { + /* storage unavailable */ + } + return next; + }); } +// Follow OS changes until the user picks a theme. +if (typeof window !== 'undefined' && window.matchMedia) { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { + try { + if (localStorage.getItem(STORAGE_KEY)) return; + } catch { + /* storage unavailable */ + } + const next: Theme = e.matches ? 'yaci-dark' : 'yaci'; + document.documentElement.setAttribute('data-theme', next); + theme.set(next); + }); +} diff --git a/applications/viewer/src/lib/tx/BalanceBar.svelte b/applications/viewer/src/lib/tx/BalanceBar.svelte new file mode 100644 index 00000000..98b5d306 --- /dev/null +++ b/applications/viewer/src/lib/tx/BalanceBar.svelte @@ -0,0 +1,38 @@ + + +
+
+
+
+ {#if other > 0n}
{/if} +
+
+
In{withdrawals > 0n ? ' (inputs + withdrawals)' : ''}
+
+
To outputs
+
+
Fee
+
+ {#if other !== 0n} +
{other > 0n ? 'Deposits' : 'Deposit refunds'}
+
0n ? other : -other} decimals={6} />
+ {/if} +
+
diff --git a/applications/viewer/src/lib/tx/DatumDialog.svelte b/applications/viewer/src/lib/tx/DatumDialog.svelte new file mode 100644 index 00000000..b5970764 --- /dev/null +++ b/applications/viewer/src/lib/tx/DatumDialog.svelte @@ -0,0 +1,26 @@ + + + e.key === 'Escape' && (utxo = null)} /> + +{#if utxo} + +{/if} diff --git a/applications/viewer/src/lib/tx/ScriptCard.svelte b/applications/viewer/src/lib/tx/ScriptCard.svelte new file mode 100644 index 00000000..b956f7a8 --- /dev/null +++ b/applications/viewer/src/lib/tx/ScriptCard.svelte @@ -0,0 +1,70 @@ + + +
+
+ {#if r?.tag}{r.tag}{r.index != null ? ` #${r.index}` : ''}{/if} + {#if version}{version}{/if} + +
+ + {#if r?.ex_units} +
+ {#each [{ label: 'Memory', icon: MemoryStick, used: r.ex_units.mem, pct: memPct }, { label: 'CPU steps', icon: Cpu, used: r.ex_units.steps, pct: stepPct }] as u (u.label)} +
+
+ {u.label} + {num(u.used)} · {u.pct.toFixed(2)}% of tx limit +
+
+
+
+
+ {/each} +
+ {/if} + +
+ {#if r?.data}{/if} + {#if script.datum}{/if} +
+
diff --git a/applications/viewer/src/lib/tx/UtxoCard.svelte b/applications/viewer/src/lib/tx/UtxoCard.svelte new file mode 100644 index 00000000..c7d95ad0 --- /dev/null +++ b/applications/viewer/src/lib/tx/UtxoCard.svelte @@ -0,0 +1,89 @@ + + +
(hover.address = utxo.address)} + onmouseleave={() => (hover.address = null)} + role="group" +> +
+
+ +
+ {#if isScript}script{/if} + {#if tag}{tag}{/if} + {#if utxo.stake_address} + {shorten(utxo.stake_address, 9, 5)} + {/if} +
+
+
+ + {#if side === 'in'} + + {shorten(utxo.tx_hash, 6, 4)}#{utxo.output_index} + + {:else} +
#{utxo.output_index}
+ {/if} +
+
+ + {#if assets.length} +
+ {#each showAll ? assets : assets.slice(0, 4) as a (a.unit)} + + {/each} + {#if assets.length > 4} + + {/if} +
+ {/if} + + {#if utxo.inline_datum || utxo.data_hash || utxo.reference_script_hash} +
+ {#if utxo.inline_datum} + + {:else if utxo.data_hash} + Datum {shorten(utxo.data_hash, 6, 4)} + {/if} + {#if utxo.reference_script_hash} + Reference script + {/if} +
+ {/if} +
diff --git a/applications/viewer/src/lib/tx/UtxoFlow.svelte b/applications/viewer/src/lib/tx/UtxoFlow.svelte new file mode 100644 index 00000000..02435025 --- /dev/null +++ b/applications/viewer/src/lib/tx/UtxoFlow.svelte @@ -0,0 +1,91 @@ + + +
+ +
+
+ Inputs · {inputs.length} + +
+
    + {#each inputs as u (u.tx_hash + u.output_index)} +
  • + {/each} +
+
+ + +
+ + + +
+ + + Fee + +
+
+ + +
+
+ Outputs · {outputs.length} + +
+
    + {#each outputs as u (u.output_index)} +
  • + {/each} +
+
+
+ + diff --git a/applications/viewer/src/lib/tx/hover.svelte.ts b/applications/viewer/src/lib/tx/hover.svelte.ts new file mode 100644 index 00000000..be2ee7f2 --- /dev/null +++ b/applications/viewer/src/lib/tx/hover.svelte.ts @@ -0,0 +1,6 @@ +/* The address under the pointer, so every input and output at that address lights up together. */ +class AddressHover { + address = $state(null); +} + +export const hover = new AddressHover(); diff --git a/applications/viewer/src/lib/tx/types.ts b/applications/viewer/src/lib/tx/types.ts new file mode 100644 index 00000000..b6caf003 --- /dev/null +++ b/applications/viewer/src/lib/tx/types.ts @@ -0,0 +1,26 @@ +export interface Amount { + unit: string; + policy_id?: string | null; + asset_name?: string | null; + quantity: string | number; +} + +export interface Utxo { + tx_hash: string; + output_index: number; + address: string; + stake_address?: string | null; + amount: Amount[]; + data_hash?: string | null; + inline_datum?: string | null; + inline_datum_json?: unknown; + script_ref?: string | null; + reference_script_hash?: string | null; +} + +export const lovelaceOf = (amounts: Amount[] | undefined) => + (amounts ?? []).filter((a) => a.unit === 'lovelace').reduce((s, a) => s + BigInt(a.quantity), 0n); + +export const assetsOf = (amounts: Amount[] | undefined) => (amounts ?? []).filter((a) => a.unit !== 'lovelace'); + +export const sumLovelace = (utxos: Utxo[] | undefined) => (utxos ?? []).reduce((s, u) => s + lovelaceOf(u.amount), 0n); diff --git a/applications/viewer/src/lib/ui/AssetChip.svelte b/applications/viewer/src/lib/ui/AssetChip.svelte index fe29ed9d..9cac8543 100644 --- a/applications/viewer/src/lib/ui/AssetChip.svelte +++ b/applications/viewer/src/lib/ui/AssetChip.svelte @@ -18,5 +18,11 @@ >{info.name.slice(0, 1).toUpperCase()} {info.name} - {#if quantity != null}×{num(quantity)}{/if} + {#if info.label}{info.label}{/if} + {#if quantity != null} + {@const q = String(quantity)} + {q.startsWith('-') || q.startsWith('+') ? q[0] + num(q.slice(1)) : '×' + num(q)} + {/if} diff --git a/applications/viewer/src/lib/ui/LookupHero.svelte b/applications/viewer/src/lib/ui/LookupHero.svelte new file mode 100644 index 00000000..7e3eacbb --- /dev/null +++ b/applications/viewer/src/lib/ui/LookupHero.svelte @@ -0,0 +1,73 @@ + + +
+
+ +
+
{kind}
+

{title}

+

{text}

+ +
+ (problem = null)} + spellcheck="false" + autocomplete="off" + aria-label={placeholder} + /> + +
+ {#if problem}

{problem}

{/if} + {#if examples.length} +
+ Try + {#each examples as ex (ex.value)} + + {/each} +
+ {/if} + {#if children}
{@render children()}
{/if} +
diff --git a/applications/viewer/src/lib/ui/PlutusTree.svelte b/applications/viewer/src/lib/ui/PlutusTree.svelte new file mode 100644 index 00000000..01005862 --- /dev/null +++ b/applications/viewer/src/lib/ui/PlutusTree.svelte @@ -0,0 +1,51 @@ + + +{#if 'fields' in data} +
+ Constr {data.constructor} + {#if data.fields.length === 0}[]{/if} + {#if data.fields.length} +
+ {#each data.fields as f, i (i)} +
{i}
+ {/each} +
+ {/if} +
+{:else if 'int' in data} + {data.int} +{:else if 'bytes' in data} + {@const text = data.bytes.length >= 4 ? hexToText(data.bytes) : null} + {data.bytes ? `#${data.bytes}` : '#""'}{#if text}"{text}"{/if} +{:else if 'list' in data} +
+ List [{data.list.length}] + {#if data.list.length} +
+ {#each data.list as item, i (i)} +
{i}
+ {/each} +
+ {/if} +
+{:else if 'map' in data} +
+ Map {'{'}{data.map.length}{'}'} +
+ {#each data.map as kv, i (i)} +
+
+ → +
+
+ {/each} +
+
+{/if} diff --git a/applications/viewer/src/lib/ui/PlutusView.svelte b/applications/viewer/src/lib/ui/PlutusView.svelte new file mode 100644 index 00000000..70ba3f18 --- /dev/null +++ b/applications/viewer/src/lib/ui/PlutusView.svelte @@ -0,0 +1,38 @@ + + +
+
+ {label} +
+ {#each [['tree', 'Decoded'], ['json', 'JSON'], ['cbor', 'CBOR']] as [id, text] (id)} + + {/each} +
+
+ {#if view === 'tree'} +
+ {#if decoded}{:else}Could not decode this CBOR as Plutus data.{/if} +
+ {:else if view === 'json'} +
+ {:else} +
+ {cbor} + {#if cbor}{/if} +
+ {/if} +
diff --git a/applications/viewer/src/lib/ui/Tabs.svelte b/applications/viewer/src/lib/ui/Tabs.svelte index 690ff0aa..2ea1d6af 100644 --- a/applications/viewer/src/lib/ui/Tabs.svelte +++ b/applications/viewer/src/lib/ui/Tabs.svelte @@ -11,12 +11,23 @@ export interface Tab { import { page } from '$app/state'; - /* Underlined tabs. With `param`, the active tab is kept in the URL so it survives reloads and can be shared. */ + /* + * Underlined tabs. With `param`, the active tab is kept in the URL so it survives reloads and can be shared. + * With `link`, tabs are plain links to ?param=id, for pages whose server load depends on the tab. + */ let { tabs, active = $bindable(), - param = undefined - }: { tabs: Tab[]; active: string; param?: string } = $props(); + param = undefined, + link = false + }: { tabs: Tab[]; active: string; param?: string; link?: boolean } = $props(); + + function hrefFor(id: string) { + const u = new URL(page.url); + u.searchParams.set(param ?? 'tab', id); + u.searchParams.delete('page'); + return u.pathname + u.search; + } function select(id: string) { active = id; @@ -28,21 +39,24 @@ export interface Tab { } -
+
{#each tabs as t (t.id)} - + {#if on}{/if} + {/each}
diff --git a/applications/viewer/src/routes/+error.svelte b/applications/viewer/src/routes/+error.svelte new file mode 100644 index 00000000..9959d302 --- /dev/null +++ b/applications/viewer/src/routes/+error.svelte @@ -0,0 +1,25 @@ + + +{page.status === 404 ? 'Not found' : 'Error'} · Yaci Viewer + +
+
+ +
+
{page.status}
+

{page.status === 404 ? "We couldn't find that" : 'Something went wrong'}

+

+ {page.error?.message ?? + (page.status === 404 ? 'It may not be on this chain yet, or the store has not indexed it.' : 'Yaci Store returned an error.')} +

+
+ + Dashboard +
+
diff --git a/applications/viewer/src/routes/NetworkInfo.svelte b/applications/viewer/src/routes/NetworkInfo.svelte deleted file mode 100644 index 87e12c04..00000000 --- a/applications/viewer/src/routes/NetworkInfo.svelte +++ /dev/null @@ -1,172 +0,0 @@ - - -
-
-
-

Epoch

-

{network_info.epoch}

-
-
-

Current Block

-

{network_info.number}

-
-
-

Slot

- {#if network_info.epoch_slot} -

{network_info.epoch_slot}/{network_info.slots_per_epoch}

- {/if} -
-
-

Epoch Progress

- {#if network_info.epoch_progress} -
-
-
-
-

{network_info.epoch_progress}%

-
- {/if} -
-
-

Total Active Stake

- {#if network_info.total_active_stake} -
-

{formatAda(parseInt(network_info.total_active_stake))} ADA

-
- {:else} - - - {/if} -
-
-

Treasury

- {#if network_info.treasury} -
-

{formatAda(parseInt(network_info.treasury))} ADA

-
- {:else} - - - {/if} -
-
-

Reserves

- {#if network_info.reserves} -
-

{formatAda(parseInt(network_info.reserves))} ADA

-
- {:else} - - - {/if} -
-
-

Circulating Supply

- {#if network_info.circulating_supply} -
-

{formatAda(parseInt(network_info.circulating_supply))} ADA

-
- {:else} - - - {/if} -
-
-
- - diff --git a/applications/viewer/src/routes/addresses/+page.svelte b/applications/viewer/src/routes/addresses/+page.svelte index 29023754..f720d6af 100644 --- a/applications/viewer/src/routes/addresses/+page.svelte +++ b/applications/viewer/src/routes/addresses/+page.svelte @@ -1,96 +1,28 @@ -
-
-

Address Explorer

- -
-
-
-
- - -
- - {#if error} -
- - {error} -
- {/if} - -
- -
-
-
-
- -
-
- -
-

Address types

-
-

Supported address formats:

-
    -
  • Payment addresses (addr1..., addr_test1...)
  • -
  • Stake addresses (stake1..., stake_test1...)
  • -
  • Hex-encoded addresses
  • -
-
-
-
-
-
-
+Addresses · Yaci Viewer + + diff --git a/applications/viewer/src/routes/addresses/[address]/+page.server.ts b/applications/viewer/src/routes/addresses/[address]/+page.server.ts index 5a234cc5..31717dbb 100644 --- a/applications/viewer/src/routes/addresses/[address]/+page.server.ts +++ b/applications/viewer/src/routes/addresses/[address]/+page.server.ts @@ -1,146 +1,60 @@ -import { error, redirect } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; +import { api, paging, tryApi } from '$lib/api/client'; +import { stakeAddressOf } from '$lib/bech32'; +import type { Amount, Utxo } from '$lib/tx/types'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; -const DEFAULT_COUNT = 15; -const DEFAULT_ORDER = 'desc'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; -interface Amount { - unit: string; - quantity: number; -} - -interface Utxo { - tx_hash: string; - output_index: number; - address: string; - amount: Array<{ unit: string; quantity: string }>; - inline_datum?: string; - data_hash?: string; - script_ref?: string; -} - -interface Transaction { - tx_hash: string; - block_height: number; - block_time: number; -} +const TABS = ['txs', 'utxos', 'tokens'] as const; +/* + * Balance comes from /balance when the store aggregates balances, otherwise /amounts. Only the open tab's list is + * fetched, so busy addresses stay quick. Yaci Store pages are 1-based. + */ export const load: PageServerLoad = async ({ params, url, fetch }) => { - const address = params.address; - - // Redirect stake addresses to dedicated stake page - if (address.startsWith('stake')) { - throw redirect(302, `/stake/${address}`); - } - - const page = parseInt(url.searchParams.get('page') || '1'); - const count = parseInt(url.searchParams.get('count') || String(DEFAULT_COUNT)); - const order = url.searchParams.get('order') || DEFAULT_ORDER; - const tab = url.searchParams.get('tab') || 'utxos'; - const balanceApi = url.searchParams.get('balanceApi') || 'auto'; - - if (!address) { - throw error(400, 'Address is required'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { - throw error(400, 'Invalid count number'); - } - if (order !== 'asc' && order !== 'desc') { - throw error(400, 'Invalid order parameter'); - } - - const fetchAmounts = async (): Promise<{ data: Amount[]; balanceApiUsed: 'balance' | 'amounts' }> => { - // Try /balance first if auto or balance - if (balanceApi === 'auto' || balanceApi === 'balance') { - try { - const balanceUrl = `${INDEXER_BASE_URL}/addresses/${address}/balance`; - const response = await fetch(balanceUrl); - if (response.ok) { - const json = await response.json(); - const data: Amount[] = json.amounts || []; - return { data, balanceApiUsed: 'balance' }; - } - } catch { - // Fall through to /amounts - } - } - - // Fall back to /amounts - try { - const amountsUrl = `${INDEXER_BASE_URL}/addresses/${address}/amounts`; - const response = await fetch(amountsUrl); - if (response.ok) { - const data: Amount[] = await response.json(); - return { data, balanceApiUsed: 'amounts' }; - } - } catch { - // Return empty - } - - return { data: [], balanceApiUsed: 'amounts' }; - }; - - const fetchUtxos = async () => { - const apiUrl = `${INDEXER_BASE_URL}/addresses/${address}/utxos?page=${page - 1}&count=${count}&order=${order}`; - try { - const response = await fetch(apiUrl); - if (!response.ok) { - return { data: [], error: `UTXOs: ${response.statusText}` }; - } - const data: Utxo[] = await response.json(); - // Transform amount entries: map {unit, quantity} to {asset_name, quantity} - // so they are compatible with the AmountBadges component - const transformed = data.map(utxo => ({ - ...utxo, - amount: utxo.amount.map(a => ({ asset_name: a.unit, quantity: a.quantity, unit: a.unit })) - })); - return { data: transformed, error: null }; - } catch (err) { - return { data: [], error: 'Failed to fetch UTXOs' }; - } - }; - - const fetchTransactions = async () => { - const apiUrl = `${INDEXER_BASE_URL}/addresses/${address}/transactions?page=${page - 1}&count=${count}&order=${order}`; - try { - const response = await fetch(apiUrl); - if (!response.ok) { - return { data: [], error: `Transactions: ${response.statusText}` }; - } - const data: Transaction[] = await response.json(); - return { data, error: null }; - } catch (err) { - return { data: [], error: 'Failed to fetch transactions' }; - } - }; - - const [amountsResult, utxosResult, transactionsResult] = await Promise.all([ - fetchAmounts(), - fetchUtxos(), - fetchTransactions() - ]); - - const errors = [ - utxosResult.error, - transactionsResult.error - ].filter(e => e !== null); - const combinedError = errors.length > 0 ? errors.join('; ') : null; - - return { - address, - amounts: amountsResult.data, - balanceApiUsed: amountsResult.balanceApiUsed, - utxos: utxosResult.data, - transactions: transactionsResult.data, - currentPage: page, - count, - order, - tab, - error: combinedError - }; + const address = params.address.trim(); + if (address.startsWith('stake')) redirect(302, `/stake/${address}`); + + const tab = (TABS as readonly string[]).includes(url.searchParams.get('tab') ?? '') ? (url.searchParams.get('tab') as (typeof TABS)[number]) : 'txs'; + const { page, count } = paging(url, 20); + const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; + const stake = stakeAddressOf(address); + + const balance = async (): Promise => { + const b = await tryApi(`/addresses/${address}/balance`, { fetch }); + if (b?.amounts) return b.amounts; + return tryApi(`/addresses/${address}/amounts`, { fetch }); + }; + + const list = async (): Promise<{ rows: Any[]; error: string | null }> => { + if (tab === 'tokens') return { rows: [], error: null }; + try { + const rows = await api(`/addresses/${address}/${tab === 'txs' ? 'transactions' : 'utxos'}`, { + fetch, + params: { page, count, order }, + timeoutMs: 25000 + }); + return { rows: rows ?? [], error: null }; + } catch (e) { + return { rows: [], error: (e as Error).message }; + } + }; + + const [amounts, rows, account] = await Promise.all([balance(), list(), stake ? tryApi(`/accounts/${stake}`, { fetch }) : null]); + + return { + address, + stake, + account: account as { controlled_amount?: number; withdrawable_amount?: number; pool_id?: string | null } | null, + amounts: amounts ?? [], + balanceKnown: amounts !== null, + tab, + page, + count, + order, + rows: rows.rows as Any[] as (Utxo & { block_height?: number; block_time?: number })[], + listError: rows.error + }; }; diff --git a/applications/viewer/src/routes/addresses/[address]/+page.svelte b/applications/viewer/src/routes/addresses/[address]/+page.svelte index 570ef041..4416dec2 100644 --- a/applications/viewer/src/routes/addresses/[address]/+page.svelte +++ b/applications/viewer/src/routes/addresses/[address]/+page.svelte @@ -1,397 +1,169 @@ -
-
- -
-
-

- Address -

-
- -
-
- -
-
-
- ADA Balance -
-
- {lovelaceToAda(adaBalance, 2)} ADA -
-
- -
-
- Token Types -
-
- {tokenCount} -
-
- -
-
- UTXOs -
-
- {utxos.length}{utxos.length === count ? '+' : ''} -
-
-
-
- - {#if errorMsg} -
- -
- {/if} - - -
-
- - - - - -
-
- - -
- {#if loading} -
- -
- {/if} - - - {#if activeTabIndex === UTXOS_TAB} -
- {#if utxos.length === 0} - - {:else} -
- - - - - - - - - - {#each utxos as utxo (utxo.tx_hash + '#' + utxo.output_index)} - - - - - - {/each} - -
Tx Hash # IndexAmountsDetails
- - {truncate(utxo.tx_hash, 20, '...')} - - #{utxo.output_index} - {#if utxo.data_hash} -
- Datum: {truncate(utxo.data_hash, 20, '...')} -
- {/if} -
-
- -
-
- {#if utxo.inline_datum || utxo.script_ref} - - {:else} - - - {/if} -
-
- - -
- - Page {currentPage} - -
- {/if} -
- {/if} - - - {#if activeTabIndex === TXS_TAB} -
- {#if transactions.length === 0} - - {:else} -
- - - - - - - - - - {#each transactions as tx (tx.tx_hash)} - - - - - - {/each} - -
Tx HashBlockTime
- - {truncate(tx.tx_hash, 25, '...')} - - - - {tx.block_height} - - - {getDate(tx.block_time)} -
-
- - -
- - Page {currentPage} - -
- {/if} -
- {/if} - - - {#if activeTabIndex === TOKENS_TAB} -
- {#if tokens.length === 0} - - {:else} -
- - - - - - - - - - {#each tokens as token (token.unit)} - {@const parsed = parseUnit(token.unit)} - - - - - - {/each} - -
Policy IDAsset NameQuantity
- - {truncate(parsed.policyId, 20, '...')} - - - - {parsed.assetNameUtf8 || '(empty)'} - - - {token.quantity.toLocaleString()} -
-
- {/if} -
- {/if} -
-
+Address {shorten(data.address, 10, 6)} · Yaci Viewer + + + {#snippet icon()}{#if info.script}{:else}{/if}{/snippet} +
+ {data.address} + +
+ {#snippet actions()} + {#if info.script}Smart contract{/if} + {#if info.network}{info.network}{/if} + {/snippet} +
+ +
+
+
+
Balance
+
+ {#if data.balanceKnown}{:else}—{/if} +
+
+ {tokens.length ? `${num(tokens.length)} native token${tokens.length === 1 ? '' : 's'}` : 'No native tokens'} +
+
+
+
Stake account
+ {#if data.stake} + {shorten(data.stake, 12, 8)} + {#if data.account?.controlled_amount != null} +
controls
+ {/if} + {:else} +
{info.era === 'byron' ? 'Byron addresses cannot stake.' : 'This address has no stake part.'}
+ {/if} +
+
+
Delegation
+ {#if data.account?.pool_id} + {shorten(data.account.pool_id, 12, 8)} +
delegated stake pool
+ {:else} +
{data.stake ? 'Not delegated' : '—'}
+ {/if} +
- +
+
+
+ +
+ {#if data.tab !== 'tokens'} + + {:else} + + {/if} +
+ + {#if data.listError} + + {:else if data.tab === 'txs'} + r.tx_hash} + rowHref={(r) => `/transactions/${r.tx_hash}`} + emptyTitle="No transactions" + emptyText="Nothing has been sent to or from this address yet." + > + {#snippet cell(r, key)} + {#if key === 'hash'}{shorten(r.tx_hash, 14, 10)} + {:else if key === 'block'}#{num(r.block_height)} + {:else if key === 'age'}{/if} + {/snippet} + + + {:else if data.tab === 'utxos'} + r.tx_hash + r.output_index} + emptyTitle="No unspent outputs" + emptyText="Everything sent here has been spent." + > + {#snippet cell(r, key)} + {#if key === 'ref'}{shorten(r.tx_hash, 10, 6)}#{r.output_index} + {:else if key === 'value'} + {:else if key === 'assets'} + {@const a = assetsOf(r.amount)} +
{#each a.slice(0, 3) as x (x.unit)}{/each}{#if a.length > 3}+{a.length - 3}{/if}{#if !a.length}—{/if}
+ {:else if key === 'datum'} + {#if r.inline_datum} + {:else if r.data_hash}Hash + {:else}—{/if} + {/if} + {/snippet} +
+ + {:else} + {#if !tokens.length} + + {:else} + + {/if} + {/if} +
- + diff --git a/applications/viewer/src/routes/blocks/+page.server.ts b/applications/viewer/src/routes/blocks/+page.server.ts index e1543269..a9e08944 100644 --- a/applications/viewer/src/routes/blocks/+page.server.ts +++ b/applications/viewer/src/routes/blocks/+page.server.ts @@ -1,34 +1,25 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { api, paging } from '$lib/api/client'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 1; - const count = 15; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/blocks?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const { total, total_pages, blocks } = data; - console.log(blocks); - - if (res.ok) { - return { - blocks, - total: total, - total_pages: total_pages, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch blocks.' } - }; - -} +export const load: PageServerLoad = async ({ url, fetch }) => { + const { page, count } = paging(url, 20); + try { + const [data, params] = await Promise.all([ + api('/blocks', { fetch, params: { page, count } }), + api('/epochs/latest/parameters', { fetch }).catch(() => null) + ]); + return { + blocks: (data?.blocks ?? []) as Any[], + totalPages: (data?.total_pages as number) || null, + page, + count, + maxBlockSize: Number(params?.max_block_size) || 90112 + }; + } catch (e) { + error(503, (e as Error).message); + } +}; diff --git a/applications/viewer/src/routes/blocks/+page.svelte b/applications/viewer/src/routes/blocks/+page.svelte index 473c930e..1e6b3cb2 100644 --- a/applications/viewer/src/routes/blocks/+page.svelte +++ b/applications/viewer/src/routes/blocks/+page.svelte @@ -1,268 +1,74 @@ -
-
- - {#if showToast} -
- - Copied to clipboard -
- {/if} - -
-
-

Recent Blocks

-
- -
- - -
-
- - {#if !isErrorData(data) && data.blocks && data.blocks.length > 0} -
-
- - - -
-
- {/if} - - {#if isErrorData(data)} -
- - {data.body?.error || 'Failed to load blocks. Please check your connection to the Yaci node.'} -
- {:else if !data.blocks || data.blocks.length === 0} -
-

No blocks found

-

Waiting for new blocks to be produced...

-
- {:else} -
- - {#if loading} -
- -
- {/if} - - - - - -
- {#each data.blocks as block (block.number)} -
-
- - -
- -
-
-
Slot
-
{block.slot}
-
-
-
# of Txs
-
{block.tx_count}
-
-
- -
-
- Pool: {truncate(block.slot_leader, 20)} -
-
- Size: {(block.size / 1024).toFixed(2)} KB -
-
-
- {/each} -
-
- -
-
- - - -
-
- {/if} -
+Blocks · Yaci Viewer + + + {#snippet icon()}{/snippet} + {#if chain.tip}Tip #{num(chain.tip.number)} · epoch {chain.tip.epoch}{#if data.page === 1 && chain.status === 'live'} · new blocks appear live{/if}{/if} + + +
+ b.number} rowHref={(b) => `/blocks/${b.number}`} fresh={isNew} emptyTitle="No blocks yet"> + {#snippet cell(b, key)} + {#if key === 'number'} + + + #{num(b.number)} + + {:else if key === 'age'} + + {:else if key === 'epoch'} + {b.epoch} / {num(b.slot)} + {:else if key === 'txs'} + {b.txCount} + {:else if key === 'size'} +
+
+
+
+ {b.size >= 1024 ? `${(b.size / 1024).toFixed(1)} KB` : `${b.size} B`} +
+ {:else if key === 'fees'} + {#if b.fees}{:else}—{/if} + {:else if key === 'leader'} + {#if b.leader}{shorten(b.leader, 8, 6)}{/if} + {/if} + {/snippet} +
+
- - diff --git a/applications/viewer/src/routes/blocks/Blocks.svelte b/applications/viewer/src/routes/blocks/Blocks.svelte deleted file mode 100644 index a071d24f..00000000 --- a/applications/viewer/src/routes/blocks/Blocks.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - diff --git a/applications/viewer/src/routes/blocks/BlocksAggr.svelte b/applications/viewer/src/routes/blocks/BlocksAggr.svelte deleted file mode 100644 index 2e4ffcb8..00000000 --- a/applications/viewer/src/routes/blocks/BlocksAggr.svelte +++ /dev/null @@ -1,51 +0,0 @@ - - -
-

- Last 5 mins -

-
-
-

- Average Block Size -

-
-
{aggregateData.avgBlockSize / 1000} KB
-
-
-
-

- Block Size / Capacity (%) -

-
-
{aggregateData.blockSizePerct}%
-
-
-
-

- Transactions / sec -

-
-
{formatDecimal(aggregateData.tps)}
-
-
-
-

- Transaction Outputs / sec -

-
-
{formatDecimal(aggregateData.tops)}
-
-
-
-
- - diff --git a/applications/viewer/src/routes/blocks/RecentBlocks.svelte b/applications/viewer/src/routes/blocks/RecentBlocks.svelte deleted file mode 100644 index 3e74bb31..00000000 --- a/applications/viewer/src/routes/blocks/RecentBlocks.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - -
-

Recent Blocks

-
- {#each [...blocks].slice(0, noOfBlocks) as block} - {#if block.number} - -
-

- Block - {block.number} -

-

Slot {block.slot}

-

Txs {block.ntx}

-

Size {(block.size / 1024).toFixed(2)} kb

-

Pool {block.slot_leader}

-

- - {getDate(block.block_time)}, {moment(block.block_time * 1000).fromNow()} - -

-
- - {/if} - {/each} -
-
- - - diff --git a/applications/viewer/src/routes/blocks/[number]/+page.server.ts b/applications/viewer/src/routes/blocks/[number]/+page.server.ts index 1abfd1c2..ddebe884 100644 --- a/applications/viewer/src/routes/blocks/[number]/+page.server.ts +++ b/applications/viewer/src/routes/blocks/[number]/+page.server.ts @@ -1,27 +1,17 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { tryApi } from '$lib/api/client'; +import { must } from '$lib/api/load'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params}) => { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const blockApiUrl = `${INDEXER_BASE_URL}/blocks/${params.number}`; - const blockTxsApiUrl = `${INDEXER_BASE_URL}/blocks/${params.number}/txs`; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - const res = await fetch(blockApiUrl); - const resTxs = await fetch(blockTxsApiUrl); - const block = await res.json(); - const txs = await resTxs.json(); - - console.log(block); - - if (res.ok) { - return { - block, - txs, - }; - } - - return { - status: 404, - body: { error: 'Can not fetch block.' } - }; -} +/* A block by number or hash, its transactions, and the max block size for the fill gauge. */ +export const load: PageServerLoad = async ({ params, fetch }) => { + const id = params.number.trim(); + const block = await must(`/blocks/${id}`, { fetch }, `No block ${id} on this chain.`); + const [txs, protocol] = await Promise.all([ + tryApi(`/blocks/${block.number}/txs`, { fetch }), + tryApi('/epochs/latest/parameters', { fetch }) + ]); + return { block, txs: txs ?? [], maxBlockSize: Number(protocol?.max_block_size) || 90112 }; +}; diff --git a/applications/viewer/src/routes/blocks/[number]/+page.svelte b/applications/viewer/src/routes/blocks/[number]/+page.svelte index 7e66b519..d2798af4 100644 --- a/applications/viewer/src/routes/blocks/[number]/+page.svelte +++ b/applications/viewer/src/routes/blocks/[number]/+page.svelte @@ -1,206 +1,127 @@ - -
-
- -
-
-
-

Block Hash

-
- {block.hash} - -
-
+ +Block #{num(b.number)} · Yaci Viewer -
-

Previous Hash

-
- - {block.previous_block ? block.previous_block : 'Genesis Block'} - - {#if block.previous_block} - - {/if} -
-
-
-
+ + {#snippet icon()}{/snippet} + · {dateTime(b.time)} + {#snippet actions()} + Prev + Next + {/snippet} + -
-
-
-
Number
-
{block.number}
-
-
-
Epoch
-
{block.epoch}
-
-
-
Slot
-
{block.slot}
-
-
-
Era
-
{block.era}
-
-
+
+ +
+ + {b.epoch} + {num(b.slot)}{#if b.epoch_slot != null} · {num(b.epoch_slot)} in epoch{/if} + {b.tx_count} + + + {#if b.previous_block}{:else}—{/if} + {b.protocol_version ?? '—'} + +
-
-
-
# of Txs
-
{block.tx_count}
-
-
-
Size
-
{block.size} bytes
-
-
-
Protocol Version
-
{block.protocol_version}
-
-
-
Timestamp
-
- {getDate(block.time)} - {moment(block.time * 1000).fromNow()} -
-
-
-
+ + {#if advanced} +
+ + + + + {b.op_cert_counter ?? '—'} / {b.op_cert_kes_period ?? '—'} + +
+ {/if} + - {#if txs && txs.length > 0} -
-

Transactions in this Block

-
-
- - - - - - - - - - - {#each txs as tx} - - - - - - - {/each} - -
Tx HashTotal Output (ADA)Fee (ADA)Output Addresses
- - {truncate(tx.tx_hash, 20, "...")} - - - {lovelaceToAda(tx.total_output)} - - {lovelaceToAda(tx.fee)} - -
- {#each tx.output_addresses as address} -
- {truncate(address, 25, "...")} -
- {/each} -
-
-
-
-
- {/if} +
+ + {#snippet icon()}{/snippet} +
+ + {confirmations > 999 ? '999+' : confirmations} + +

+ {#if isTip}This is the newest block. It is the chain tip.{:else if confirmations < 100}{num(confirmations)} block{confirmations === 1 ? '' : 's'} built on top. Settles after a few more.{:else}{num(confirmations)} blocks on top. Final for all practical purposes.{/if} +

+
+
+ +
+
{(Number(b.size) / 1024).toFixed(2)} KB
+
{(fill * 100).toFixed(2)}% of {(data.maxBlockSize / 1024).toFixed(0)} KB
+
+
+
+
+
-
+
+
+
+ +

Transactions · {data.txs.length}

+
+
+ t.tx_hash} rowHref={(t) => `/transactions/${t.tx_hash}`} emptyTitle="No transactions in this block" emptyText="Empty blocks are normal on a quiet devnet."> + {#snippet cell(t, key)} + {#if key === 'hash'} + {shorten(t.tx_hash, 12, 10)} + {:else if key === 'to'} + {@const addrs = t.output_addresses ?? []} + {shorten(addrs[0], 12, 6)}{#if addrs.length > 1}+{addrs.length - 1}{/if} + {:else if key === 'fee'} + + {:else if key === 'out'} + + {/if} + {/snippet} +
- -{#if txs.length > 0} -
-
- - - - - - - - - - - {#each txs as tx, index} - - - - - - - {/each} - -
Tx HashTotal Output (Ada)Fee (Ada)Output Addresses
- {tx.tx_hash} - {lovelaceToAda(tx.total_output)}{lovelaceToAda(tx.fee)} - {#each tx.output_addresses as address} -
- {/each} -
-
-
-{/if} diff --git a/applications/viewer/src/routes/blocks/store.js b/applications/viewer/src/routes/blocks/store.js deleted file mode 100644 index bf155503..00000000 --- a/applications/viewer/src/routes/blocks/store.js +++ /dev/null @@ -1,92 +0,0 @@ -import {writable} from 'svelte/store'; -import { env } from '$env/dynamic/public'; - -export const blocksStore = writable({}); -export const aggregateStore = writable({}); -export const recentTxStore = writable({}); - -export let blocksCache = []; -export let txCache = []; - -const socket = new WebSocket(env["PUBLIC_INDEXER_WS_URL"]); - -// Connection opened -socket.addEventListener('open', function (event) { - console.log("It's open !!!"); - keepAlive(30000); -}); - -socket.addEventListener('close', function (event) { - console.log("It's close. try to reopen !!!"); -}); - -// Listen for messages -socket.addEventListener('message', function (event) { - try { - const data = JSON.parse(event.data); - if (data.res_type == 'INIT_DATA') { - console.log("Inside init data") - data.blocks.forEach(value => blocksStore.set(value)); - data.recent_txs.forEach(value => recentTxStore.set(value)); - if (data.aggregateData) - aggregateStore.set(data.aggregateData); - } else if (data.res_type == 'BLOCK_DATA') { - blocksStore.set(data); - } else if (data.res_type == 'AGGR_DATA') { - aggregateStore.set(data); - } - else if (data.res_type == 'RECENT_TXS_DATA') { - console.log(data); - data.recent_txs.forEach(tx => recentTxStore.set(tx)); - } - console.log(data); - - } catch (error) { - - } -}); - -blocksStore.subscribe(currentMessage => { - if (currentMessage && currentMessage.number != null) { - blocksCache = [currentMessage, ...blocksCache.filter(b => b.number !== currentMessage.number)].slice(0, 30); - } -}); - -recentTxStore.subscribe(currentMessage => { - if (currentMessage && currentMessage.hash) { - txCache = [currentMessage, ...txCache.filter(t => t.hash !== currentMessage.hash)].slice(0, 30); - } -}); - -export const sendMessage = (message) => { - if (socket.readyState <= 1) { - socket.send(message); - } -} - -export const subscribeBlocks = () => { - blocksStore.subscribe(); -} - -export const subscribeAggregates = () => { - aggregateStore.subscribe(); -} - -let timerId = 0; -function keepAlive(timeout = 20000) { - sendMessage('PING') - timerId = setTimeout(keepAlive, timeout); -} - -function cancelKeepAlive() { - if (timerId) { - clearTimeout(timerId); - } -} - -// export default { -// messageStore, -// subscribe, -// // subscribe: messageStore.subscribe, -// sendMessage -// } diff --git a/applications/viewer/src/routes/stake/[address]/+page.server.ts b/applications/viewer/src/routes/stake/[address]/+page.server.ts index fd53382b..740b75f5 100644 --- a/applications/viewer/src/routes/stake/[address]/+page.server.ts +++ b/applications/viewer/src/routes/stake/[address]/+page.server.ts @@ -1,53 +1,38 @@ import { error } from '@sveltejs/kit'; +import { api, paging, tryApi } from '$lib/api/client'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; -const DEFAULT_COUNT = 15; -const DEFAULT_ORDER = 'desc'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; -export const load: PageServerLoad = async ({ params, url, fetch }) => { - const address = params.address; - - if (!address || !address.startsWith('stake')) { - throw error(400, 'Invalid stake address'); - } +const TABS = { rewards: 'rewards', withdrawals: 'withdrawals', other: 'reward_rest' } as const; - const page = parseInt(url.searchParams.get('page') || '1'); - const count = parseInt(url.searchParams.get('count') || String(DEFAULT_COUNT)); - const order = url.searchParams.get('order') || DEFAULT_ORDER; - const tab = url.searchParams.get('tab') || 'rewards'; +/* A stake account: what it controls, where it delegates (pool and DRep), and its rewards history. */ +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const address = params.address.trim(); + if (!/^(stake|stake_test)1/.test(address)) error(404, 'That is not a stake address.'); - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { - throw error(400, 'Invalid count number'); - } - if (order !== 'asc' && order !== 'desc') { - throw error(400, 'Invalid order parameter'); - } + const tab = (url.searchParams.get('tab') ?? 'rewards') as keyof typeof TABS; + const endpoint = TABS[tab] ?? TABS.rewards; + const { page, count } = paging(url, 20); - const [rewards, rewardRest, withdrawals] = await Promise.all([ - fetch(`${INDEXER_BASE_URL}/accounts/${address}/rewards?page=${page - 1}&count=${count}&order=${order}`) - .then(r => r.ok ? r.json() : []) - .catch(() => []), - fetch(`${INDEXER_BASE_URL}/accounts/${address}/reward_rest?page=${page - 1}&count=${count}&order=${order}`) - .then(r => r.ok ? r.json() : []) - .catch(() => []), - fetch(`${INDEXER_BASE_URL}/accounts/${address}/withdrawals?page=${page - 1}&count=${count}&order=${order}`) - .then(r => r.ok ? r.json() : []) - .catch(() => []), - ]); + const [account, drep, rows] = await Promise.all([ + tryApi(`/accounts/${address}`, { fetch }), + tryApi(`/governance/delegation-votes/address/${address}`, { fetch, params: { page: 1, count: 1, order: 'desc' } }), + api(`/accounts/${address}/${endpoint}`, { fetch, params: { page, count, order: 'desc' } }).then( + (r) => ({ rows: r ?? [], error: null as string | null }), + (e) => ({ rows: [] as Any[], error: (e as Error).message }) + ) + ]); - return { - address, - rewards, - rewardRest, - withdrawals, - currentPage: page, - count, - order, - tab - }; + return { + address, + account: account as { controlled_amount?: number; withdrawable_amount?: number; pool_id?: string | null } | null, + drep: (drep?.[0] ?? null) as { drep_id?: string; drep_hash?: string; drep_type?: string; block_time?: number } | null, + tab: tab in TABS ? tab : 'rewards', + page, + count, + rows: rows.rows, + listError: rows.error + }; }; diff --git a/applications/viewer/src/routes/stake/[address]/+page.svelte b/applications/viewer/src/routes/stake/[address]/+page.svelte index 549473fc..a1fd31f7 100644 --- a/applications/viewer/src/routes/stake/[address]/+page.svelte +++ b/applications/viewer/src/routes/stake/[address]/+page.svelte @@ -1,299 +1,111 @@ -
-
- -
-
-

- Stake Address -

-
- -
-
-
- - -
-
- - - - - -
-
- - -
- {#if loading} -
- -
- {/if} - - - {#if activeTabIndex === REWARDS_TAB} -
- {#if rewards.length === 0} - - {:else} -
- - - - - - - - - - - {#each rewards as reward} - - - - - - - {/each} - -
EpochPool IDTypeAmount
- {reward.epoch} - - - {truncate(reward.pool_id, 20, '...')} - - - {#if reward.type === 'member'} - MEMBER - {:else if reward.type === 'leader'} - LEADER - {:else} - {reward.type} - {/if} - - {lovelaceToAda(reward.amount, 6)} ADA -
-
- {/if} -
- {/if} - - - {#if activeTabIndex === OTHER_REWARDS_TAB} -
- {#if rewardRest.length === 0} - - {:else} -
- - - - - - - - - - {#each rewardRest as reward} - - - - - - {/each} - -
EpochTypeAmount
- {reward.epoch} - - {reward.type} - - {lovelaceToAda(reward.amount, 6)} ADA -
-
- {/if} -
- {/if} - - - {#if activeTabIndex === WITHDRAWALS_TAB} -
- {#if withdrawals.length === 0} - - {:else} -
- - - - - - - - - - - {#each withdrawals as w} - - - - - - - {/each} - -
Tx HashEpochBlockAmount
- - {truncate(w.tx_hash, 20, '...')} - - - {w.epoch} - - - {w.block_number} - - - {lovelaceToAda(w.amount, 6)} ADA -
-
- {/if} -
- {/if} - - - {#if activeData.length > 0} -
- - Page {currentPage} - -
- {/if} -
-
+Stake {shorten(data.address, 10, 6)} · Yaci Viewer + + + {#snippet icon()}{/snippet} +
+ {data.address} + +
+ {#snippet actions()} + {#if data.account?.pool_id}Delegating{:else if data.account}Not delegated{/if} + {/snippet} +
+ +
+
+
+
Controlled stake
+
+ {#if data.account}{:else}—{/if} +
+
+ Withdrawable rewards {#if data.account}{:else}—{/if} +
+
+
+
Stake pool
+ {#if data.account?.pool_id} + {shorten(data.account.pool_id, 12, 8)} +
earns staking rewards
+ {:else} +
Not delegated to a pool
+ {/if} +
+
+
Voting power (DRep)
+ {#if drepLabel} +
{drepLabel}
+ {:else if data.drep?.drep_id} + {shorten(data.drep.drep_id, 12, 8)} +
since
+ {:else} +
Not delegated to a DRep
+ {/if} +
- +
+ + {#if data.listError} + + {:else if data.tab === 'withdrawals'} + r.tx_hash} + rowHref={(r) => `/transactions/${r.tx_hash}`} + emptyTitle="No withdrawals" + > + {#snippet cell(r, key)} + {#if key === 'tx'}{shorten(r.tx_hash, 12, 8)} + {:else if key === 'epoch'}{r.epoch} + {:else if key === 'age'} + {:else}{/if} + {/snippet} + + + {:else} + `${r.epoch}-${r.type}-${r.pool_id ?? ''}-${r.amount}`} + emptyTitle={data.tab === 'other' ? 'No other rewards' : 'No rewards yet'} + emptyText={data.tab === 'other' ? 'Refunds, treasury and reserve payouts show up here.' : 'Rewards arrive two epochs after delegating.'} + > + {#snippet cell(r, key)} + {#if key === 'epoch'}{r.epoch ?? r.earned_epoch} + {:else if key === 'type'}{r.type} + {:else if key === 'pool'}{#if r.pool_id}{shorten(r.pool_id, 10, 6)}{:else}—{/if} + {:else}{/if} + {/snippet} + + + {/if} +
diff --git a/applications/viewer/src/routes/transactions/+page.server.ts b/applications/viewer/src/routes/transactions/+page.server.ts index 62007eee..c96c959f 100644 --- a/applications/viewer/src/routes/transactions/+page.server.ts +++ b/applications/viewer/src/routes/transactions/+page.server.ts @@ -1,34 +1,21 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { api, paging } from '$lib/api/client'; +import { error } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 1; - const count = 15; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/txs?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const {total, total_pages, transaction_summaries} = data; - console.log(transaction_summaries); - - if (res.ok) { - return { - txs : transaction_summaries, - total: total, - total_pages: total_pages, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch transactions.' } - }; - -} +export const load: PageServerLoad = async ({ url, fetch }) => { + const { page, count } = paging(url, 20); + try { + const data = await api('/txs', { fetch, params: { page, count } }); + return { + txs: (data?.transaction_summaries ?? []) as Any[], + totalPages: (data?.total_pages as number) || null, + page, + count + }; + } catch (e) { + error(503, (e as Error).message); + } +}; diff --git a/applications/viewer/src/routes/transactions/+page.svelte b/applications/viewer/src/routes/transactions/+page.svelte index ff90b021..a0905a54 100644 --- a/applications/viewer/src/routes/transactions/+page.svelte +++ b/applications/viewer/src/routes/transactions/+page.svelte @@ -1,292 +1,66 @@ -
-
- - {#if showToast} -
- - Copied to clipboard -
- {/if} - -
-
-

Recent Transactions

-
- -
- - -
-
- - {#if !isErrorData(data) && data.txs && data.txs.length > 0} -
-
- - - -
-
- {/if} - - {#if isErrorData(data)} -
- - {data.body?.error || 'Failed to load transactions. Please check your connection to the Yaci node.'} -
- {:else if !data.txs || data.txs.length === 0} -
-

No transactions found

-

Waiting for new transactions to be produced...

-
- {:else} -
- - {#if loading} -
- -
- {/if} - - - -
- {#each data.txs as tx (tx.tx_hash)} -
-
-
-
Tx Hash
- - {tx.tx_hash} - -
- -
- -
-
-
{@html iconCube} Block
- {tx.block_number} - (Slot {tx.slot}) -
-
-
-
{@html iconCardano}
Output -
-
{formatAda(tx.total_output)}
-
-
- -
-
-
{@html iconCardano}
- Fee: {formatAda(tx.fee)} ADA -
-
-
- {/each} -
-
- -
-
- - - -
-
- {/if} -
+Transactions · Yaci Viewer + + + {#snippet icon()}{/snippet} + Newest first{#if data.page === 1} · new transactions appear as blocks land{/if} + + +
+ t.hash} rowHref={(t) => `/transactions/${t.hash}`} fresh={isNew} emptyTitle="No transactions yet" emptyText="Send ADA to an address on your devnet and it shows up here."> + {#snippet cell(t, key)} + {#if key === 'hash'} + + + {shorten(t.hash, 12, 10)} + + {:else if key === 'block'} + {#if t.block < 0}Genesis{:else} + #{num(t.block)} + {#if blockTime.get(t.block)}
{/if} + {/if} + {:else if key === 'to'} + {shorten(t.outputAddresses[0], 12, 6)}{#if t.outputAddresses.length > 1}+{t.outputAddresses.length - 1}{/if} + {:else if key === 'fee'} + {#if t.fee != null}{:else}—{/if} + {:else if key === 'out'} + + {/if} + {/snippet} +
+
- - \ No newline at end of file diff --git a/applications/viewer/src/routes/transactions/RecentTxs.svelte b/applications/viewer/src/routes/transactions/RecentTxs.svelte deleted file mode 100644 index deffe6b0..00000000 --- a/applications/viewer/src/routes/transactions/RecentTxs.svelte +++ /dev/null @@ -1,49 +0,0 @@ - -
-

Transactions

-
- {#each [...txs].slice(0, noOfTxs) as tx} - {#if tx.hash} - -
-

- Tx Hash - - {truncate(tx.hash, 38, '...')} - - - -

-

- Block / Slot - {tx.block} / {tx.slot} -

-

- Output Addresses -

-
- {#each tx.output_addresses as address} -

{truncate(address, 60, "...")}

- {/each} -
-

- Output - {lovelaceToAda(tx.output, 2)} Ada -

-
- {/if} - {/each} - -
-
- - diff --git a/applications/viewer/src/routes/transactions/Transactions.svelte b/applications/viewer/src/routes/transactions/Transactions.svelte deleted file mode 100644 index 49f389af..00000000 --- a/applications/viewer/src/routes/transactions/Transactions.svelte +++ /dev/null @@ -1,31 +0,0 @@ - - - diff --git a/applications/viewer/src/routes/transactions/TxSearch.js b/applications/viewer/src/routes/transactions/TxSearch.js deleted file mode 100644 index c22b7be9..00000000 --- a/applications/viewer/src/routes/transactions/TxSearch.js +++ /dev/null @@ -1,17 +0,0 @@ -import {redirect} from "@sveltejs/kit"; - -/** @type {import('./$types').Actions} */ -export const actions = { - login: async ({ cookies, request, url }) => { - const data = await request.formData(); - const tx_hash = data.get('txhash'); - const redirectUrl = `/transactions/${tx_hash}`; - console.log(redirectUrl) - - throw redirect(301, redirectUrl); - - }, - register: async (event) => { - // TODO register the user - } -}; diff --git a/applications/viewer/src/routes/transactions/TxSearch.svelte b/applications/viewer/src/routes/transactions/TxSearch.svelte deleted file mode 100644 index fa5318b9..00000000 --- a/applications/viewer/src/routes/transactions/TxSearch.svelte +++ /dev/null @@ -1,13 +0,0 @@ - -
- Transaction - - -
diff --git a/applications/viewer/src/routes/transactions/[id]/+page.server.ts b/applications/viewer/src/routes/transactions/[id]/+page.server.ts index 8418ce92..fa42623d 100644 --- a/applications/viewer/src/routes/transactions/[id]/+page.server.ts +++ b/applications/viewer/src/routes/transactions/[id]/+page.server.ts @@ -1,30 +1,49 @@ +import { tryApi } from '$lib/api/client'; +import { must } from '$lib/api/load'; +import type { Utxo } from '$lib/tx/types'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async ({ params, fetch }) => { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const txHash = params.id; - - const [tx, scripts, metadata, withdrawals, mints] = await Promise.all([ - fetch(`${INDEXER_BASE_URL}/txs/${txHash}`).then(r => r.ok ? r.json() : null), - fetch(`${INDEXER_BASE_URL}/txs/${txHash}/scripts`).then(r => r.ok ? r.json() : []), - fetch(`${INDEXER_BASE_URL}/txs/${txHash}/metadata`).then(r => r.ok ? r.json() : null), - fetch(`${INDEXER_BASE_URL}/txs/${txHash}/withdrawals`).then(r => r.ok ? r.json() : []), - fetch(`${INDEXER_BASE_URL}/assets/txs/${txHash}`).then(r => r.ok ? r.json() : []), - ]); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - if (tx) { - return { - tx, - contracts: scripts, - metadata, - withdrawals, - mints, - }; - } +export interface TxDetail { + hash: string; + block_height: number; + slot: number; + inputs: Utxo[]; + outputs: Utxo[]; + fees: number | string; + total_output: number | string; + ttl?: number | null; + validity_interval_start?: number | null; + auxiliary_data_hash?: string | null; + script_data_hash?: string | null; + collateral_inputs?: Utxo[]; + collateral_return?: Utxo | null; + total_collateral?: number | string | null; + reference_inputs?: Utxo[]; + required_signers?: string[] | null; + invalid?: boolean; +} - return { - status: 404, - body: { error: 'Can not fetch transaction.' } - }; +export const load: PageServerLoad = async ({ params, fetch }) => { + const hash = params.id.trim().toLowerCase(); + const tx = await must(`/txs/${hash}`, { fetch }, 'No transaction with that hash on this chain.'); + const [scripts, metadata, withdrawals, mints, block, protocol] = await Promise.all([ + tryApi(`/txs/${hash}/scripts`, { fetch }), + tryApi(`/txs/${hash}/metadata`, { fetch }), + tryApi(`/txs/${hash}/withdrawals`, { fetch }), + tryApi(`/assets/txs/${hash}`, { fetch }), + tx.block_height >= 0 ? tryApi(`/blocks/${tx.block_height}`, { fetch }) : null, + tryApi('/epochs/latest/parameters', { fetch }) + ]); + return { + tx, + scripts: scripts ?? [], + metadata: metadata ?? [], + withdrawals: withdrawals ?? [], + mints: mints ?? [], + block: block ? { time: block.time as number, epoch: block.epoch as number, hash: block.hash as string } : null, + limits: { mem: Number(protocol?.max_tx_ex_mem) || 14_000_000, steps: Number(protocol?.max_tx_ex_steps) || 10_000_000_000 } + }; }; diff --git a/applications/viewer/src/routes/transactions/[id]/+page.svelte b/applications/viewer/src/routes/transactions/[id]/+page.svelte index a310e3fe..4ec1c8b2 100644 --- a/applications/viewer/src/routes/transactions/[id]/+page.svelte +++ b/applications/viewer/src/routes/transactions/[id]/+page.svelte @@ -1,347 +1,209 @@ - -
-
-
-
-

- Transaction Hash -

-
- {tx.hash} - -
-
- -
-
-
- Block / Slot -
-
- - {tx.block_height} - - / - {tx.slot} -
-
- -
-
Fee
-
- {lovelaceToAda(tx.fees, 4)} ADA -
-
- -
-
- Total Output -
-
- {lovelaceToAda(tx.total_output, 2)} ADA -
-
+Tx {shorten(tx.hash, 8, 6)} · Yaci Viewer -
-
TTL
-
{tx.ttl}
+ + {#snippet icon()}{/snippet} +
+ + +
+ {#snippet actions()} + {#if tx.invalid} + Script failed · collateral taken + {:else} + Success + {/if} + {#if data.scripts.length}Smart contract{/if} + {#if minted.length}Mint{/if} + {#if burned.length}Burn{/if} + {#if data.metadata.length}Metadata{/if} + {/snippet} +
+ +
+ +
+ + {#if tx.block_height >= 0} + #{num(tx.block_height)} + {:else}Genesis{/if} + + {#if data.block}{:else}—{/if} + + {#if data.block}{data.block.epoch} / {/if}{num(tx.slot)} + + + + + {#if tx.validity_interval_start || tx.ttl} + {tx.validity_interval_start ? num(tx.validity_interval_start) : '…'} → {tx.ttl ? num(tx.ttl) : '∞'} + {:else}No limit{/if} + + {#if tx.script_data_hash}{/if} + {#if tx.auxiliary_data_hash}{/if} + {#if tx.total_collateral}{/if} +
+
+ + + + {#if data.mints.length} +
+
Tokens {minted.length && burned.length ? 'minted and burned' : minted.length ? 'minted' : 'burned'}
+
+ {#each data.mints.slice(0, 8) as m (m.unit)} + 0 ? '+' : '') + m.quantity} /> + {/each} + {#if data.mints.length > 8}{/if}
-
- -
-
- - - - - - - - - - - {#if withdrawals && withdrawals.length > 0} - - {/if} - - {#if mints && mints.length > 0} - - {/if} - - + {/if} + +
+ +
+ +
+ {#if active === 'utxos'} + (datumOf = u)} /> + {:else if active === 'scripts'} +
+ {#each data.scripts as s, i (s.script_hash + i)} + + {/each}
-
- -
- {#if activeTabIndex == INPUT_TAB} -
- -
- {/if} - - {#if activeTabIndex == CONTRACT_TAB} -
- {#if contracts.length === 0} -
- No contracts found in this transaction. -
- {:else} - {#each contracts as contract, index} -
- {#if index > 0}
{/if} - -
+ {:else if active === 'mints'} +
+ + + + {#each data.mints as m (m.unit)} + + + + + + {/each} - {/if} - - {/if} - - {#if activeTabIndex == COLLATERAL_TAB} -
- -
- {/if} - - {#if activeTabIndex == METADATA_TAB} -
- {#if !metadata} -
No metadata available.
- {:else} - - {/if} -
- {/if} - - {#if activeTabIndex == REFERENCE_INPUT_TAB} -
- -
- {/if} - - {#if activeTabIndex == WITHDRAWALS_TAB} -
- {#if !withdrawals || withdrawals.length === 0} - - {:else} -
-
AssetPolicyFingerprintQuantity
{parseUnit(m.unit).name}{Number(m.quantity) > 0 ? '+' : ''}{num(m.quantity)}
- - - - - - - - {#each withdrawals as w} - - - - - {/each} - -
AddressAmount
- - - {lovelaceToAda(w.amount, 6)} ADA -
-
- {/if} -
- {/if} - - {#if activeTabIndex == MINTS_TAB} -
- {#if !mints || mints.length === 0} - - {:else} -
- - - - - - - - - - - {#each mints as mint} - {@const unit = mint.unit || (mint.policy + (mint.asset_name || ''))} - {@const parsed = parseUnit(unit)} - - - - - - - {/each} - -
AssetPolicy IDTypeQuantity
- - {parsed.assetNameUtf8 || parsed.assetNameHex || '(empty name)'} - - - - {truncate(parsed.policyId, 20, '...')} - - - {#if mint.mint_type === 'MINT'} - MINT - {:else} - BURN - {/if} - - {mint.quantity} -
+ + +
+ {:else if active === 'metadata'} +
+ {#each data.metadata as m (m.label)} +
+
+ Label {m.label} + {#if METADATA_LABELS[m.label]}{METADATA_LABELS[m.label]}{/if}
- {/if} + +
+ {/each} +
+ {:else if active === 'withdrawals'} +
    + {#each data.withdrawals as w (w.address)} +
  • + + +
  • + {/each} +
+ {:else if active === 'collateral'} +
+
+
Collateral inputs
+
+ {#each tx.collateral_inputs ?? [] as u (u.tx_hash + u.output_index)}{/each} +
- {/if} - - {#if activeTabIndex == JSON_TAB} -
- +
+
Collateral return
+ {#if tx.collateral_return}{:else}

None

{/if} +

Collateral is only taken if a script fails. {tx.invalid ? 'It failed, so it was.' : 'This transaction succeeded, so it was not.'}

- {/if} -
+
+ {:else if active === 'references'} +

Read but not spent: these outputs supply reference scripts or datums.

+
+ {#each tx.reference_inputs ?? [] as u (u.tx_hash + u.output_index)} (datumOf = x)} />{/each} +
+ {:else if active === 'signers'} +

Key hashes that must sign this transaction (for scripts that check signatures).

+
    + {#each tx.required_signers ?? [] as s (s)}
  • {/each} +
+ {:else if active === 'raw'} + + {:else} + + {/if}
- + diff --git a/applications/viewer/vite.config.js b/applications/viewer/vite.config.js index 652979fb..671425b9 100644 --- a/applications/viewer/vite.config.js +++ b/applications/viewer/vite.config.js @@ -3,6 +3,10 @@ import { sveltekit } from '@sveltejs/kit/vite'; /** @type {import('vite').UserConfig} */ const config = { plugins: [sveltekit()], + optimizeDeps: { + // Icons are imported one file each; pre-bundling them on demand reloads the dev page mid-hydration. + exclude: ['@lucide/svelte'] + }, test: { include: ['src/**/*.{test,spec}.{js,ts}'] } From 9c1ad4a3211cd7b94820c4a38eb34737fd448edf Mon Sep 17 00:00:00 2001 From: Satya Date: Sat, 17 Oct 2026 01:49:10 +0800 Subject: [PATCH 3/3] Viewer: redesign assets, epochs, staking and governance; cleanup New and redesigned pages - Epochs (new): list with live progress for the current epoch, and an epoch page with blocks and grouped, filterable protocol parameters - Assets: unit/policy/fingerprint pages with mint and burn history, CIP-68 names, supply; scripts (native script tree, CBOR) and datums - Ada pots with trend charts; withdrawals - Pools: lookup plus recent registrations and retirements; pool page with metadata, stake trend, delegators, blocks and rewards - Rewards, epoch stakes (fix the doubled /api/v1 in its loader and the browser fetches that ignored the client base URL), and one design for the five certificate lists - Governance: overview, proposals (govactions redirects there), action page with a vote tally, DReps, votes, committee, constitution and DRep activity Security - The governance anchor page fetched any URL on the server. Anchors and pool metadata now go through one safe fetcher: http(s) on 80/443 and ipfs only, no private/loopback/link-local/reserved addresses (also checked after DNS, at connect time, and on every redirect), size and time limits, JSON only. Anchors are checked against their on-chain BLAKE2b-256 hash Fixes and cleanup - API timeouts now also race the request (SvelteKit's server fetch can ignore the abort signal); the live clock reads real time on the server - Remove DaisyUI, the old components, utilities, stores and dead routes; drop moment, svelte-feather-icons, classnames and @popperjs/core - Docker image runs the production build (node build) on 5173 instead of the Vite dev server; the browser store URL follows HOST_STORE_API_PORT - Playwright smoke tests that actually run (19 pages and flows), unit tests for formatting, search, CBOR, bech32, addresses and URL safety - README rewritten Co-Authored-By: Claude Opus 5.5 --- applications/viewer/.dockerignore | 10 + applications/viewer/Dockerfile | 29 +- applications/viewer/README.md | 85 ++- applications/viewer/package-lock.json | 107 +--- applications/viewer/package.json | 6 +- applications/viewer/src/app.postcss | 2 +- .../viewer/src/components/AddressLink.svelte | 8 - .../viewer/src/components/AmountBadges.svelte | 61 --- .../viewer/src/components/AssetLink.svelte | 12 - .../viewer/src/components/Contract.svelte | 206 ------- .../viewer/src/components/EmptyState.svelte | 11 - .../viewer/src/components/ErrorState.svelte | 16 - .../viewer/src/components/JsonContent.svelte | 36 -- applications/viewer/src/components/Nav.svelte | 284 ---------- .../viewer/src/components/Skeleton.svelte | 18 - .../viewer/src/components/TruncateCopy.svelte | 33 -- .../viewer/src/components/TxnContext.svelte | 26 - .../src/components/drep/DRepDetails.svelte | 61 --- .../components/health/HealthIndicator.svelte | 26 - .../components/inputoutput/InputOutput.svelte | 21 - .../src/components/inputoutput/Inputs.svelte | 47 -- .../inputoutput/OutputDetails.svelte | 60 -- .../src/components/inputoutput/Outputs.svelte | 46 -- applications/viewer/src/index.test.ts | 7 - applications/viewer/src/lib/api/client.ts | 16 +- .../viewer/src/lib/assets/AssetAvatar.svelte | 12 + .../viewer/src/lib/assets/MintHistory.svelte | 49 ++ .../src/lib/assets/NativeScriptTree.svelte | 44 ++ applications/viewer/src/lib/assets/load.ts | 29 + applications/viewer/src/lib/assets/script.ts | 9 + applications/viewer/src/lib/assets/types.ts | 16 + .../src/lib/dashboard/BlockConveyor.svelte | 2 +- .../viewer/src/lib/dashboard/EpochCard.svelte | 2 +- .../viewer/src/lib/epochs/ParamsView.svelte | 100 ++++ .../viewer/src/lib/epochs/TrendChart.svelte | 93 ++++ applications/viewer/src/lib/epochs/epochs.ts | 84 +++ applications/viewer/src/lib/epochs/params.ts | 111 ++++ applications/viewer/src/lib/epochs/pots.ts | 28 + .../src/lib/governance/AnchorDoc.svelte | 86 +++ .../src/lib/governance/AnchorLink.svelte | 17 + .../src/lib/governance/DRepCertList.svelte | 69 +++ .../src/lib/governance/ProposalTable.svelte | 44 ++ .../src/lib/governance/VoteTally.svelte | 45 ++ .../viewer/src/lib/governance/anchor-url.ts | 32 ++ .../src/lib/governance/anchor.server.ts | 52 ++ .../viewer/src/lib/governance/anchor.test.ts | 96 ++++ .../viewer/src/lib/governance/blake2b.ts | 75 +++ .../viewer/src/lib/governance/certs.ts | 24 + .../viewer/src/lib/governance/data.ts | 55 ++ .../viewer/src/lib/governance/drep.ts | 31 ++ .../viewer/src/lib/governance/labels.ts | 145 +++++ .../viewer/src/lib/governance/reward.ts | 8 + .../viewer/src/lib/live/chain.svelte.ts | 9 +- applications/viewer/src/lib/net/public-url.ts | 142 +++++ .../viewer/src/lib/server/safe-fetch.ts | 127 +++++ .../viewer/src/lib/shell/Sidebar.svelte | 4 +- .../viewer/src/lib/shell/Topbar.svelte | 5 +- applications/viewer/src/lib/shell/nav.ts | 6 +- .../viewer/src/lib/staking/CertPage.svelte | 107 ++++ applications/viewer/src/lib/staking/certs.ts | 54 ++ .../viewer/src/lib/staking/distribution.ts | 29 + .../viewer/src/lib/staking/pool.test.ts | 23 + applications/viewer/src/lib/staking/pool.ts | 33 ++ .../src/lib/staking/poolMetadata.server.ts | 34 ++ .../viewer/src/lib/stores/balanceApi.ts | 4 - applications/viewer/src/lib/stores/health.ts | 56 -- applications/viewer/src/lib/stores/toast.ts | 24 - applications/viewer/src/lib/ui/Pager.svelte | 3 +- applications/viewer/src/lib/ui/TimeAgo.svelte | 5 +- applications/viewer/src/lib/util.ts | 55 -- applications/viewer/src/lib/utils/asset.ts | 33 -- applications/viewer/src/lib/utils/format.ts | 17 - applications/viewer/src/lib/utils/hash.ts | 13 - applications/viewer/src/lib/variables.ts | 3 - applications/viewer/src/routes/+layout.svelte | 2 + applications/viewer/src/routes/+page.svelte | 2 +- .../viewer/src/routes/adapot/+page.server.ts | 75 +-- .../viewer/src/routes/adapot/+page.svelte | 250 --------- .../routes/adapot/components/SearchBar.svelte | 69 --- .../src/routes/adapot/current/+page.server.ts | 43 +- .../src/routes/adapot/current/+page.svelte | 278 ++++------ .../src/routes/adapot/list/+page.server.ts | 85 +-- .../src/routes/adapot/list/+page.svelte | 215 +++----- .../viewer/src/routes/adapot/store.ts | 50 -- .../src/routes/api/adapot/[epoch]/+server.ts | 75 --- .../src/routes/api/adapot/list/+server.ts | 74 --- .../viewer/src/routes/assets/+page.svelte | 126 ++--- .../fingerprint/[fingerprint]/+page.server.ts | 73 +-- .../fingerprint/[fingerprint]/+page.svelte | 181 +----- .../assets/policy/[policy]/+page.server.ts | 58 +- .../assets/policy/[policy]/+page.svelte | 209 +++---- .../routes/assets/unit/[unit]/+page.server.ts | 127 ++--- .../routes/assets/unit/[unit]/+page.svelte | 485 +++++------------ .../src/routes/blocks/[number]/+page.svelte | 2 +- .../certificates/delegations/+page.server.ts | 39 +- .../certificates/delegations/+page.svelte | 267 +-------- .../pool-registrations/+page.server.ts | 34 +- .../pool-registrations/+page.svelte | 282 +--------- .../pool-retirements/+page.server.ts | 34 +- .../pool-retirements/+page.svelte | 132 +---- .../stake-deregistrations/+page.svelte | 130 ----- .../stake-registrations/+page.svelte | 130 ----- .../stakekey-deregistrations/+page.server.ts | 34 +- .../stakekey-deregistrations/+page.svelte | 132 +---- .../stakekey-registrations/+page.server.ts | 34 +- .../stakekey-registrations/+page.svelte | 151 +---- .../src/routes/datums/[hash]/+page.server.ts | 33 +- .../src/routes/datums/[hash]/+page.svelte | 126 +---- .../src/routes/epoch-stakes/+page.server.ts | 59 +- .../src/routes/epoch-stakes/+page.svelte | 370 +++++-------- .../epoch-stakes/accounts/+page.server.ts | 66 +-- .../routes/epoch-stakes/accounts/+page.svelte | 237 +++----- .../routes/epoch-stakes/pools/+page.server.ts | 128 ++--- .../routes/epoch-stakes/pools/+page.svelte | 313 +++-------- .../viewer/src/routes/epochs/+page.server.ts | 46 ++ .../viewer/src/routes/epochs/+page.svelte | 130 +++++ .../src/routes/epochs/[epoch]/+page.server.ts | 65 +++ .../src/routes/epochs/[epoch]/+page.svelte | 164 ++++++ .../src/routes/governance/+page.server.ts | 80 ++- .../viewer/src/routes/governance/+page.svelte | 238 ++++---- .../routes/governance/anchor/+page.server.ts | 66 +-- .../src/routes/governance/anchor/+page.svelte | 514 ++---------------- .../governance/committee/+page.server.ts | 36 +- .../routes/governance/committee/+page.svelte | 143 +++-- .../governance/constitution/+page.server.ts | 43 +- .../governance/constitution/+page.svelte | 96 ++-- .../drep-deregistrations/+page.server.ts | 36 +- .../drep-deregistrations/+page.svelte | 148 +---- .../drep-registrations/+page.server.ts | 36 +- .../drep-registrations/+page.svelte | 166 +----- .../governance/drep-updates/+page.server.ts | 36 +- .../governance/drep-updates/+page.svelte | 166 +----- .../routes/governance/dreps/+page.server.ts | 56 +- .../src/routes/governance/dreps/+page.svelte | 301 +++------- .../governance/dreps/[drepId]/+page.server.ts | 66 ++- .../governance/dreps/[drepId]/+page.svelte | 267 ++++----- .../governance/govactions/+page.server.ts | 39 +- .../routes/governance/govactions/+page.svelte | 180 ------ .../govactions/[actionid]/+page.server.ts | 82 ++- .../govactions/[actionid]/+page.svelte | 246 ++++----- .../governance/proposals/+page.server.ts | 62 +-- .../routes/governance/proposals/+page.svelte | 434 ++------------- .../src/routes/governance/proposals/types.ts | 27 - .../routes/governance/votes/+page.server.ts | 47 +- .../src/routes/governance/votes/+page.svelte | 218 ++------ .../routes/pools/[pool_id]/+page.server.ts | 94 +++- .../src/routes/pools/[pool_id]/+page.svelte | 513 ++++++----------- .../src/routes/pools/search/+page.server.ts | 15 + .../src/routes/pools/search/+page.svelte | 163 +++--- .../viewer/src/routes/rewards/+page.server.ts | 7 + .../viewer/src/routes/rewards/+page.svelte | 208 +++---- .../rewards/account/[address]/+page.server.ts | 100 +--- .../rewards/account/[address]/+page.svelte | 306 +---------- .../rewards/components/SearchBar.svelte | 69 --- .../rewards/epoch/[epoch]/+page.server.ts | 149 ++--- .../routes/rewards/epoch/[epoch]/+page.svelte | 460 +++------------- .../[pool_id]/epoch/[epoch]/+page.server.ts | 85 +-- .../pool/[pool_id]/epoch/[epoch]/+page.svelte | 289 ++-------- .../viewer/src/routes/rewards/store.ts | 60 -- .../src/routes/scripts/[hash]/+page.server.ts | 40 +- .../src/routes/scripts/[hash]/+page.svelte | 202 +++---- .../src/routes/transactions/[id]/+page.svelte | 2 +- .../src/routes/withdrawals/+page.server.ts | 37 +- .../src/routes/withdrawals/+page.svelte | 176 ++---- applications/viewer/src/util/ada_util.js | 9 - applications/viewer/src/util/util.js | 32 -- applications/viewer/tailwind.config.cjs | 49 +- applications/viewer/tests/smoke.spec.ts | 69 +++ applications/viewer/tests/test.ts | 23 - applications/viewer/vite.config.js | 6 +- config/env | 2 +- 171 files changed, 5177 insertions(+), 10810 deletions(-) create mode 100644 applications/viewer/.dockerignore delete mode 100644 applications/viewer/src/components/AddressLink.svelte delete mode 100644 applications/viewer/src/components/AmountBadges.svelte delete mode 100644 applications/viewer/src/components/AssetLink.svelte delete mode 100644 applications/viewer/src/components/Contract.svelte delete mode 100644 applications/viewer/src/components/EmptyState.svelte delete mode 100644 applications/viewer/src/components/ErrorState.svelte delete mode 100644 applications/viewer/src/components/JsonContent.svelte delete mode 100644 applications/viewer/src/components/Nav.svelte delete mode 100644 applications/viewer/src/components/Skeleton.svelte delete mode 100644 applications/viewer/src/components/TruncateCopy.svelte delete mode 100644 applications/viewer/src/components/TxnContext.svelte delete mode 100644 applications/viewer/src/components/drep/DRepDetails.svelte delete mode 100644 applications/viewer/src/components/health/HealthIndicator.svelte delete mode 100644 applications/viewer/src/components/inputoutput/InputOutput.svelte delete mode 100644 applications/viewer/src/components/inputoutput/Inputs.svelte delete mode 100644 applications/viewer/src/components/inputoutput/OutputDetails.svelte delete mode 100644 applications/viewer/src/components/inputoutput/Outputs.svelte delete mode 100644 applications/viewer/src/index.test.ts create mode 100644 applications/viewer/src/lib/assets/AssetAvatar.svelte create mode 100644 applications/viewer/src/lib/assets/MintHistory.svelte create mode 100644 applications/viewer/src/lib/assets/NativeScriptTree.svelte create mode 100644 applications/viewer/src/lib/assets/load.ts create mode 100644 applications/viewer/src/lib/assets/script.ts create mode 100644 applications/viewer/src/lib/assets/types.ts create mode 100644 applications/viewer/src/lib/epochs/ParamsView.svelte create mode 100644 applications/viewer/src/lib/epochs/TrendChart.svelte create mode 100644 applications/viewer/src/lib/epochs/epochs.ts create mode 100644 applications/viewer/src/lib/epochs/params.ts create mode 100644 applications/viewer/src/lib/epochs/pots.ts create mode 100644 applications/viewer/src/lib/governance/AnchorDoc.svelte create mode 100644 applications/viewer/src/lib/governance/AnchorLink.svelte create mode 100644 applications/viewer/src/lib/governance/DRepCertList.svelte create mode 100644 applications/viewer/src/lib/governance/ProposalTable.svelte create mode 100644 applications/viewer/src/lib/governance/VoteTally.svelte create mode 100644 applications/viewer/src/lib/governance/anchor-url.ts create mode 100644 applications/viewer/src/lib/governance/anchor.server.ts create mode 100644 applications/viewer/src/lib/governance/anchor.test.ts create mode 100644 applications/viewer/src/lib/governance/blake2b.ts create mode 100644 applications/viewer/src/lib/governance/certs.ts create mode 100644 applications/viewer/src/lib/governance/data.ts create mode 100644 applications/viewer/src/lib/governance/drep.ts create mode 100644 applications/viewer/src/lib/governance/labels.ts create mode 100644 applications/viewer/src/lib/governance/reward.ts create mode 100644 applications/viewer/src/lib/net/public-url.ts create mode 100644 applications/viewer/src/lib/server/safe-fetch.ts create mode 100644 applications/viewer/src/lib/staking/CertPage.svelte create mode 100644 applications/viewer/src/lib/staking/certs.ts create mode 100644 applications/viewer/src/lib/staking/distribution.ts create mode 100644 applications/viewer/src/lib/staking/pool.test.ts create mode 100644 applications/viewer/src/lib/staking/pool.ts create mode 100644 applications/viewer/src/lib/staking/poolMetadata.server.ts delete mode 100644 applications/viewer/src/lib/stores/balanceApi.ts delete mode 100644 applications/viewer/src/lib/stores/health.ts delete mode 100644 applications/viewer/src/lib/stores/toast.ts delete mode 100644 applications/viewer/src/lib/util.ts delete mode 100644 applications/viewer/src/lib/utils/asset.ts delete mode 100644 applications/viewer/src/lib/utils/format.ts delete mode 100644 applications/viewer/src/lib/utils/hash.ts delete mode 100644 applications/viewer/src/lib/variables.ts delete mode 100644 applications/viewer/src/routes/adapot/+page.svelte delete mode 100644 applications/viewer/src/routes/adapot/components/SearchBar.svelte delete mode 100644 applications/viewer/src/routes/adapot/store.ts delete mode 100644 applications/viewer/src/routes/api/adapot/[epoch]/+server.ts delete mode 100644 applications/viewer/src/routes/api/adapot/list/+server.ts delete mode 100644 applications/viewer/src/routes/certificates/stake-deregistrations/+page.svelte delete mode 100644 applications/viewer/src/routes/certificates/stake-registrations/+page.svelte create mode 100644 applications/viewer/src/routes/epochs/+page.server.ts create mode 100644 applications/viewer/src/routes/epochs/+page.svelte create mode 100644 applications/viewer/src/routes/epochs/[epoch]/+page.server.ts create mode 100644 applications/viewer/src/routes/epochs/[epoch]/+page.svelte delete mode 100644 applications/viewer/src/routes/governance/govactions/+page.svelte delete mode 100644 applications/viewer/src/routes/governance/proposals/types.ts create mode 100644 applications/viewer/src/routes/pools/search/+page.server.ts create mode 100644 applications/viewer/src/routes/rewards/+page.server.ts delete mode 100644 applications/viewer/src/routes/rewards/components/SearchBar.svelte delete mode 100644 applications/viewer/src/routes/rewards/store.ts delete mode 100644 applications/viewer/src/util/ada_util.js delete mode 100644 applications/viewer/src/util/util.js create mode 100644 applications/viewer/tests/smoke.spec.ts delete mode 100644 applications/viewer/tests/test.ts diff --git a/applications/viewer/.dockerignore b/applications/viewer/.dockerignore new file mode 100644 index 00000000..89d5fdb4 --- /dev/null +++ b/applications/viewer/.dockerignore @@ -0,0 +1,10 @@ +node_modules +build +.svelte-kit +test-results +.playwright-mcp +playwright-report +deno.lock +.env +.env.* +*.log diff --git a/applications/viewer/Dockerfile b/applications/viewer/Dockerfile index 64100aff..c1e323b4 100644 --- a/applications/viewer/Dockerfile +++ b/applications/viewer/Dockerfile @@ -1,14 +1,19 @@ -FROM node:slim - -RUN apt-get update && apt-get install -y \ - git \ - && rm -rf /var/lib/apt/lists/* - -COPY . /yaci-viewer -WORKDIR /yaci-viewer - -RUN npm install && npm run build +# Build the SvelteKit app, then run the production server (adapter-node). +# PUBLIC_INDEXER_* variables are read at runtime, so one image works for any Yaci Store. +FROM node:22-slim AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build && npm prune --omit=dev +FROM node:22-slim +WORKDIR /app +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=5173 +COPY --from=build /app/package.json ./ +COPY --from=build /app/node_modules ./node_modules +COPY --from=build /app/build ./build EXPOSE 5173 - -CMD ["npm", "run", "dev"] +CMD ["node", "build"] diff --git a/applications/viewer/README.md b/applications/viewer/README.md index 5df4b350..a7768fcc 100644 --- a/applications/viewer/README.md +++ b/applications/viewer/README.md @@ -1,69 +1,58 @@ -## Getting Started +# Yaci Viewer -### Installation +A live Cardano explorer for your Yaci DevKit devnet, or any [Yaci Store](https://github.com/bloxbean/yaci-store). -To install Yaci Viewer globally, run: +- **Live dashboard:** new blocks slide in as they're made, with block time, transaction and block-load trends, + epoch progress and ADA supply. +- **Search everything:** press / or ⌘K (or just paste) a block number or hash, + transaction, address, stake address, pool, DRep, policy, asset, fingerprint, script, datum or epoch. +- **Transactions you can read:** inputs flow into outputs, scripts show their execution budget, redeemers and datums + are decoded from CBOR, plus mints, metadata, collateral, reference inputs and the raw JSON. +- Addresses, stake accounts, assets, epochs and protocol parameters, pools, rewards, certificates, Ada pots and + Conway governance. +- Light and dark themes; works on a phone. -```bash -npm install -g @bloxbean/yaci-viewer -``` - -### Running Yaci Viewer - -1. Start the Yaci DevKit. -2. Run the following command: +## Run it ```bash +npm install -g @bloxbean/yaci-viewer yaci-viewer ``` -By default, Yaci Viewer connects to a Yaci-Store backend running on `localhost` at port `8080`. - ---- - -## Configuration Options - -If you need to connect to a Yaci-Store backend running on a different host or port, you can configure Yaci Viewer in one of the following ways: - -### Option 1: Using a `.env` File +Yaci Viewer reads from Yaci Store at `http://localhost:8080/api/v1` and serves the UI at `http://localhost:3000`. +Set `PORT` to use another port, for example `PORT=5173 yaci-viewer` to match the Docker distribution. -Create a `.env` file in the same directory where you are starting Yaci Viewer, and specify the following environment variables: +The Docker distribution of Yaci DevKit already includes Yaci Viewer at `http://localhost:5173`. -- `PUBLIC_INDEXER_BASE_URL`: The base URL for the indexer API. Default: `http://localhost:8080/api/v1`. -- `PUBLIC_INDEXER_WS_URL`: The WebSocket URL for live updates. Default: `ws://localhost:8080/ws/liveblocks`. +## Configuration -### Option 2: Using Command-Line Arguments +Set these in the environment, or in a `.env` file in the directory you start Yaci Viewer from: -You can also specify the indexer base URL and WebSocket URL directly via command-line arguments: +| Variable | Default | What it is | +|---|---|---| +| `PUBLIC_INDEXER_BASE_URL` | `http://localhost:8080/api/v1` | Yaci Store REST API, used by the server | +| `PUBLIC_INDEXER_CLIENT_BASE_URL` | same as above | Yaci Store as the browser sees it, when that differs (e.g. Docker) | +| `PUBLIC_INDEXER_WS_URL` | `ws://localhost:8080/ws/liveblocks` | Live updates. Leave empty for a store without the live module; the viewer polls instead | +| `PUBLIC_MAINTENANCE_MSG` | | Optional banner shown on every page | +| `PORT`, `HOST` | `3000`, `0.0.0.0` | Where Yaci Viewer listens | -```bash -yaci-viewer --indexerBaseUrl http://new-url.com/api/v1 --indexerWsUrl ws://new-url.com/ws/liveblocks -``` - ---- - -## Notes - -- Ensure that the YaciDevKit or Yaci-Store backend is running and accessible before starting Yaci Viewer. - -## Developing - -Once you've checked out the project and installed dependencies with `npm install` start a development server: +The base and WebSocket URLs can also be passed on the command line: ```bash -npm run dev - -# or start the server and open the app in a new browser tab -npm run dev -- --open +yaci-viewer --indexerBaseUrl http://my-store:8080/api/v1 --indexerWsUrl ws://my-store:8080/ws/liveblocks ``` -> **Note:** Ensure that Yaci DevKit is running and that you have created a `.env` file with the indexer URLs in the viewer folder. For the required environment variables, refer to the `env.example` file. - -## Building - -To create a production version of the app: +## Develop ```bash -npm run build +npm install +PUBLIC_INDEXER_BASE_URL=http://localhost:8080/api/v1 npm run dev # http://localhost:5173 +npm run check # svelte-check +npm run test:unit # vitest +npm test # Playwright smoke tests (needs a running Yaci Store) +npm run build # production build in build/, run with `node build` ``` +SvelteKit 2, Svelte 5 and Tailwind. Shared code lives in `src/lib`: `api/` (Yaci Store client), `live/` (WebSocket +with a polling fallback), `ui/` (components), `shell/` (layout, navigation, search) and `format.ts`, `cbor.ts`, +`bech32.ts`. diff --git a/applications/viewer/package-lock.json b/applications/viewer/package-lock.json index 9b4d5497..8aaa853f 100644 --- a/applications/viewer/package-lock.json +++ b/applications/viewer/package-lock.json @@ -10,8 +10,6 @@ "license": "MIT", "dependencies": { "dotenv": "^16.4.7", - "moment": "^2.30.1", - "svelte-feather-icons": "^4.1.0", "yargs": "^17.7.2" }, "bin": { @@ -23,15 +21,13 @@ "@fontsource-variable/space-grotesk": "5.3.0", "@lucide/svelte": "1.47.0", "@playwright/test": "^1.46.0", - "@popperjs/core": "^2.11.8", "@sveltejs/adapter-node": "5.5.7", "@sveltejs/kit": "2.70.3", "@sveltejs/vite-plugin-svelte": "5.1.1", + "@types/node": "22.20.4", "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "autoprefixer": "^10.4.20", - "classnames": "^2.5.1", - "daisyui": "^4.12.10", "eslint": "^9.8.0", "eslint-config-prettier": "^9.1.0", "postcss": "^8.4.41", @@ -862,16 +858,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, "node_modules/@rollup/plugin-commonjs": { "version": "29.0.3", "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", @@ -1547,6 +1533,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.20.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.4.tgz", + "integrity": "sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2186,12 +2182,6 @@ "node": ">= 6" } }, - "node_modules/classnames": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", - "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", - "dev": true - }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -2314,16 +2304,6 @@ "node": ">= 8" } }, - "node_modules/css-selector-tokenizer": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.8.0.tgz", - "integrity": "sha512-Jd6Ig3/pe62/qe5SBPTN8h8LeUg/pT4lLgtavPf7updwwHpvFzxvOQBHYj2LZDMjUnBzgvIUSjRcf6oT5HzHFg==", - "dev": true, - "dependencies": { - "cssesc": "^3.0.0", - "fastparse": "^1.1.2" - } - }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2336,34 +2316,6 @@ "node": ">=4" } }, - "node_modules/culori": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/culori/-/culori-3.3.0.tgz", - "integrity": "sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/daisyui": { - "version": "4.12.10", - "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-4.12.10.tgz", - "integrity": "sha512-jp1RAuzbHhGdXmn957Z2XsTZStXGHzFfF0FgIOZj3Wv9sH7OZgLfXTRZNfKVYxltGUOBsG1kbWAdF5SrqjebvA==", - "dev": true, - "dependencies": { - "css-selector-tokenizer": "^0.8", - "culori": "^3", - "picocolors": "^1", - "postcss-js": "^4" - }, - "engines": { - "node": ">=16.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/daisyui" - } - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2805,12 +2757,6 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fastparse": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", - "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==", - "dev": true - }, "node_modules/fastq": { "version": "1.14.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.14.0.tgz", @@ -3355,14 +3301,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/moment": { - "version": "2.30.1", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", - "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", - "engines": { - "node": "*" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -4389,22 +4327,6 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/svelte-feather-icons": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/svelte-feather-icons/-/svelte-feather-icons-4.1.0.tgz", - "integrity": "sha512-fcTL4VzEN4BoccQJjKiui0b9DWEsmO6zGpI0tQTJbthRtNrYheoU487zyA1BD8rj9kj6jbKGmGVo7zbSdRvMvA==", - "dependencies": { - "svelte": "^3.38.2" - } - }, - "node_modules/svelte-feather-icons/node_modules/svelte": { - "version": "3.59.2", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.59.2.tgz", - "integrity": "sha512-vzSyuGr3eEoAtT/A6bmajosJZIUWySzY2CzB3w2pgPvnkUjGqlDnsNnA0PMO+mMAhuyMul6C2uuZzY6ELSkzyA==", - "engines": { - "node": ">= 8" - } - }, "node_modules/svelte-preprocess": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-6.0.3.tgz", @@ -4729,6 +4651,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", diff --git a/applications/viewer/package.json b/applications/viewer/package.json index a6d99ba3..5365cb37 100644 --- a/applications/viewer/package.json +++ b/applications/viewer/package.json @@ -34,15 +34,13 @@ "@fontsource-variable/space-grotesk": "5.3.0", "@lucide/svelte": "1.47.0", "@playwright/test": "^1.46.0", - "@popperjs/core": "^2.11.8", "@sveltejs/adapter-node": "5.5.7", "@sveltejs/kit": "2.70.3", "@sveltejs/vite-plugin-svelte": "5.1.1", + "@types/node": "22.20.4", "@typescript-eslint/eslint-plugin": "^8.0.1", "@typescript-eslint/parser": "^8.0.1", "autoprefixer": "^10.4.20", - "classnames": "^2.5.1", - "daisyui": "^4.12.10", "eslint": "^9.8.0", "eslint-config-prettier": "^9.1.0", "postcss": "^8.4.41", @@ -61,8 +59,6 @@ "type": "module", "dependencies": { "dotenv": "^16.4.7", - "moment": "^2.30.1", - "svelte-feather-icons": "^4.1.0", "yargs": "^17.7.2" } } diff --git a/applications/viewer/src/app.postcss b/applications/viewer/src/app.postcss index ccbd3d8f..00a7cc3c 100644 --- a/applications/viewer/src/app.postcss +++ b/applications/viewer/src/app.postcss @@ -91,7 +91,7 @@ } /* ========================================================================== - Building blocks shared by the components. Prefixed y- so they don't clash with DaisyUI while it's still here. + Building blocks shared by the components (prefixed y-). ========================================================================== */ @layer components { .y-panel { diff --git a/applications/viewer/src/components/AddressLink.svelte b/applications/viewer/src/components/AddressLink.svelte deleted file mode 100644 index bcbd7de1..00000000 --- a/applications/viewer/src/components/AddressLink.svelte +++ /dev/null @@ -1,8 +0,0 @@ - - -{truncate(address, maxLength, '...')} diff --git a/applications/viewer/src/components/AmountBadges.svelte b/applications/viewer/src/components/AmountBadges.svelte deleted file mode 100644 index 6630c306..00000000 --- a/applications/viewer/src/components/AmountBadges.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -{#if amounts} - {#each amounts as amount} - {#if amount.asset_name === 'lovelace'} - {lovelaceToAda(amount.quantity)} Ada - {:else} -
- {amount.quantity} -
- {#if amount.unit && amount.unit !== 'lovelace'} - - {truncateAssetName(amount.asset_name)} - - {:else} - - {truncateAssetName(amount.asset_name)} - - {/if} - -
-
- {/if} - {/each} -{/if} diff --git a/applications/viewer/src/components/AssetLink.svelte b/applications/viewer/src/components/AssetLink.svelte deleted file mode 100644 index 7f5089d4..00000000 --- a/applications/viewer/src/components/AssetLink.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - -{displayName} diff --git a/applications/viewer/src/components/Contract.svelte b/applications/viewer/src/components/Contract.svelte deleted file mode 100644 index 24d231f7..00000000 --- a/applications/viewer/src/components/Contract.svelte +++ /dev/null @@ -1,206 +0,0 @@ - - -
- - {#if showToast} -
- {toastMessage} -
- {/if} - - -
-
-
Contract {index + 1}
-
- {contract.script_hash} - ({contract.type}) -
-
-
- - -
-
-
Redeemer
- {#if contract.redeemer && typeof contract.redeemer === 'object'} -
-
- Tag - {contract.redeemer.tag} -
-
- Index - {contract.redeemer.index} -
- {#if contract.redeemer.data} -
- Data - {contract.redeemer.data} -
- {/if} - {#if contract.redeemer.ex_units} -
-
- Memory - {contract.redeemer.ex_units.mem.toLocaleString()} -
-
- Steps - {contract.redeemer.ex_units.steps.toLocaleString()} -
-
- {/if} -
- {:else} -
{formatRedeemer(contract.redeemer)}
- {/if} -
-
- - - {#if contract.datum_hash} -
-
-
Datum Hash
-
- {contract.datum_hash} - -
-
-
- {/if} - - - {#if contract.datum} -
-
-
Datum
-
-
{contract.datum}
- -
-
-
- {/if} - - -
-
-
Contract Body
-
- - -
-
-
-
- - - diff --git a/applications/viewer/src/components/EmptyState.svelte b/applications/viewer/src/components/EmptyState.svelte deleted file mode 100644 index 73cc2ba9..00000000 --- a/applications/viewer/src/components/EmptyState.svelte +++ /dev/null @@ -1,11 +0,0 @@ - - -
-
{title}
-
{message}
- -
- diff --git a/applications/viewer/src/components/ErrorState.svelte b/applications/viewer/src/components/ErrorState.svelte deleted file mode 100644 index 18cb5693..00000000 --- a/applications/viewer/src/components/ErrorState.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
-
{title}
-
{message}
- {#if onAction} - - {/if} - -
- diff --git a/applications/viewer/src/components/JsonContent.svelte b/applications/viewer/src/components/JsonContent.svelte deleted file mode 100644 index 3bee1b28..00000000 --- a/applications/viewer/src/components/JsonContent.svelte +++ /dev/null @@ -1,36 +0,0 @@ - - -
-
-
- -
-
-
diff --git a/applications/viewer/src/components/Nav.svelte b/applications/viewer/src/components/Nav.svelte deleted file mode 100644 index dc42b78c..00000000 --- a/applications/viewer/src/components/Nav.svelte +++ /dev/null @@ -1,284 +0,0 @@ - - - - - \ No newline at end of file diff --git a/applications/viewer/src/components/Skeleton.svelte b/applications/viewer/src/components/Skeleton.svelte deleted file mode 100644 index b3888001..00000000 --- a/applications/viewer/src/components/Skeleton.svelte +++ /dev/null @@ -1,18 +0,0 @@ - - -
-
- {#each Array(lines) as _, i} -
- {/each} -
- - - diff --git a/applications/viewer/src/components/TruncateCopy.svelte b/applications/viewer/src/components/TruncateCopy.svelte deleted file mode 100644 index c2400987..00000000 --- a/applications/viewer/src/components/TruncateCopy.svelte +++ /dev/null @@ -1,33 +0,0 @@ - - -
- {truncate(text, max)} - -
diff --git a/applications/viewer/src/components/TxnContext.svelte b/applications/viewer/src/components/TxnContext.svelte deleted file mode 100644 index b390c6af..00000000 --- a/applications/viewer/src/components/TxnContext.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
-
-
- -
-
-
- - diff --git a/applications/viewer/src/components/drep/DRepDetails.svelte b/applications/viewer/src/components/drep/DRepDetails.svelte deleted file mode 100644 index a61630b7..00000000 --- a/applications/viewer/src/components/drep/DRepDetails.svelte +++ /dev/null @@ -1,61 +0,0 @@ - - -{#if show} -
-
-
- -
-
-
DRep Id :
-
- {drep.drep_id} -
-
-
-
DRep Hash :
-
- {drep.drep_hash} -
-
- {#if drep.deposit} -
-
Deposit :
-
- {drep.deposit} -
-
- {/if} - {#if drep.anchor_hash} -
-
Anchor Hash :
-
- {drep.anchor_hash} -
-
- {/if} - {#if drep.anchor_url } -
-
Anchor URL :
- -
- {/if} -
-
-{/if} diff --git a/applications/viewer/src/components/health/HealthIndicator.svelte b/applications/viewer/src/components/health/HealthIndicator.svelte deleted file mode 100644 index e759cb5d..00000000 --- a/applications/viewer/src/components/health/HealthIndicator.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - -
- - -
- diff --git a/applications/viewer/src/components/inputoutput/InputOutput.svelte b/applications/viewer/src/components/inputoutput/InputOutput.svelte deleted file mode 100644 index 8b5373f6..00000000 --- a/applications/viewer/src/components/inputoutput/InputOutput.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -
-
-
-
-

Inputs

- -
-
-

Outputs

- -
-
-
-
diff --git a/applications/viewer/src/components/inputoutput/Inputs.svelte b/applications/viewer/src/components/inputoutput/Inputs.svelte deleted file mode 100644 index 9990082f..00000000 --- a/applications/viewer/src/components/inputoutput/Inputs.svelte +++ /dev/null @@ -1,47 +0,0 @@ - - -
- {#each inputs as input} -
-
- -
-
- -
-
- {#if input.inline_datum_json || input.script_ref} -
-
- -
-
- {/if} -
-
- {/each} -
- - diff --git a/applications/viewer/src/components/inputoutput/OutputDetails.svelte b/applications/viewer/src/components/inputoutput/OutputDetails.svelte deleted file mode 100644 index 4d3c1eb3..00000000 --- a/applications/viewer/src/components/inputoutput/OutputDetails.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - -{#if show} -
-
-
- -
-
- {#if output.inline_datum} -
-
Datum (Cbor) :
-
- {output.inline_datum} -
-
-
-
Datum (Json) :
- -
- {/if} - - {#if output.script_ref} -
-
Reference Script Hash :
-
- {output.reference_script_hash} -
-
-
-
Reference Script :
- -
- {/if} -
-
-
-{/if} diff --git a/applications/viewer/src/components/inputoutput/Outputs.svelte b/applications/viewer/src/components/inputoutput/Outputs.svelte deleted file mode 100644 index 2e2685b3..00000000 --- a/applications/viewer/src/components/inputoutput/Outputs.svelte +++ /dev/null @@ -1,46 +0,0 @@ - - -
- {#each outputs as output} -
-
-
-
- -
-
- {#if output.inline_datum_json || output.script_ref} -
-
- -
-
- {/if} -
-
- {/each} -
- - diff --git a/applications/viewer/src/index.test.ts b/applications/viewer/src/index.test.ts deleted file mode 100644 index e07cbbd7..00000000 --- a/applications/viewer/src/index.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { describe, it, expect } from 'vitest'; - -describe('sum test', () => { - it('adds 1 + 2 to equal 3', () => { - expect(1 + 2).toBe(3); - }); -}); diff --git a/applications/viewer/src/lib/api/client.ts b/applications/viewer/src/lib/api/client.ts index 4e152357..8b62238b 100644 --- a/applications/viewer/src/lib/api/client.ts +++ b/applications/viewer/src/lib/api/client.ts @@ -51,11 +51,21 @@ function buildUrl(path: string, params?: Params): string { export async function api(path: string, opts: ApiOptions = {}): Promise { const f = opts.fetch ?? fetch; const ctrl = new AbortController(); - const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 20000); + const timeoutMs = opts.timeoutMs ?? 20000; + let timer: ReturnType | undefined; + // SvelteKit's server-side fetch doesn't always honour the abort signal, so the timeout also races the request. + const timedOut = new Promise((_, reject) => { + timer = setTimeout(() => { + ctrl.abort(); + const e = new Error('timeout'); + e.name = 'AbortError'; + reject(e); + }, timeoutMs); + }); opts.signal?.addEventListener('abort', () => ctrl.abort()); const url = buildUrl(path, opts.params); try { - const res = await f(url, { signal: ctrl.signal, headers: { accept: 'application/json' } }); + const res = await Promise.race([f(url, { signal: ctrl.signal, headers: { accept: 'application/json' } }), timedOut]); if (!res.ok) { let detail = res.statusText; try { @@ -66,7 +76,7 @@ export async function api(path: string, opts: ApiOptions = {}): Pro } throw new ApiError(detail || `HTTP ${res.status}`, res.status, path); } - const text = await res.text(); + const text = await Promise.race([res.text(), timedOut]); if (!text) return null as T; try { return JSON.parse(text) as T; diff --git a/applications/viewer/src/lib/assets/AssetAvatar.svelte b/applications/viewer/src/lib/assets/AssetAvatar.svelte new file mode 100644 index 00000000..e6e753ef --- /dev/null +++ b/applications/viewer/src/lib/assets/AssetAvatar.svelte @@ -0,0 +1,12 @@ + + + diff --git a/applications/viewer/src/lib/assets/MintHistory.svelte b/applications/viewer/src/lib/assets/MintHistory.svelte new file mode 100644 index 00000000..d0c5bb76 --- /dev/null +++ b/applications/viewer/src/lib/assets/MintHistory.svelte @@ -0,0 +1,49 @@ + + + `${r.tx_hash}-${r.unit}-${r.quantity}`} + rowHref={(r) => `/transactions/${r.tx_hash}`} + emptyTitle="No mint or burn events" + emptyText="Nothing has minted or burned this asset in the indexed history." +> + {#snippet cell(r, key)} + {#if key === 'tx'} + {shorten(r.tx_hash, 12, 8)} + {:else if key === 'type'} + {#if burn(r)}Burn{:else}Mint{/if} + {:else if key === 'asset'} + {@const p = parseUnit(r.unit)} + {p.name} + {:else if key === 'qty'} + {@const q = String(r.quantity)} + {burn(r) ? '−' : '+'}{num(q.replace('-', ''))} + {:else if key === 'block'} + #{num(r.block_number)} + {:else if key === 'age'} + + {/if} + {/snippet} + diff --git a/applications/viewer/src/lib/assets/NativeScriptTree.svelte b/applications/viewer/src/lib/assets/NativeScriptTree.svelte new file mode 100644 index 00000000..39ba5b76 --- /dev/null +++ b/applications/viewer/src/lib/assets/NativeScriptTree.svelte @@ -0,0 +1,44 @@ + + +{#if type === 'sig'} +
+ + Signed by + {shorten(script.keyHash, 12, 8)} + +
+{:else if type === 'before' || type === 'after'} +
+ + {type === 'before' ? 'Valid before slot' : 'Valid from slot'} + {num(script.slot)} +
+{:else if type === 'all' || type === 'any' || type === 'atleast'} +
+
+ {#if type === 'all'}All of these{:else if type === 'any'}Any one of these{:else}At least {script.required} of these{/if} + · {children.length} +
+
+ {#each children as child, i (i)}{/each} +
+
+{:else} +
{JSON.stringify(script, null, 2)}
+{/if} diff --git a/applications/viewer/src/lib/assets/load.ts b/applications/viewer/src/lib/assets/load.ts new file mode 100644 index 00000000..d917fb13 --- /dev/null +++ b/applications/viewer/src/lib/assets/load.ts @@ -0,0 +1,29 @@ +import { ApiError, api } from '$lib/api/client'; + +export interface Supply { + value: number | string | null; + /** The store said 404: no such asset/policy. */ + notFound: boolean; + error: string | null; +} + +/* Supply for a unit, policy or fingerprint. Big policies can take a while, so it never blocks the page. */ +export async function loadSupply(path: string, f: typeof fetch, timeoutMs = 10000): Promise { + try { + const r = await api<{ supply: number | string }>(path, { fetch: f, timeoutMs }); + return { value: r?.supply ?? null, notFound: false, error: null }; + } catch (e) { + const notFound = e instanceof ApiError && (e.status === 404 || e.status === 400); + return { value: null, notFound, error: notFound ? null : (e as Error).message }; + } +} + +/* A list for a tab: never throws, so one slow query only affects its own tab. */ +export async function loadList(path: string, f: typeof fetch, params: Record, timeoutMs = 15000) { + try { + const rows = await api(path, { fetch: f, params, timeoutMs }); + return { rows: rows ?? [], error: null as string | null }; + } catch (e) { + return { rows: [] as T[], error: (e as Error).message }; + } +} diff --git a/applications/viewer/src/lib/assets/script.ts b/applications/viewer/src/lib/assets/script.ts new file mode 100644 index 00000000..5540af59 --- /dev/null +++ b/applications/viewer/src/lib/assets/script.ts @@ -0,0 +1,9 @@ +/* Yaci Store reports script types as plutusV1/V2/V3, timelock (native), or PLUTUS_V3-style enums in tx scripts. */ +export function scriptTypeLabel(type: string | null | undefined): string { + const t = String(type ?? '').toLowerCase().replace(/[_\s]/g, ''); + if (t.startsWith('plutusv')) return `Plutus V${t.slice(7)}`; + if (t === 'timelock' || t.includes('native') || t === 'multisig') return 'Native script'; + return type ? String(type) : 'Script'; +} + +export const isPlutus = (type: string | null | undefined) => String(type ?? '').toLowerCase().includes('plutus'); diff --git a/applications/viewer/src/lib/assets/types.ts b/applications/viewer/src/lib/assets/types.ts new file mode 100644 index 00000000..fc94258b --- /dev/null +++ b/applications/viewer/src/lib/assets/types.ts @@ -0,0 +1,16 @@ +/* One mint or burn event, as /assets/.../history returns it. */ +export interface MintEvent { + tx_hash: string; + block_number: number; + block_time: number; + slot: number; + policy: string; + unit: string; + fingerprint?: string; + asset_name?: string; + quantity: number | string; + mint_type?: 'MINT' | 'BURN' | string; +} + +/** Hue for an asset avatar, stable per policy. */ +export const policyHue = (policy: string) => parseInt(policy.slice(0, 6) || '0', 16) % 360; diff --git a/applications/viewer/src/lib/dashboard/BlockConveyor.svelte b/applications/viewer/src/lib/dashboard/BlockConveyor.svelte index 530c9f47..5913292c 100644 --- a/applications/viewer/src/lib/dashboard/BlockConveyor.svelte +++ b/applications/viewer/src/lib/dashboard/BlockConveyor.svelte @@ -22,7 +22,7 @@ const kb = (bytes: number) => (bytes >= 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${bytes} B`); -
+
    {#if tip} diff --git a/applications/viewer/src/lib/dashboard/EpochCard.svelte b/applications/viewer/src/lib/dashboard/EpochCard.svelte index 2f07eafe..f3fb6b53 100644 --- a/applications/viewer/src/lib/dashboard/EpochCard.svelte +++ b/applications/viewer/src/lib/dashboard/EpochCard.svelte @@ -22,7 +22,7 @@
    Epoch
    - {e ? num(e.epoch) : '—'} + {e ? num(e.epoch) : '—'}
    Ends in
    diff --git a/applications/viewer/src/lib/epochs/ParamsView.svelte b/applications/viewer/src/lib/epochs/ParamsView.svelte new file mode 100644 index 00000000..955704ff --- /dev/null +++ b/applications/viewer/src/lib/epochs/ParamsView.svelte @@ -0,0 +1,100 @@ + + +
    +
    + + +
    + {shown.reduce((s, grp) => s + grp.rows.length, 0)} shown +
    + +{#if !shown.length && !models.length} + +{:else} +
    + {#each shown as grp (grp.label)} +
    +

    {grp.label}

    +
    + {#each grp.rows as r (r.key)} +
    +
    +
    {r.label}
    +
    {r.key}{r.hint ? ` · ${r.hint}` : ''}
    +
    +
    + {show(r)} + +
    +
    + {/each} +
    +
    + {/each} +
    +{/if} + +{#if models.length && (!q || 'cost models'.includes(q) || models.some(([k]) => k.toLowerCase().includes(q)))} +
    +

    Plutus cost models

    +
      + {#each models as [lang, model] (lang)} +
    • + + {#if openModels === lang}
      {/if} +
    • + {/each} +
    +
    +{/if} diff --git a/applications/viewer/src/lib/epochs/TrendChart.svelte b/applications/viewer/src/lib/epochs/TrendChart.svelte new file mode 100644 index 00000000..7c0b511e --- /dev/null +++ b/applications/viewer/src/lib/epochs/TrendChart.svelte @@ -0,0 +1,93 @@ + + +
    +
    + {label} + {#if change != null} + {change >= 0 ? '+' : ''}{change.toFixed(2)}% + {/if} +
    + {#if shown} +
    {format(shown.y)}
    +
    epoch {shown.x}{hover == null ? ' · latest' : ''}
    + {/if} + {#if sorted.length > 1} +
    + (hover = null)} + > + + + + + + + + + {#if hover != null && xy[hover]} + + {/if} + + {#if hover != null && xy[hover]} + + {/if} +
    +
    + epoch {first.x}epoch {last.x} +
    + {:else} +

    Needs at least two epochs of data.

    + {/if} +
    diff --git a/applications/viewer/src/lib/epochs/epochs.ts b/applications/viewer/src/lib/epochs/epochs.ts new file mode 100644 index 00000000..c6f0b4f1 --- /dev/null +++ b/applications/viewer/src/lib/epochs/epochs.ts @@ -0,0 +1,84 @@ +import { tryApi } from '$lib/api/client'; + +/* + * Epoch helpers. Yaci Store's /epochs aggregates are optional (a fresh DevKit devnet has none), so pages fall back + * to what every store has: /epochs/latest (the current epoch's real boundaries) and /blocks/epoch/{n}. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; +type Fetch = typeof fetch; + +export interface EpochRow { + number: number; + start: number | null; // seconds + end: number | null; // seconds + /** True when start/end are the epoch's boundaries, false when they are first/last block times. */ + exact: boolean; + blocks: number | null; + txs: number | null; + output: string | number | null; + fees: string | number | null; + activeStake: string | number | null; +} + +export interface LatestEpoch { + epoch: number; + start: number; + end: number; +} + +export async function latestEpoch(fetch: Fetch): Promise { + const l = await tryApi('/epochs/latest', { fetch }); + if (!l || l.epoch == null) return null; + return { epoch: Number(l.epoch), start: Number(l.start_time), end: Number(l.end_time) }; +} + +/** + * Boundaries for epoch n, assuming every epoch since n has the current epoch's length. That holds on devnets and for + * every Shelley-era epoch; `fits` checks it against a block time when there is one. + */ +export function uniformBounds(latest: LatestEpoch, n: number) { + const len = latest.end - latest.start; + const start = latest.start - (latest.epoch - n) * len; + return { start, end: start + len }; +} + +export function fromAggregate(e: Any, latest: LatestEpoch | null): EpochRow { + const n = Number(e.number ?? e.epoch); + let start: number | null = e.start_time ?? e.first_block_time ?? null; + let end: number | null = e.end_time ?? e.last_block_time ?? null; + let exact = false; + if (latest) { + const u = uniformBounds(latest, n); + // Use real boundaries when the block times sit inside them. + if (start == null || (start >= u.start && start <= u.end)) { + start = u.start; + end = u.end; + exact = true; + } + } + return { + number: n, + start, + end, + exact, + blocks: e.block_count ?? null, + txs: e.transaction_count ?? e.tx_count ?? null, + output: e.total_output ?? e.output ?? null, + fees: e.total_fees ?? e.fees ?? null, + activeStake: e.active_stake && e.active_stake !== '0' ? e.active_stake : null + }; +} + +/** Active stake for epoch n (missing for the first epochs and on stores without stake snapshots). */ +export async function activeStake(fetch: Fetch, n: number, timeoutMs = 8000): Promise { + const s = await tryApi(`/epochs/${n}/total-stake`, { fetch, timeoutMs }); + return s?.active_stake ?? null; +} + +/** Block count for epoch n from the paged blocks endpoint's total. */ +export async function blockCount(fetch: Fetch, n: number): Promise { + const b = await tryApi(`/blocks/epoch/${n}`, { fetch, params: { page: 1, count: 1 }, timeoutMs: 8000 }); + return b?.total ?? null; +} diff --git a/applications/viewer/src/lib/epochs/params.ts b/applications/viewer/src/lib/epochs/params.ts new file mode 100644 index 00000000..22cf6d76 --- /dev/null +++ b/applications/viewer/src/lib/epochs/params.ts @@ -0,0 +1,111 @@ +/* Protocol parameters grouped for reading, with friendly names and units. */ + +export type Unit = 'lovelace' | 'percent' | 'ratio' | 'bytes' | 'number' | 'epochs' | 'text'; + +interface Def { + key: string; + label: string; + unit: Unit; + hint?: string; +} + +const g = (label: string, defs: Def[]) => ({ label, defs }); + +export const PARAM_GROUPS = [ + g('Fees', [ + { key: 'min_fee_a', label: 'Fee per byte', unit: 'number', hint: 'lovelace per tx byte' }, + { key: 'min_fee_b', label: 'Base fee', unit: 'lovelace' }, + { key: 'min_fee_ref_script_cost_per_byte', label: 'Reference script fee', unit: 'number', hint: 'lovelace per byte' }, + { key: 'price_mem', label: 'Price per memory unit', unit: 'number', hint: 'lovelace' }, + { key: 'price_step', label: 'Price per CPU step', unit: 'number', hint: 'lovelace' } + ]), + g('Sizes and limits', [ + { key: 'max_block_size', label: 'Max block body size', unit: 'bytes' }, + { key: 'max_tx_size', label: 'Max transaction size', unit: 'bytes' }, + { key: 'max_block_header_size', label: 'Max block header size', unit: 'bytes' }, + { key: 'max_val_size', label: 'Max value size', unit: 'bytes' }, + { key: 'max_collateral_inputs', label: 'Max collateral inputs', unit: 'number' }, + { key: 'collateral_percent', label: 'Collateral', unit: 'number', hint: '% of the fee' } + ]), + g('Plutus execution', [ + { key: 'max_tx_ex_mem', label: 'Max memory per transaction', unit: 'number' }, + { key: 'max_tx_ex_steps', label: 'Max CPU steps per transaction', unit: 'number' }, + { key: 'max_block_ex_mem', label: 'Max memory per block', unit: 'number' }, + { key: 'max_block_ex_steps', label: 'Max CPU steps per block', unit: 'number' } + ]), + g('Deposits and minimums', [ + { key: 'key_deposit', label: 'Stake key deposit', unit: 'lovelace' }, + { key: 'pool_deposit', label: 'Pool deposit', unit: 'lovelace' }, + { key: 'drep_deposit', label: 'DRep deposit', unit: 'lovelace' }, + { key: 'gov_action_deposit', label: 'Governance action deposit', unit: 'lovelace' }, + { key: 'min_pool_cost', label: 'Min pool cost', unit: 'lovelace' }, + { key: 'coins_per_utxo_size', label: 'Lovelace per UTxO byte', unit: 'number' }, + { key: 'min_utxo', label: 'Min UTxO', unit: 'lovelace' } + ]), + g('Staking and rewards', [ + { key: 'n_opt', label: 'Target number of pools (k)', unit: 'number' }, + { key: 'a0', label: 'Pledge influence (a0)', unit: 'ratio' }, + { key: 'rho', label: 'Monetary expansion (ρ)', unit: 'ratio' }, + { key: 'tau', label: 'Treasury cut (τ)', unit: 'ratio' }, + { key: 'e_max', label: 'Max pool retirement', unit: 'epochs' }, + { key: 'decentralisation_param', label: 'Decentralisation (d)', unit: 'ratio' } + ]), + g('Governance', [ + { key: 'gov_action_lifetime', label: 'Governance action lifetime', unit: 'epochs' }, + { key: 'drep_activity', label: 'DRep activity period', unit: 'epochs' }, + { key: 'committee_min_size', label: 'Committee min size', unit: 'number' }, + { key: 'committee_max_term_length', label: 'Committee max term', unit: 'epochs' }, + { key: 'pvt_motion_no_confidence', label: 'SPO: no confidence', unit: 'percent' }, + { key: 'pvt_committee_normal', label: 'SPO: update committee', unit: 'percent' }, + { key: 'pvt_committee_no_confidence', label: 'SPO: committee in no confidence', unit: 'percent' }, + { key: 'pvt_hard_fork_initiation', label: 'SPO: hard fork', unit: 'percent' }, + { key: 'pvt_p_p_security_group', label: 'SPO: security parameters', unit: 'percent' }, + { key: 'dvt_motion_no_confidence', label: 'DRep: no confidence', unit: 'percent' }, + { key: 'dvt_committee_normal', label: 'DRep: update committee', unit: 'percent' }, + { key: 'dvt_committee_no_confidence', label: 'DRep: committee in no confidence', unit: 'percent' }, + { key: 'dvt_update_to_constitution', label: 'DRep: new constitution', unit: 'percent' }, + { key: 'dvt_hard_fork_initiation', label: 'DRep: hard fork', unit: 'percent' }, + { key: 'dvt_p_p_network_group', label: 'DRep: network parameters', unit: 'percent' }, + { key: 'dvt_p_p_economic_group', label: 'DRep: economic parameters', unit: 'percent' }, + { key: 'dvt_p_p_technical_group', label: 'DRep: technical parameters', unit: 'percent' }, + { key: 'dvt_p_p_gov_group', label: 'DRep: governance parameters', unit: 'percent' }, + { key: 'dvt_treasury_withdrawal', label: 'DRep: treasury withdrawal', unit: 'percent' } + ]), + g('Protocol', [ + { key: 'protocol_major_ver', label: 'Protocol major version', unit: 'number' }, + { key: 'protocol_minor_ver', label: 'Protocol minor version', unit: 'number' }, + { key: 'extra_entropy', label: 'Extra entropy', unit: 'text' }, + { key: 'nonce', label: 'Epoch nonce', unit: 'text' } + ]) +]; + +/** Keys shown elsewhere or not worth a row. */ +export const HIDDEN = new Set(['epoch', 'cost_models', 'cost_models_raw', 'cost_models_hash', 'pvtpp_security_group']); + +export interface ParamRow { + key: string; + label: string; + unit: Unit; + hint?: string; + value: unknown; +} + +/** Groups with only the keys this store returned, plus an "Other" group for anything unknown. */ +export function groupParams(params: Record): { label: string; rows: ParamRow[] }[] { + const known = new Set(); + const groups = PARAM_GROUPS.map((grp) => ({ + label: grp.label, + rows: grp.defs + .filter((d) => { + known.add(d.key); + return params[d.key] !== undefined && params[d.key] !== null; + }) + .map((d) => ({ ...d, value: params[d.key] })) + })); + const other = Object.keys(params) + .filter((k) => !known.has(k) && !HIDDEN.has(k) && params[k] !== null && typeof params[k] !== 'object') + .sort() + .map((k) => ({ key: k, label: k.replace(/_/g, ' '), unit: 'text' as Unit, value: params[k] })); + if (other.length) groups.push({ label: 'Other', rows: other }); + return groups.filter((x) => x.rows.length); +} diff --git a/applications/viewer/src/lib/epochs/pots.ts b/applications/viewer/src/lib/epochs/pots.ts new file mode 100644 index 00000000..9033aee9 --- /dev/null +++ b/applications/viewer/src/lib/epochs/pots.ts @@ -0,0 +1,28 @@ +/* Ada pot fields, in the order they read best, with a colour per pot. */ +export interface AdaPot { + epoch: number; + treasury?: number | null; + reserves?: number | null; + circulation?: number | null; + rewards_pot?: number | null; + pool_rewards_pot?: number | null; + distributed_rewards?: number | null; + undistributed_rewards?: number | null; + fees?: number | null; + deposits_stake?: number | null; +} + +export const POTS: { key: keyof AdaPot; label: string; hint: string; color: string }[] = [ + { key: 'treasury', label: 'Treasury', hint: 'Funds governance can spend', color: 'cyan' }, + { key: 'reserves', label: 'Reserves', hint: 'ADA not yet in circulation', color: 'violet' }, + { key: 'circulation', label: 'Circulation', hint: 'ADA held by everyone else', color: 'brand' }, + { key: 'rewards_pot', label: 'Rewards pot', hint: 'Reserves expansion plus fees, for this epoch', color: 'mint' }, + { key: 'pool_rewards_pot', label: 'Pool rewards pot', hint: 'Rewards pot after the treasury cut', color: 'mint' }, + { key: 'distributed_rewards', label: 'Distributed rewards', hint: 'Paid to pools and delegators', color: 'brand' }, + { key: 'undistributed_rewards', label: 'Undistributed rewards', hint: 'Returned to the reserves', color: 'amber' }, + { key: 'fees', label: 'Fees', hint: 'Transaction fees collected', color: 'amber' }, + { key: 'deposits_stake', label: 'Stake deposits', hint: 'Key, pool and DRep deposits held', color: 'blue' } +]; + +/** Lovelace → ADA as a float, for charts. */ +export const toAda = (v: number | string | null | undefined) => (v == null ? NaN : Number(v) / 1_000_000); diff --git a/applications/viewer/src/lib/governance/AnchorDoc.svelte b/applications/viewer/src/lib/governance/AnchorDoc.svelte new file mode 100644 index 00000000..88fa62ac --- /dev/null +++ b/applications/viewer/src/lib/governance/AnchorDoc.svelte @@ -0,0 +1,86 @@ + + +
    + {#if title}

    {title}

    {/if} + + {#if authors.length} +
    + {#each authors as a, i (i)} + {text(a.name) || 'Anonymous'}{a.witness ? ' · signed' : ''} + {/each} +
    + {/if} + + {#each sections as [label, value] (label)} +
    +

    {label}

    +

    {value}

    +
    + {/each} + + {#if references.length} +
    +

    References

    +
      + {#each references as r, i (i)} + {@const href = safeHref(text(r.uri))} +
    • + {#if href} + + {text(r.label) || text(r.uri)} + + {:else} + {text(r.label) || text(r.uri)} + {/if} + {#if r['@type']}{text(r['@type'])}{/if} +
    • + {/each} +
    +
    + {/if} + + {#if !title && !sections.length && !references.length} +

    This document doesn't follow CIP-108 or CIP-119, so there is nothing to lay out. The raw JSON is below.

    + {/if} +
    diff --git a/applications/viewer/src/lib/governance/AnchorLink.svelte b/applications/viewer/src/lib/governance/AnchorLink.svelte new file mode 100644 index 00000000..6861db11 --- /dev/null +++ b/applications/viewer/src/lib/governance/AnchorLink.svelte @@ -0,0 +1,17 @@ + + +{#if url} + + + {shorten(url.replace(/^https?:\/\//, ''), 26, 16)} + +{:else} + None +{/if} diff --git a/applications/viewer/src/lib/governance/DRepCertList.svelte b/applications/viewer/src/lib/governance/DRepCertList.svelte new file mode 100644 index 00000000..13d5d0f2 --- /dev/null +++ b/applications/viewer/src/lib/governance/DRepCertList.svelte @@ -0,0 +1,69 @@ + + + + {#snippet icon()}{/snippet} + {TEXT[kind].sub} + {#snippet actions()} + + {/snippet} + + +
    + r.tx_hash + r.cert_index} + rowHref={(r) => `/governance/dreps/${drepId(r)}`} + emptyTitle="Nothing yet" + emptyText={TEXT[kind].empty} + > + {#snippet cell(r, key)} + {#if key === 'drep'}{shorten(drepId(r), 14, 8)} + {:else if key === 'deposit'} + {:else if key === 'anchor'} + {:else if key === 'type'}{credLabel(r.cred_type)} + {:else if key === 'tx'}{shorten(r.tx_hash, 8, 6)} + {:else}{/if} + {/snippet} + + +
    diff --git a/applications/viewer/src/lib/governance/ProposalTable.svelte b/applications/viewer/src/lib/governance/ProposalTable.svelte new file mode 100644 index 00000000..23f1aef5 --- /dev/null +++ b/applications/viewer/src/lib/governance/ProposalTable.svelte @@ -0,0 +1,44 @@ + + + `${p.tx_hash}_${p.index}`} + rowHref={(p) => actionHref(p.tx_hash, p.index)} + emptyTitle="No governance actions" + emptyText={emptyText ?? 'Proposals submitted to this chain show up here.'} +> + {#snippet cell(p, key)} + {#if key === 'action'} + + {actionLabel(p.type)} + {shorten(p.tx_hash, 10, 6)}#{p.index} + + {:else if key === 'status'} + {#if p.status}{statusLabel(p.status)}{:else}—{/if} + {:else if key === 'deposit'} + + {:else if key === 'epoch'} + {p.epoch ?? '—'} + {:else if key === 'age'} + {#if p.block_time}{:else}—{/if} + {/if} + {/snippet} + diff --git a/applications/viewer/src/lib/governance/VoteTally.svelte b/applications/viewer/src/lib/governance/VoteTally.svelte new file mode 100644 index 00000000..4980d8ea --- /dev/null +++ b/applications/viewer/src/lib/governance/VoteTally.svelte @@ -0,0 +1,45 @@ + + +
    + {#each groups as g (g)} + {@const t = tally[g]} +
    +
    + {VOTER_LABEL[g]} + + {#if ratios[g] != null}{(Number(ratios[g]) * 100).toFixed(1)}% approval ·{/if} + {total(t)} vote{total(t) === 1 ? '' : 's'} + +
    + +
    + Yes {t.yes} + No {t.no} + Abstain {t.abstain} +
    +
    + {/each} + {#if partial}

    Counted from the most recent votes only; this action has more.

    {/if} +
    diff --git a/applications/viewer/src/lib/governance/anchor-url.ts b/applications/viewer/src/lib/governance/anchor-url.ts new file mode 100644 index 00000000..e5d640cd --- /dev/null +++ b/applications/viewer/src/lib/governance/anchor-url.ts @@ -0,0 +1,32 @@ +/* + * Which anchor URLs the viewer's server may fetch. Anchors are user-controlled (anyone can put a URL on chain, and + * anyone can type one into /governance/anchor?url=), so the server must never be turned into a proxy for the local + * network: only http(s) on standard ports and ipfs://, and never private, loopback, link-local or other special + * addresses. Hostnames are checked again after DNS resolution (see $lib/server/safe-fetch.ts). + */ + +import { checkPublicUrl } from '$lib/net/public-url'; + +export { isBlockedIp, ipKind } from '$lib/net/public-url'; + +/** Public gateways, tried in order (they rate-limit). A gateway set with IPFS_GATEWAY goes first. */ +export const IPFS_GATEWAYS = ['https://ipfs.io/ipfs/', 'https://dweb.link/ipfs/', 'https://gateway.pinata.cloud/ipfs/', 'https://ipfs.blockfrost.dev/ipfs/']; + +export type AnchorCheck = { ok: true; url: URL; display: string; alternates?: URL[] } | { ok: false; reason: string }; + +export function checkAnchorUrl(raw: string | null | undefined, gateways: string[] = IPFS_GATEWAYS): AnchorCheck { + const input = (raw ?? '').trim(); + if (!input) return { ok: false, reason: 'No anchor URL given.' }; + if (input.length > 2048) return { ok: false, reason: 'That URL is too long.' }; + + if (/^ipfs:\/\//i.test(input)) { + // ipfs:/// → public gateways, tried in order. + const rest = input.slice('ipfs://'.length).replace(/^ipfs\//, ''); + if (!/^[a-zA-Z0-9]{20,}(\/[^?#\s]*)?$/.test(rest)) return { ok: false, reason: 'That IPFS link is not valid.' }; + const [first, ...others] = gateways.map((g) => new URL(g.replace(/\/?$/, '/') + rest)); + return { ok: true, url: first, display: input, alternates: others }; + } + const checked = checkPublicUrl(input); + if (!checked.ok) return checked.reason === 'Only http and https URLs can be opened.' ? { ok: false, reason: 'Only http, https and ipfs anchors can be opened.' } : checked; + return { ok: true, url: checked.url, display: input }; +} diff --git a/applications/viewer/src/lib/governance/anchor.server.ts b/applications/viewer/src/lib/governance/anchor.server.ts new file mode 100644 index 00000000..654aa3e7 --- /dev/null +++ b/applications/viewer/src/lib/governance/anchor.server.ts @@ -0,0 +1,52 @@ +import { env } from '$env/dynamic/private'; +import { SafeFetchError, safeGet } from '$lib/server/safe-fetch'; +import { IPFS_GATEWAYS, checkAnchorUrl } from './anchor-url'; +import { blake2b256Hex } from './blake2b'; + +/* + * Fetch a governance anchor document through safeGet (no private addresses, checked again after DNS and on every + * redirect), with a 1 MB cap and JSON only. IPFS anchors try the next gateway when one fails; a gateway set + * in IPFS_GATEWAY is tried first (it must be public too). The whole lookup has a 12 s budget. + */ + +const BUDGET_MS = 12000; + +export class AnchorError extends Error {} + +export interface AnchorResult { + url: string; + fetchedFrom: string; + json: unknown; + /** BLAKE2b-256 of the raw bytes, to compare with the hash on chain. */ + hash: string; +} + +export async function fetchAnchor(raw: string): Promise { + const gateways = env.IPFS_GATEWAY ? [env.IPFS_GATEWAY, ...IPFS_GATEWAYS] : IPFS_GATEWAYS; + const first = checkAnchorUrl(raw, gateways); + if (!first.ok) throw new AnchorError(first.reason); + const deadline = Date.now() + BUDGET_MS; + let lastError: unknown; + for (const start of [first.url, ...(first.alternates ?? [])]) { + const left = deadline - Date.now(); + if (left < 500) break; + try { + const res = await safeGet(start, { + maxBytes: 1024 * 1024, + timeoutMs: Math.min(6000, left), + maxRedirects: 3, + accept: 'application/ld+json, application/json;q=0.9, */*;q=0.1' + }); + let json: unknown; + try { + json = JSON.parse(new TextDecoder().decode(res.body)); + } catch { + throw new AnchorError('The document is not JSON.'); + } + return { url: first.display, fetchedFrom: res.url.href, json, hash: blake2b256Hex(res.body) }; + } catch (e) { + lastError = e instanceof SafeFetchError ? new AnchorError(e.message) : e; + } + } + throw lastError ?? new AnchorError('The anchor host took too long to answer.'); +} diff --git a/applications/viewer/src/lib/governance/anchor.test.ts b/applications/viewer/src/lib/governance/anchor.test.ts new file mode 100644 index 00000000..6211034c --- /dev/null +++ b/applications/viewer/src/lib/governance/anchor.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { checkAnchorUrl, isBlockedIp } from './anchor-url'; +import { blake2b256Hex } from './blake2b'; + +const refused = (u: string) => !checkAnchorUrl(u).ok; + +describe('checkAnchorUrl', () => { + it('accepts public http(s) and maps ipfs to a gateway', () => { + expect(checkAnchorUrl('https://example.com/meta.jsonld').ok).toBe(true); + expect(checkAnchorUrl('http://example.com:80/a').ok).toBe(true); + const ipfs = checkAnchorUrl('ipfs://bafkreigulsqznngqsp7ry3zkkma4h4zpb5ff7ngrgba4t6fv5wbetxbu6m'); + expect(ipfs.ok && ipfs.url.href).toBe('https://ipfs.io/ipfs/bafkreigulsqznngqsp7ry3zkkma4h4zpb5ff7ngrgba4t6fv5wbetxbu6m'); + expect(ipfs.ok && ipfs.alternates?.length).toBe(3); + }); + + it('refuses other schemes, credentials and odd ports', () => { + expect(refused('file:///etc/passwd')).toBe(true); + expect(refused('ftp://example.com/x')).toBe(true); + expect(refused('javascript:alert(1)')).toBe(true); + expect(refused('gopher://example.com')).toBe(true); + expect(refused('https://user:pw@example.com/')).toBe(true); + expect(refused('http://example.com:8080/')).toBe(true); + expect(refused('ipfs://../../etc')).toBe(true); + expect(refused('')).toBe(true); + }); + + it('refuses local hosts and private address literals', () => { + for (const u of [ + 'http://localhost:8080/api', + 'http://localhost/', + 'http://foo.localhost/', + 'http://printer.local/', + 'http://intranet/', + 'http://127.0.0.1/', + 'http://127.1.2.3/', + 'http://10.0.0.5/', + 'http://172.20.0.1/', + 'http://192.168.1.1/', + 'http://169.254.169.254/latest/meta-data/', + 'http://0.0.0.0/', + 'http://[::1]/', + 'http://[::ffff:127.0.0.1]/', + 'http://[::ffff:7f00:1]/', + 'http://[fe80::1]/', + 'http://[fd00::1]/', + 'http://2130706433/', + 'http://0x7f000001/' + ]) { + expect(refused(u), u).toBe(true); + } + }); +}); + +describe('isBlockedIp', () => { + it('blocks special ranges, including IPv6 forms that embed IPv4', () => { + for (const ip of ['127.0.0.1', '10.1.2.3', '100.64.0.1', '172.31.255.255', '192.168.0.1', '169.254.169.254', '224.0.0.1', '255.255.255.255', '::', '::1', '::ffff:10.0.0.1', '64:ff9b::7f00:1', '2002:7f00:1::', 'fc00::1', 'fe80::abcd', 'not-an-ip']) { + expect(isBlockedIp(ip), ip).toBe(true); + } + }); + + it('allows public addresses', () => { + for (const ip of ['1.1.1.1', '93.184.216.34', '172.32.0.1', '2606:4700:4700::1111', '::ffff:8.8.8.8']) { + expect(isBlockedIp(ip), ip).toBe(false); + } + }); +}); + +describe('blake2b256Hex', () => { + it('matches the RFC test vectors', () => { + expect(blake2b256Hex(new TextEncoder().encode('abc'))).toBe('bddd813c634239723171ef3fee98579b94964e3bb1cb3e427262c8c068d52319'); + expect(blake2b256Hex(new Uint8Array())).toBe('0e5751c026e543b2e8ab2eb06099daa1d1e5df47778f7787faab45cdf12fe3a8'); + // Longer than one block + expect(blake2b256Hex(new Uint8Array(300).fill(97))).toHaveLength(64); + }); +}); + +import { rewardHexToStake } from './reward'; + +describe('rewardHexToStake', () => { + it('encodes reward addresses', () => { + expect(rewardHexToStake('e19bf9954990de3a15a4e6a8923cb2d3aa2521a75c8dc2997f46dee8dd')).toMatch(/^stake1/); + expect(rewardHexToStake('e0' + '00'.repeat(28))).toMatch(/^stake_test1/); + expect(rewardHexToStake('zz')).toBeNull(); + }); +}); + +import { drepIdCandidates, drepIdFromHash } from './drep'; + +describe('drep ids', () => { + it('builds CIP-129 ids from hashes', () => { + expect(drepIdFromHash('69077c7a93a4131767c72721d187b5d386eaaa70d9b4022dcb77fc9d')).toBe('drep1yf5swlr6jwjpx9m8cunjr5v8khfcd642wrvmgq3dedmle8g8lrfgp'); + expect(drepIdCandidates('drep1yf5swlr6jwjpx9m8cunjr5v8khfcd642wrvmgq3dedmle8g8lrfgp')).toEqual(['drep1yf5swlr6jwjpx9m8cunjr5v8khfcd642wrvmgq3dedmle8g8lrfgp']); + expect(drepIdCandidates('69077c7a93a4131767c72721d187b5d386eaaa70d9b4022dcb77fc9d')).toHaveLength(2); + expect(drepIdCandidates('nonsense')).toEqual([]); + }); +}); diff --git a/applications/viewer/src/lib/governance/blake2b.ts b/applications/viewer/src/lib/governance/blake2b.ts new file mode 100644 index 00000000..3f3a8e54 --- /dev/null +++ b/applications/viewer/src/lib/governance/blake2b.ts @@ -0,0 +1,75 @@ +/* + * BLAKE2b with a configurable digest length (RFC 7693). Cardano anchor hashes are BLAKE2b-256 of the document + * bytes; Node's crypto only offers BLAKE2b-512, so this small implementation lets the viewer verify them. + */ + +const IV = [ + 0x6a09e667f3bcc908n, 0xbb67ae8584caa73bn, 0x3c6ef372fe94f82bn, 0xa54ff53a5f1d36f1n, + 0x510e527fade682d1n, 0x9b05688c2b3e6c1fn, 0x1f83d9abfb41bd6bn, 0x5be0cd19137e2179n +]; + +const SIGMA = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], + [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], + [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], + [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], + [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0] +]; + +const MASK = 0xffffffffffffffffn; +const rotr = (x: bigint, n: bigint) => ((x >> n) | (x << (64n - n))) & MASK; + +function compress(h: bigint[], block: Uint8Array, t: bigint, last: boolean) { + const m: bigint[] = []; + const view = new DataView(block.buffer, block.byteOffset, 128); + for (let i = 0; i < 16; i++) m.push(view.getBigUint64(i * 8, true)); + const v = [...h, ...IV]; + v[12] ^= t & MASK; + v[13] ^= t >> 64n; + if (last) v[14] ^= MASK; + const g = (a: number, b: number, c: number, d: number, x: bigint, y: bigint) => { + v[a] = (v[a] + v[b] + x) & MASK; + v[d] = rotr(v[d] ^ v[a], 32n); + v[c] = (v[c] + v[d]) & MASK; + v[b] = rotr(v[b] ^ v[c], 24n); + v[a] = (v[a] + v[b] + y) & MASK; + v[d] = rotr(v[d] ^ v[a], 16n); + v[c] = (v[c] + v[d]) & MASK; + v[b] = rotr(v[b] ^ v[c], 63n); + }; + for (let r = 0; r < 12; r++) { + const s = SIGMA[r % 10]; + g(0, 4, 8, 12, m[s[0]], m[s[1]]); + g(1, 5, 9, 13, m[s[2]], m[s[3]]); + g(2, 6, 10, 14, m[s[4]], m[s[5]]); + g(3, 7, 11, 15, m[s[6]], m[s[7]]); + g(0, 5, 10, 15, m[s[8]], m[s[9]]); + g(1, 6, 11, 12, m[s[10]], m[s[11]]); + g(2, 7, 8, 13, m[s[12]], m[s[13]]); + g(3, 4, 9, 14, m[s[14]], m[s[15]]); + } + for (let i = 0; i < 8; i++) h[i] ^= v[i] ^ v[i + 8]; +} + +export function blake2b(data: Uint8Array, outLen = 32): Uint8Array { + const h = [...IV]; + h[0] ^= 0x01010000n ^ BigInt(outLen); + const blocks = Math.max(1, Math.ceil(data.length / 128)); + for (let i = 0; i < blocks; i++) { + const block = new Uint8Array(128); + block.set(data.subarray(i * 128, i * 128 + 128)); + const last = i === blocks - 1; + compress(h, block, BigInt(last ? data.length : (i + 1) * 128), last); + } + const out = new Uint8Array(64); + const view = new DataView(out.buffer); + h.forEach((x, i) => view.setBigUint64(i * 8, x, true)); + return out.slice(0, outLen); +} + +export const blake2b256Hex = (data: Uint8Array) => Array.from(blake2b(data, 32), (b) => b.toString(16).padStart(2, '0')).join(''); diff --git a/applications/viewer/src/lib/governance/certs.ts b/applications/viewer/src/lib/governance/certs.ts new file mode 100644 index 00000000..0b3d6198 --- /dev/null +++ b/applications/viewer/src/lib/governance/certs.ts @@ -0,0 +1,24 @@ +import { error } from '@sveltejs/kit'; +import { api, paging } from '$lib/api/client'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export type CertKind = 'registrations' | 'updates' | 'deregistrations'; + +export const CERT_KINDS: { id: CertKind; label: string; href: string }[] = [ + { id: 'registrations', label: 'Registrations', href: '/governance/drep-registrations' }, + { id: 'updates', label: 'Updates', href: '/governance/drep-updates' }, + { id: 'deregistrations', label: 'Retirements', href: '/governance/drep-deregistrations' } +]; + +/* DRep certificates of one kind (/governance/dreps/{kind}, 1-based). */ +export async function loadCerts(kind: CertKind, url: URL, f: typeof fetch) { + const { page, count } = paging(url, 20); + try { + const rows = await api(`/governance/dreps/${kind}`, { fetch: f, params: { page, count, order: 'desc' } }); + return { kind, rows: rows ?? [], page, count }; + } catch (e) { + error(503, (e as Error).message); + } +} diff --git a/applications/viewer/src/lib/governance/data.ts b/applications/viewer/src/lib/governance/data.ts new file mode 100644 index 00000000..215bfc12 --- /dev/null +++ b/applications/viewer/src/lib/governance/data.ts @@ -0,0 +1,55 @@ +import { tryApi } from '$lib/api/client'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export interface CommitteeMember { + hash: string; + cred_type: string; + start_epoch: number | null; + expired_epoch: number | null; + active: boolean; +} + +export interface Committee { + threshold: { numerator: number; denominator: number } | null; + members: CommitteeMember[]; + epoch: number | null; +} + +/* The current committee, members de-duplicated by hash (the store can list a member once per term). */ +export async function loadCommittee(f: typeof fetch): Promise { + const [c, epoch] = await Promise.all([tryApi('/governance/committees/current', { fetch: f }), tryApi('/epochs/latest', { fetch: f })]); + if (!c) return null; + const current = epoch?.epoch ?? null; + const byHash = new Map(); + for (const m of c.members ?? []) { + const prev = byHash.get(m.hash); + if (prev && (prev.expired_epoch ?? 0) >= (m.expired_epoch ?? 0)) continue; + byHash.set(m.hash, { + hash: m.hash, + cred_type: m.cred_type, + start_epoch: m.start_epoch ?? null, + expired_epoch: m.expired_epoch ?? null, + active: current == null || m.expired_epoch == null || m.expired_epoch >= current + }); + } + const members = [...byHash.values()].sort((a, b) => Number(b.active) - Number(a.active) || (a.expired_epoch ?? 0) - (b.expired_epoch ?? 0)); + const d = Number(c.threshold_denominator); + return { + threshold: d ? { numerator: Number(c.threshold_numerator), denominator: d } : null, + members, + epoch: current + }; +} + +export interface Constitution { + active_epoch?: number; + anchor_url?: string; + anchor_hash?: string; + script?: string | null; +} + +export async function loadConstitution(f: typeof fetch): Promise { + return (await tryApi('/governance/constitution', { fetch: f })) ?? (await tryApi('/governance/live/constitution', { fetch: f })); +} diff --git a/applications/viewer/src/lib/governance/drep.ts b/applications/viewer/src/lib/governance/drep.ts new file mode 100644 index 00000000..8296d5ad --- /dev/null +++ b/applications/viewer/src/lib/governance/drep.ts @@ -0,0 +1,31 @@ +import { bech32Decode, bech32Encode } from '$lib/bech32'; + +/* + * DRep ids. Yaci Store uses CIP-129 ids (drep1 + a header byte: 0x22 key, 0x23 script). Votes and certificates + * sometimes only carry the 28-byte hash, and older tools use CIP-105 ids (drep1 / drep_script1 without a header). + */ + +const hexToBytes = (h: string) => new Uint8Array(h.match(/../g)!.map((b) => parseInt(b, 16))); + +export function drepIdFromHash(hash: string, script = false): string | null { + if (!/^[0-9a-f]{56}$/i.test(hash)) return null; + return bech32Encode('drep', new Uint8Array([script ? 0x23 : 0x22, ...hexToBytes(hash)])); +} + +/** Candidate CIP-129 ids for whatever was typed or linked: a CIP-129 id, a CIP-105 id, or a hex hash. */ +export function drepIdCandidates(input: string): string[] { + const s = input.trim(); + if (/^[0-9a-f]{56}$/i.test(s)) return [drepIdFromHash(s)!, drepIdFromHash(s, true)!]; + const d = bech32Decode(s); + if (!d) return []; + if ((d.hrp === 'drep' || d.hrp === 'drep_script') && d.bytes.length === 28) { + const hex = Array.from(d.bytes, (b) => b.toString(16).padStart(2, '0')).join(''); + return [drepIdFromHash(hex, d.hrp === 'drep_script')!]; + } + if (d.hrp === 'drep' && d.bytes.length === 29) return [s.toLowerCase()]; + return []; +} + +export function drepHrefFor(voterType: string | undefined, hash: string, drepId?: string | null): string { + return `/governance/dreps/${drepId ?? drepIdFromHash(hash, (voterType ?? '').includes('SCRIPT')) ?? hash}`; +} diff --git a/applications/viewer/src/lib/governance/labels.ts b/applications/viewer/src/lib/governance/labels.ts new file mode 100644 index 00000000..a0879a24 --- /dev/null +++ b/applications/viewer/src/lib/governance/labels.ts @@ -0,0 +1,145 @@ +import { drepHrefFor } from './drep'; +/** Same tones as $lib/ui/Badge. */ +export type Tone = 'neutral' | 'brand' | 'ok' | 'warn' | 'bad' | 'info' | 'violet' | 'cyan'; + +/* Human names and colours for Conway governance values from Yaci Store. */ + +export const ACTION_LABEL: Record = { + PARAMETER_CHANGE_ACTION: 'Parameter change', + HARD_FORK_INITIATION_ACTION: 'Hard fork', + TREASURY_WITHDRAWALS_ACTION: 'Treasury withdrawal', + NO_CONFIDENCE: 'No confidence', + UPDATE_COMMITTEE: 'Update committee', + NEW_CONSTITUTION: 'New constitution', + INFO_ACTION: 'Info' +}; + +export const ACTION_TONE: Record = { + PARAMETER_CHANGE_ACTION: 'cyan', + HARD_FORK_INITIATION_ACTION: 'bad', + TREASURY_WITHDRAWALS_ACTION: 'warn', + NO_CONFIDENCE: 'bad', + UPDATE_COMMITTEE: 'violet', + NEW_CONSTITUTION: 'info', + INFO_ACTION: 'neutral' +}; + +export const actionLabel = (t: string | null | undefined) => (t ? (ACTION_LABEL[t] ?? titleCase(t)) : 'Unknown'); +export const actionTone = (t: string | null | undefined): Tone => (t ? (ACTION_TONE[t] ?? 'neutral') : 'neutral'); + +export const STATUS_TONE: Record = { + LIVE: 'ok', + ACTIVE: 'ok', + RATIFIED: 'info', + ENACTED: 'violet', + EXPIRED: 'neutral', + DROPPED: 'neutral', + INACTIVE: 'warn', + RETIRED: 'neutral' +}; + +export const statusTone = (s: string | null | undefined): Tone => (s ? (STATUS_TONE[s.toUpperCase()] ?? 'neutral') : 'neutral'); +export const statusLabel = (s: string | null | undefined) => (s ? (s.toUpperCase() === 'LIVE' ? 'Voting' : titleCase(s)) : 'Unknown'); + +export type VoterGroup = 'drep' | 'spo' | 'cc'; + +export function voterGroup(voterType: string | null | undefined): VoterGroup { + const t = (voterType ?? '').toUpperCase(); + if (t.startsWith('CONSTITUTIONAL_COMMITTEE')) return 'cc'; + if (t.startsWith('STAKING_POOL')) return 'spo'; + return 'drep'; +} + +export const VOTER_LABEL: Record = { drep: 'DReps', spo: 'Stake pools', cc: 'Constitutional committee' }; + +export const VOTE_TONE: Record = { YES: 'ok', NO: 'bad', ABSTAIN: 'neutral' }; +export const voteTone = (v: string | null | undefined): Tone => VOTE_TONE[(v ?? '').toUpperCase()] ?? 'neutral'; +export const voteLabel = (v: string | null | undefined) => titleCase(v ?? '—'); + +export const CRED_LABEL: Record = { ADDR_KEYHASH: 'Key', SCRIPTHASH: 'Script' }; +export const credLabel = (c: string | null | undefined) => (c ? (CRED_LABEL[c] ?? titleCase(c)) : '—'); + +export function titleCase(s: string) { + const words = s.replace(/_ACTION$/, '').toLowerCase().split('_'); + return words.map((w, i) => (i === 0 ? w.charAt(0).toUpperCase() + w.slice(1) : w)).join(' '); +} + +/** Gov action route id: "_". */ +export const actionHref = (txHash: string, index: number | string) => `/governance/govactions/${txHash}_${index}`; + +/** Where a voter's hash links to. DReps by hash work with the DRep page; others have no page yet. */ +export function voterHref(v: { voter_type?: string; voter_hash?: string; drep_id?: string | null }): string | undefined { + const g = voterGroup(v.voter_type); + if (g === 'drep' && v.voter_hash) return drepHrefFor(v.voter_type, v.voter_hash, v.drep_id); + if (g === 'spo' && v.voter_hash) return `/pools/${v.voter_hash}`; + return undefined; +} + +export const anchorHref = (url: string, hash?: string | null) => + `/governance/anchor?url=${encodeURIComponent(url)}${hash ? `&hash=${hash}` : ''}`; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export interface Proposal { + tx_hash: string; + index: number; + type: string; + status?: string | null; + deposit?: number | string | null; + return_address?: string | null; + anchor_url?: string | null; + anchor_hash?: string | null; + epoch?: number | null; + block_number?: number | null; + block_time?: number | null; + slot?: number | null; + gov_action_id?: string | null; + details?: unknown; + voting_stats?: Record | null; +} + +/** One shape for proposals from /governance-state/proposals (has status) and /governance/proposals (has type). */ +export function toProposal(p: Any): Proposal { + const details = p.gov_action ?? p.details ?? null; + let type = p.type ?? details?.type; + if (!type && details) { + if (details.protocolParamUpdate) type = 'PARAMETER_CHANGE_ACTION'; + else if (details.withdrawals) type = 'TREASURY_WITHDRAWALS_ACTION'; + else if (details.protocolVersion) type = 'HARD_FORK_INITIATION_ACTION'; + else if (details.constitution) type = 'NEW_CONSTITUTION'; + else if (details.newMembersAndTerms || details.membersForRemoval) type = 'UPDATE_COMMITTEE'; + } + return { + tx_hash: p.tx_hash, + index: Number(p.index ?? 0), + type: type ?? 'INFO_ACTION', + status: p.status ?? null, + deposit: p.deposit ?? null, + return_address: p.return_address ?? null, + anchor_url: p.anchor_url ?? null, + anchor_hash: p.anchor_hash ?? null, + epoch: p.epoch ?? null, + block_number: p.block_number ?? null, + block_time: p.block_time ?? null, + slot: p.slot ?? null, + gov_action_id: p.gov_action_id ?? null, + details, + voting_stats: p.voting_stats ?? null + }; +} + +export interface Tally { + yes: number; + no: number; + abstain: number; +} + +export function tallyVotes(votes: { voter_type?: string; vote?: string }[]): Record { + const t: Record = { drep: { yes: 0, no: 0, abstain: 0 }, spo: { yes: 0, no: 0, abstain: 0 }, cc: { yes: 0, no: 0, abstain: 0 } }; + for (const v of votes) { + const k = (v.vote ?? '').toLowerCase() as keyof Tally; + if (k in t.drep) t[voterGroup(v.voter_type)][k]++; + } + return t; +} diff --git a/applications/viewer/src/lib/governance/reward.ts b/applications/viewer/src/lib/governance/reward.ts new file mode 100644 index 00000000..431eb452 --- /dev/null +++ b/applications/viewer/src/lib/governance/reward.ts @@ -0,0 +1,8 @@ +import { bech32Encode } from '$lib/bech32'; + +/** A reward address in hex (header e0/e1/f0/f1 + 28-byte hash) → stake1… / stake_test1…, or null. */ +export function rewardHexToStake(hex: string): string | null { + if (!/^(e0|e1|f0|f1)[0-9a-f]{56}$/i.test(hex)) return null; + const bytes = new Uint8Array(hex.match(/../g)!.map((b) => parseInt(b, 16))); + return bech32Encode((bytes[0] & 0x0f) === 1 ? 'stake' : 'stake_test', bytes); +} diff --git a/applications/viewer/src/lib/live/chain.svelte.ts b/applications/viewer/src/lib/live/chain.svelte.ts index 5c7678d7..49cea47e 100644 --- a/applications/viewer/src/lib/live/chain.svelte.ts +++ b/applications/viewer/src/lib/live/chain.svelte.ts @@ -107,8 +107,11 @@ class Chain { txs = $state([]); network = $state(null); epoch = $state(null); - /** Ticks every second so relative times ("12s ago") stay current. */ - now = $state(Date.now()); + private tick = $state(Date.now()); + /** Ticks every second in the browser, so relative times ("12s ago") stay current. On the server: the real time. */ + get now() { + return typeof window === 'undefined' ? Date.now() : this.tick; + } /** Set when the store can't be reached at all. */ error = $state(null); @@ -134,7 +137,7 @@ class Chain { if (this.started || typeof window === 'undefined') return; this.started = true; this.status = 'connecting'; - setInterval(() => (this.now = Date.now()), 1000); + setInterval(() => (this.tick = Date.now()), 1000); setInterval(() => this.refreshNetwork(), 30000); document.addEventListener('visibilitychange', () => { if (!document.hidden && this.status === 'polling') this.poll(); diff --git a/applications/viewer/src/lib/net/public-url.ts b/applications/viewer/src/lib/net/public-url.ts new file mode 100644 index 00000000..75b23135 --- /dev/null +++ b/applications/viewer/src/lib/net/public-url.ts @@ -0,0 +1,142 @@ +/* + * Whether the viewer's server may fetch a URL that came from chain data or user input. It must never become a proxy + * into the local network: only http(s) on the standard ports, no credentials, and no private, loopback, link-local + * or other special addresses. Hostnames are checked again after DNS resolution, when connecting (see + * $lib/server/safe-fetch.ts). + */ + +const ALLOWED_PORTS = new Set(['', '80', '443']); + +export type PublicUrlCheck = { ok: true; url: URL } | { ok: false; reason: string }; + +export function checkPublicUrl(input: string): PublicUrlCheck { + if (input.length > 2048) return { ok: false, reason: 'That URL is too long.' }; + let url: URL; + try { + url = new URL(input); + } catch { + return { ok: false, reason: 'That is not a valid URL.' }; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return { ok: false, reason: 'Only http and https URLs can be opened.' }; + if (url.username || url.password) return { ok: false, reason: 'URLs with credentials are not allowed.' }; + if (!ALLOWED_PORTS.has(url.port)) return { ok: false, reason: 'Only the standard web ports (80 and 443) are allowed.' }; + + const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + if (!host) return { ok: false, reason: 'That URL has no host.' }; + if ( + host === 'localhost' || + host.endsWith('.localhost') || + host.endsWith('.local') || + host.endsWith('.internal') || + (!host.includes('.') && !host.includes(':')) + ) { + return { ok: false, reason: 'Local and internal hosts are not allowed.' }; + } + const literal = ipKind(host); + if (literal && isBlockedIp(host)) return { ok: false, reason: 'Private, loopback and reserved addresses are not allowed.' }; + if (literal === null && /^[0-9.]+$/.test(host)) return { ok: false, reason: 'That address is not valid.' }; + return { ok: true, url }; +} + +/* ---------- IP ranges ---------- */ + +export function ipKind(s: string): 4 | 6 | null { + if (parseV4(s) !== null) return 4; + if (parseV6(s) !== null) return 6; + return null; +} + +function parseV4(s: string): number | null { + const m = s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (!m) return null; + const parts = m.slice(1).map(Number); + if (parts.some((p) => p > 255)) return null; + return ((parts[0] << 24) >>> 0) + (parts[1] << 16) + (parts[2] << 8) + parts[3]; +} + +function parseV6(input: string): bigint | null { + let s = input.toLowerCase().split('%')[0]; + if (!s.includes(':')) return null; + // Embedded IPv4 at the end (::ffff:1.2.3.4) + const v4 = s.match(/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/); + if (v4) { + const n = parseV4(v4[1]); + if (n === null) return null; + s = s.slice(0, -v4[1].length) + ((n >>> 16) & 0xffff).toString(16) + ':' + (n & 0xffff).toString(16); + } + const halves = s.split('::'); + if (halves.length > 2) return null; + const head = halves[0] ? halves[0].split(':') : []; + const tail = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const missing = 8 - head.length - tail.length; + if (halves.length === 1 ? head.length !== 8 : missing < 1) return null; + const groups = [...head, ...Array(halves.length === 2 ? missing : 0).fill('0'), ...tail]; + let v = 0n; + for (const g of groups) { + if (!/^[0-9a-f]{1,4}$/.test(g)) return null; + v = (v << 16n) | BigInt(parseInt(g, 16)); + } + return v; +} + +const V4_BLOCKED: [string, number][] = [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4] +]; + +const V6_BLOCKED: [string, number][] = [ + ['::', 128], + ['::1', 128], + ['fc00::', 7], + ['fe80::', 10], + ['fec0::', 10], + ['ff00::', 8], + ['2001:db8::', 32], + ['2001::', 32], + ['100::', 64] +]; + +function inV4(n: number, base: string, bits: number) { + const b = parseV4(base)!; + const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; + return (n & mask) >>> 0 === (b & mask) >>> 0; +} + +function inV6(n: bigint, base: string, bits: number) { + const b = parseV6(base)!; + const shift = BigInt(128 - bits); + return n >> shift === b >> shift; +} + +/** True for any address the server must not connect to. Unparseable input counts as blocked. */ +export function isBlockedIp(ip: string): boolean { + const v4 = parseV4(ip); + if (v4 !== null) return V4_BLOCKED.some(([b, bits]) => inV4(v4, b, bits)); + const v6 = parseV6(ip); + if (v6 === null) return true; + // IPv4-mapped (::ffff:a.b.c.d), IPv4-compatible (::a.b.c.d) and NAT64 (64:ff9b::/96): check the IPv4 inside. + const low32 = Number(v6 & 0xffffffffn); + const asV4 = `${low32 >>> 24}.${(low32 >>> 16) & 255}.${(low32 >>> 8) & 255}.${low32 & 255}`; + if (v6 >> 32n === 0xffffn || (v6 >> 32n === 0n && v6 > 1n) || inV6(v6, '64:ff9b::', 96) || inV6(v6, '64:ff9b:1::', 48)) { + return isBlockedIp(asV4); + } + // 6to4 (2002::/16) embeds an IPv4 in bits 16-48. + if (inV6(v6, '2002::', 16)) { + const embedded = Number((v6 >> 80n) & 0xffffffffn); + return isBlockedIp(`${embedded >>> 24}.${(embedded >>> 16) & 255}.${(embedded >>> 8) & 255}.${embedded & 255}`); + } + return V6_BLOCKED.some(([b, bits]) => inV6(v6, b, bits)); +} diff --git a/applications/viewer/src/lib/server/safe-fetch.ts b/applications/viewer/src/lib/server/safe-fetch.ts new file mode 100644 index 00000000..2a9be3db --- /dev/null +++ b/applications/viewer/src/lib/server/safe-fetch.ts @@ -0,0 +1,127 @@ +import http from 'node:http'; +import https from 'node:https'; +import dns from 'node:dns'; +import net, { type LookupFunction } from 'node:net'; +import { checkPublicUrl, isBlockedIp } from '$lib/net/public-url'; + +/* + * GET a URL that came from chain data or user input, without letting the viewer's server reach the local network. + * - checkPublicUrl: scheme, port and host rules, private IP literals. + * - safeLookup: every DNS answer is checked when the socket connects, so a hostname can't resolve (or re-resolve, + * DNS rebinding) to a private address between the check and the request. + * - Redirects are followed by hand, each hop checked again. + * - An overall deadline and a body size cap. + */ + +export class SafeFetchError extends Error {} + +export interface SafeGetOptions { + maxBytes: number; + timeoutMs: number; + maxRedirects: number; + accept?: string; +} + +export interface SafeGetResult { + url: URL; + contentType: string; + body: Uint8Array; +} + +const safeLookup: LookupFunction = (hostname, options, callback) => { + dns.lookup(hostname, { all: true, verbatim: true }, (err, list) => { + if (err) return callback(err, '', 4); + if (!list.length || list.some((a) => isBlockedIp(a.address))) { + return callback(new SafeFetchError('That host resolves to a private or reserved address.'), '', 4); + } + if (options.all) return callback(null, list); + callback(null, list[0].address, list[0].family); + }); +}; + +function concat(chunks: Uint8Array[]) { + const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let o = 0; + for (const c of chunks) { + out.set(c, o); + o += c.length; + } + return out; +} + +type Hop = { status: number; location?: string; contentType: string; body: Uint8Array }; + +function request(url: URL, deadline: number, opts: SafeGetOptions): Promise { + return new Promise((resolve, reject) => { + const host = url.hostname.replace(/^\[|\]$/g, ''); + // IP literals skip DNS, so check them here too. + if (net.isIP(host) && isBlockedIp(host)) return reject(new SafeFetchError('Private, loopback and reserved addresses are not allowed.')); + const left = deadline - Date.now(); + if (left <= 0) return reject(new SafeFetchError('The host took too long to answer.')); + const lib = url.protocol === 'https:' ? https : http; + const req = lib.request( + url, + { + method: 'GET', + lookup: safeLookup, + headers: { accept: opts.accept ?? 'application/json', 'user-agent': 'yaci-viewer' }, + timeout: left + }, + (res) => { + const status = res.statusCode ?? 0; + if (status >= 300 && status < 400) { + res.resume(); + return resolve({ status, location: String(res.headers.location ?? '') || undefined, contentType: '', body: new Uint8Array() }); + } + const tooBig = () => new SafeFetchError(`The document is larger than ${Math.round(opts.maxBytes / 1024)} KB.`); + if (Number(res.headers['content-length'] ?? 0) > opts.maxBytes) { + res.destroy(); + return reject(tooBig()); + } + const chunks: Uint8Array[] = []; + let size = 0; + res.on('data', (c: Uint8Array) => { + size += c.length; + if (size > opts.maxBytes) { + res.destroy(); + reject(tooBig()); + } else chunks.push(c); + }); + res.on('end', () => resolve({ status, contentType: String(res.headers['content-type'] ?? ''), body: concat(chunks) })); + res.on('error', reject); + } + ); + // A socket that trickles data still stops at the deadline. + const hardStop = setTimeout(() => req.destroy(new SafeFetchError('The host took too long to answer.')), left); + req.on('close', () => clearTimeout(hardStop)); + req.on('timeout', () => req.destroy(new SafeFetchError('The host took too long to answer.'))); + req.on('error', reject); + req.end(); + }); +} + +/** Fetch `start` (already checked by the caller or by checkPublicUrl) and follow safe redirects. */ +export async function safeGet(start: URL, opts: SafeGetOptions): Promise { + const first = checkPublicUrl(start.href); + if (!first.ok) throw new SafeFetchError(first.reason); + const deadline = Date.now() + opts.timeoutMs; + let url = first.url; + for (let hop = 0; ; hop++) { + let res: Hop; + try { + res = await request(url, deadline, opts); + } catch (e) { + if (e instanceof SafeFetchError) throw e; + throw new SafeFetchError(`Could not fetch the document: ${(e as Error).message}`); + } + if (res.status >= 300 && res.status < 400) { + if (!res.location || hop >= opts.maxRedirects) throw new SafeFetchError(opts.maxRedirects ? 'Too many redirects.' : 'The document is behind a redirect.'); + const next = checkPublicUrl(new URL(res.location, url).href); + if (!next.ok) throw new SafeFetchError(`It redirects somewhere not allowed: ${next.reason}`); + url = next.url; + continue; + } + if (res.status < 200 || res.status >= 300) throw new SafeFetchError(`The host answered ${res.status}.`); + return { url, contentType: res.contentType, body: res.body }; + } +} diff --git a/applications/viewer/src/lib/shell/Sidebar.svelte b/applications/viewer/src/lib/shell/Sidebar.svelte index 730b062b..73c0a62f 100644 --- a/applications/viewer/src/lib/shell/Sidebar.svelte +++ b/applications/viewer/src/lib/shell/Sidebar.svelte @@ -19,7 +19,7 @@ {#snippet links(compact: boolean)} -