diff --git a/applications/viewer/.dockerignore b/applications/viewer/.dockerignore new file mode 100644 index 00000000..4cda9105 --- /dev/null +++ b/applications/viewer/.dockerignore @@ -0,0 +1,11 @@ +node_modules +build +.svelte-kit +test-results +.playwright-mcp +playwright-report +deno.lock +.env +.env.* +*.log +static/julc diff --git a/applications/viewer/.gitignore b/applications/viewer/.gitignore index f4a1c100..de53264b 100644 --- a/applications/viewer/.gitignore +++ b/applications/viewer/.gitignore @@ -9,3 +9,7 @@ node_modules vite.config.js.timestamp-* vite.config.ts.timestamp-* .idea +/static/julc +/test-results +/playwright-report +/.cache 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..9158eded 100644 --- a/applications/viewer/README.md +++ b/applications/viewer/README.md @@ -1,69 +1,176 @@ -## Getting Started - -### Installation - -To install Yaci Viewer globally, run: +# Yaci Viewer + +A live Cardano explorer for your Yaci DevKit devnet, or any [Yaci Store](https://github.com/bloxbean/yaci-store). + +- **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. +- **Script Lab:** in your browser, with the [julc](https://github.com/bloxbean/julc) WebAssembly engine: + - decode any Plutus script into searchable UPLC, see the builtins it uses and verify its hash; + - re-run every script of a transaction with the cost model of its epoch, compare the budget with what was declared + on chain, and see the exact ScriptContext the validator received; + - when Yaci Store saves transaction CBOR (`store.transaction.save-cbor`), replays use the transaction's own bytes + (exact redeemers, witnessed datums, outputs, certificates and proposals) and a CBOR tab shows those bytes as a tree, + checked against the transaction id; otherwise the transaction is rebuilt from the store's JSON, and every replay + says which source it used; + - step through any run in a time-travel debugger: step, over, out, continue and back, breakpoints on lines, + builtins, traces and failures, environment, frames and budget per step, and links to an exact step + (`/transactions//debug//?step=N`); + - fork a transaction in the what-if lab (`/transactions//fork`): edit redeemers, datums, signers, the validity + range, outputs, mint, withdrawals, fee, the cost model's epoch or the protocol version, and every script runs again + as you type, next to the on-chain result; debug the forked runs and export any run as a JSON test fixture; + - watch scripts live in the script monitor (`/scripts/monitor`): every new script transaction is re-run as its + block arrives, with its budget against the per-transaction limits (made for devnets; sampled on public networks); + - see a script's recent activity (budgets, redeemer variants) on its page, and attach its CIP-57 blueprint + (`plutus.json`) to show datums and redeemers with type, variant and field names. + - check a draft parameter change or treasury withdrawal against the constitution's guardrail script before it is + submitted (`/governance/guardrail`): the verdict updates as you type, says which parameter the guardrail rejects, + and the run can be debugged and shared as a link; proposal pages show what a change does and run its guardrail. + - try a validator against real chain state in the validator lab (`/scripts/lab`): write it in Java (compiled in the + browser by julc, with typed datum and redeemer views and its `plutus.json`), paste a compiled script or pick one on + chain, then spend a real UTxO as if it were locked at it (or mint, or withdraw) with your redeemer, datum, signers, + validity range and withdrawals. The verdict, budget and traces update as you type; a rejected Java validator shows + the line it failed on, and runs open in the UPLC debugger or the (experimental) Java source debugger. +- Addresses, stake accounts, assets, epochs and protocol parameters, pools, rewards, certificates, Ada pots and + Conway governance. +- Light and dark themes; works on a phone. + +## Run it ```bash npm install -g @bloxbean/yaci-viewer -``` - -### Running Yaci Viewer - -1. Start the Yaci DevKit. -2. Run the following command: - -```bash 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 - -Create a `.env` file in the same directory where you are starting Yaci Viewer, and specify the following environment variables: - -- `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`. - -### Option 2: Using Command-Line Arguments - -You can also specify the indexer base URL and WebSocket URL directly via command-line arguments: - -```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: +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. + +The Docker distribution of Yaci DevKit already includes Yaci Viewer at `http://localhost:5173`. + +## Configuration + +Set these in the environment, or in a `.env` file in the directory you start Yaci Viewer from: + +| 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 | +| `PUBLIC_JULC_BASE_URL` | `/julc/` | Where the browser loads the julc engine from, if not served by Yaci Viewer itself | +| `PORT`, `HOST` | `3000`, `0.0.0.0` | Where Yaci Viewer listens | +| `HEAVY_ADDRESS_GUARD` | `true` | Protects a large Yaci Store from busy addresses (below). `false` restores the plain behaviour | +| `YACI_ANALYTICS` | `auto` | `off` stops Yaci Viewer from using the store's analytics query API (below) even when the store has it | +| `NAMES` | `auto` | `off` turns off names on Mainnet (below). On other networks they are always off | +| `NAMES_DIR` | `.cache/names` | Where the names are saved between restarts. Without a writable folder they are kept in memory only | + +### Busy addresses on a large Yaci Store + +On a Mainnet-sized Yaci Store, `/addresses/{a}/amounts`, `/addresses/{a}/transactions` and `/accounts/{stake}` can take +minutes to hours for busy addresses, and the store keeps running the query after the viewer gives up +([yaci-store#1269](https://github.com/bloxbean/yaci-store/issues/1269)). So Yaci Viewer: + +- shows address and stake pages at once, and streams the slow parts in, each with its own loading and error state; +- never calls `/amounts`: without `/balance`, it sums the address's UTxOs when one page (100) holds them all; past that + it takes the exact balance from the store's Blockfrost-compatible API (`/blockfrost/addresses/{a}`) when the store has + it, and only if that fails sums up to 500 UTxOs and shows a lower bound ("≥"); +- reads UTxOs and transactions from the Blockfrost-compatible API too, when the store has it: the store stops those + queries itself after 15 s, so a busy address leaves nothing running (the devkit's own store has no such API, and gets + the native endpoints); +- opens address pages on the UTxOs tab (the transactions are the last tab, loaded only when opened); +- remembers an endpoint that timed out for an address for 15 minutes and doesn't ask again (a UTxO page for 2), shares + identical requests in flight, keeps stake accounts for a minute, and runs at most 4 such calls at once; +- keeps crawlers off per-address pages (`robots.txt`). + +The limits are in `src/lib/server/heavy-guard.ts`. Set `HEAVY_ADDRESS_GUARD=false` once the store cancels abandoned +queries. + +When the store has its analytics query API enabled (`yaci.store.analytics.query.rest-api-enabled`, not available on +the devkit's own store), Yaci Viewer uses it where a regular endpoint is too slow for a busy **stake account**: what it +controls and its pool, as of the last export, and its **rewards**, 20 epochs a page. (Address pages don't use it: the +answers would be partial, and DuckDB isn't meant to serve every page view.) + +Those sections are labelled "From Yaci Store analytics" with how fresh the data is. Analytics queries are bounded by the +store's own 30 s timeout, at most two run at once, and answers are kept for 10 minutes. Yaci Viewer checks whether the +store has the API every 10 minutes (one `SELECT 1`), and does nothing different when it doesn't. + +The same API adds charts and numbers, again only where the store has it: + +| Page | What | +|---|---| +| Dashboard | Transactions, script runs, script CPU and fees per day (90 days); treasury and reserves; pools making blocks; the most-run scripts (7 days) | +| Governance | Voting power: the 10 largest DReps and options, with their share | +| Script | Runs, CPU steps and the costliest run per day (90 days) | +| Pool | Blocks, active stake and delegators per epoch (50 epochs) | +| Stake account | Rewards and active stake per epoch (50 epochs) | + +They read the store's daily exports, which DuckDB aggregates in well under a second. Yaci Viewer keeps the answers in +memory and serves them while it refreshes them in the background: network-wide ones for an hour, one script's, pool's or +account's for 30 minutes (`src/lib/server/insights.ts`). Pages stream them in, so they never hold up a page. + +### Names on Mainnet + +On Mainnet, Yaci Viewer shows names next to ids (never instead of them): + +| Name | Where it comes from | Shown on | +|---|---|---| +| dApp label for a script, e.g. "Minswap Order Contract v2" | [Strica's Cardano Contracts Registry](https://github.com/StricaHQ/cardano-contracts-registry) and [Cardano Fans' registry](https://github.com/Cardano-Fans/crfa-offchain-data-registry) (Strica's wins on overlap) | Transaction inputs, outputs and scripts; address, stake, script and policy pages; most-run scripts | +| DRep name | `givenName` in the DRep's CIP-119 metadata | DRep list and page, votes, certificates, voting power, stake accounts | +| Pool ticker | The pool's CIP-6 metadata | Block producer, stake accounts, delegations, epoch stakes, rewards, pool certificates, votes | +| Governance action title | `title` in the action's CIP-108 metadata | Proposals, votes, the action's page, a DRep's votes | +| Token ticker and decimals | The [Cardano token registry](https://tokens.cardano.org) (CIP-26) | Every token chip (quantities with decimals), the asset page | + +DRep names, pool tickers and action titles are what their owners wrote; Yaci Viewer keeps one only when its document +matches the hash on chain, and labels it as self-declared. Documents are fetched by the server with the same +protections as the anchor viewer: public addresses only, size and time limits. + +The server loads them in the background after it starts, after checking with the store that the network is Mainnet +(`network_magic` 764824073); on any other network nothing is fetched. dApp labels are refreshed daily, DRep names and +action titles every 6 hours, pool tickers every 12 (the first run reads every pool registration, a few minutes); a +document that couldn't be read is tried again after a day (pools: three). Tokens are looked up when a page shows them +and kept for a week. Everything is saved in `NAMES_DIR`, so after a restart the names are there at once and only what +changed is read again. Until they are loaded, pages show the ids as before. + +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` ``` +### Script Lab and the julc engine + +Script Lab uses the julc WebAssembly engine. It is not built here: `npm run dev` and `npm run build` run +`scripts/fetch-julc.mjs`, which downloads the julc release pinned in `julc.lock.json`, checks its SHA-256 and unpacks it +into `static/julc/` (git-ignored). The engine is only downloaded by the browser when a user decodes a script (about +5.5 MB with compression, then cached). + +- A production build fails if the engine can't be installed. Set `JULC_SKIP=1` to build without Script Lab (its + panels then say so), or `JULC_ARCHIVE=/path/to/julc-wasm-.tar.gz` to install from a local copy. +- To upgrade julc, change `tag`, `version`, `file` and `sha256` in `julc.lock.json` together; the checksum is in the + release's `julc-wasm--SHA256SUMS.txt`. +- The engine needs WebAssembly GC and exception handling (exnref): current Chrome, Edge, Firefox and Safari. Other + browsers get a message instead. Playwright's bundled Chromium is too old, so run the Script Lab test with Chrome: + `SCRIPT_LAB_HASH= SCRIPT_LAB_TX= SCRIPT_LAB_RUN= SCRIPT_LAB_PROPOSAL=_ PW_CHANNEL=chrome npx playwright test script-lab`. +- Re-runs rebuild the transaction from Yaci Store data (`src/lib/julc/replay/`). Until julc builds contexts exactly + as the ledger does ([julc#244](https://github.com/bloxbean/julc/issues/244)), the viewer corrects julc's context + (asset order, the other redeemers, datums given by hash, Plutus V1 shapes, the governance action of a proposal) and + runs the script again; each result says what was corrected. Most certificate kinds aren't replayed yet, and Plutus V1 + (pre-2023 history) is partly reproduced. + +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), `julc/` (Script Lab) and `format.ts`, `cbor.ts`, +`bech32.ts`. diff --git a/applications/viewer/julc.lock.json b/applications/viewer/julc.lock.json new file mode 100644 index 00000000..ab4f9c9b --- /dev/null +++ b/applications/viewer/julc.lock.json @@ -0,0 +1,8 @@ +{ + "comment": "julc WebAssembly engine used by Script Lab. Downloaded from the GitHub release by scripts/fetch-julc.mjs and checked against sha256. To upgrade, change tag, version, file and sha256 together (sha256 from the release's julc-wasm--SHA256SUMS.txt).", + "repo": "bloxbean/julc", + "tag": "v0.1.0-pre18", + "version": "0.1.0-pre18", + "file": "julc-wasm-0.1.0-pre18.tar.gz", + "sha256": "475cb97719f882ae889f24a341b6f2713e3a7ac0164d32f25e048792936ffa28" +} diff --git a/applications/viewer/package-lock.json b/applications/viewer/package-lock.json index d5da6c7c..8aaa853f 100644 --- a/applications/viewer/package-lock.json +++ b/applications/viewer/package-lock.json @@ -10,32 +10,32 @@ "license": "MIT", "dependencies": { "dotenv": "^16.4.7", - "moment": "^2.30.1", - "svelte-feather-icons": "^4.1.0", "yargs": "^17.7.2" }, "bin": { "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", + "@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", "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 +56,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 +604,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 +718,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 +748,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 +764,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", @@ -802,20 +858,10 @@ "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": "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 +893,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 +921,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 +979,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 +1045,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 +1059,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 +1073,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 +1087,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 +1101,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 +1115,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 +1129,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 +1143,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 +1157,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 +1171,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 +1184,10 @@ "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" ], @@ -1127,10 +1198,38 @@ "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-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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "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 +1241,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 +1269,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 +1283,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 +1297,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 +1310,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 +1353,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 +1366,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 +1394,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 +1455,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,12 +1527,22 @@ "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" }, + "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", @@ -1617,10 +1819,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 +1840,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 +1910,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": { @@ -1989,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", @@ -2117,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", @@ -2139,38 +2316,10 @@ "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.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 +2360,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 +2614,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 +2661,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", @@ -2618,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", @@ -2794,12 +2927,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 +2947,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 +2999,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", @@ -3190,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", @@ -3780,13 +3883,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 +3899,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 +3976,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 +4216,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 +4243,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 +4264,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": { @@ -4214,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", @@ -4286,6 +4383,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 +4537,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", @@ -4530,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", @@ -5190,17 +5318,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..3895fe3b 100644 --- a/applications/viewer/package.json +++ b/applications/viewer/package.json @@ -11,7 +11,9 @@ "homepage": "https://devkit.yaci.xyz/", "repository": "github:bloxbean/yaci-devkit", "scripts": { + "predev": "node scripts/fetch-julc.mjs --optional", "dev": "vite dev --host 0.0.0.0", + "prebuild": "node scripts/fetch-julc.mjs", "build": "vite build", "preview": "vite preview --host 0.0.0.0", "test": "playwright test", @@ -29,24 +31,26 @@ "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", + "@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", "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", @@ -57,8 +61,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/playwright.config.ts b/applications/viewer/playwright.config.ts index 1027493e..34f7ed61 100644 --- a/applications/viewer/playwright.config.ts +++ b/applications/viewer/playwright.config.ts @@ -14,7 +14,9 @@ const config: PlaywrightTestConfig = { { name: 'chromium', use: { - browserName: 'chromium' + browserName: 'chromium', + // e.g. PW_CHANNEL=chrome for features the bundled Chromium lacks (the julc engine needs Wasm exnref) + channel: process.env.PW_CHANNEL || undefined } } ] diff --git a/applications/viewer/scripts/fetch-julc.mjs b/applications/viewer/scripts/fetch-julc.mjs new file mode 100644 index 00000000..3a557f6e --- /dev/null +++ b/applications/viewer/scripts/fetch-julc.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +/* + * Installs the julc WebAssembly engine (Script Lab) into static/julc/ from the julc GitHub release pinned in + * julc.lock.json. julc is never built here: the release archive is downloaded, checked against the pinned sha256 + * and unpacked (plain files only). + * + * node scripts/fetch-julc.mjs required: fails the build if the engine can't be installed + * node scripts/fetch-julc.mjs --optional dev: warns and carries on; Script Lab hides itself + * + * JULC_SKIP=1 skip entirely (Script Lab hidden) + * JULC_ARCHIVE= use a local copy of the release archive (offline builds); it is still checked + */ +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync } from 'node:zlib'; +import { untar } from './lib/untar.mjs'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const optional = process.argv.includes('--optional'); +const lock = JSON.parse(readFileSync(join(root, 'julc.lock.json'), 'utf8')); +const target = join(root, 'static', 'julc'); +const marker = join(root, 'node_modules', '.cache', 'julc', 'installed'); +const cacheFile = join(root, 'node_modules', '.cache', 'julc', lock.file); +const url = `https://github.com/${lock.repo}/releases/download/${lock.tag}/${lock.file}`; + +const log = (msg) => console.log(`[julc] ${msg}`); +const sha256 = (buf) => createHash('sha256').update(buf).digest('hex'); + +async function archive() { + if (process.env.JULC_ARCHIVE) return readFileSync(process.env.JULC_ARCHIVE); + if (existsSync(cacheFile)) { + const cached = readFileSync(cacheFile); + if (sha256(cached) === lock.sha256) return cached; + } + log(`downloading ${url}`); + const res = await fetch(url, { signal: AbortSignal.timeout(120_000) }); + if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`); + const buf = Buffer.from(await res.arrayBuffer()); + mkdirSync(dirname(cacheFile), { recursive: true }); + writeFileSync(cacheFile, buf); + return buf; +} + +async function install() { + if (existsSync(marker) && existsSync(join(target, 'engine.json')) && readFileSync(marker, 'utf8').trim() === lock.sha256) { + log(`engine ${lock.version} already installed`); + return; + } + const buf = await archive(); + const actual = sha256(buf); + if (actual !== lock.sha256) throw new Error(`checksum mismatch for ${lock.file}: expected ${lock.sha256}, got ${actual}`); + + const files = untar(gunzipSync(buf)); + const engine = files.get('engine.json'); + if (!engine) throw new Error('archive has no engine.json'); + const manifest = JSON.parse(engine.toString('utf8')); + if (manifest.julcVersion !== lock.version) throw new Error(`archive is julc ${manifest.julcVersion}, lock says ${lock.version}`); + if (!files.has(manifest.launcher) || !files.has(`${manifest.launcher}.wasm`)) throw new Error('archive is missing the engine launcher'); + + rmSync(target, { recursive: true, force: true }); + mkdirSync(target, { recursive: true }); + // Type declarations aren't needed at runtime (the viewer has its own, src/lib/julc/types.ts). + for (const [name, body] of files) if (!name.endsWith('.d.ts')) writeFileSync(join(target, name), body); + mkdirSync(dirname(marker), { recursive: true }); + writeFileSync(marker, lock.sha256 + '\n'); + log(`installed engine ${lock.version} (${manifest.variant}, ${manifest.version}) into static/julc`); +} + +if (process.env.JULC_SKIP === '1') { + log('JULC_SKIP=1: skipping; Script Lab will be hidden'); +} else { + try { + await install(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + if (!optional) { + console.error(`[julc] ${msg}\n[julc] Set JULC_ARCHIVE to a local copy of ${lock.file}, or JULC_SKIP=1 to build without Script Lab.`); + process.exit(1); + } + log(`${msg}; continuing without Script Lab`); + } +} diff --git a/applications/viewer/scripts/lib/untar.mjs b/applications/viewer/scripts/lib/untar.mjs new file mode 100644 index 00000000..3f9dd6f3 --- /dev/null +++ b/applications/viewer/scripts/lib/untar.mjs @@ -0,0 +1,32 @@ +/* + * Minimal reader for the flat tar archives julc releases ship. Only regular files at the top level are returned; + * directories, links, pax headers and anything with a nested or unusual name are skipped, so an archive can't + * write outside the target folder. + */ +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function field(block, start, length) { + const end = block.indexOf(0, start); + return block.toString('utf8', start, end === -1 || end > start + length ? start + length : end); +} + +/** @param {Buffer} tar @returns {Map} */ +export function untar(tar) { + const files = new Map(); + let offset = 0; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every((b) => b === 0)) break; + const size = parseInt(field(header, 124, 12).trim() || '0', 8); + if (!Number.isFinite(size) || size < 0) throw new Error('corrupt tar header'); + const type = String.fromCharCode(header[156] || 48); + const prefix = field(header, 345, 155); + const name = (prefix ? `${prefix}/` : '') + field(header, 0, 100); + const body = tar.subarray(offset + 512, offset + 512 + size); + if (body.length !== size) throw new Error('truncated tar archive'); + const base = name.replace(/^\.\//, ''); + if (type === '0' && SAFE_NAME.test(base)) files.set(base, Buffer.from(body)); + offset += 512 + Math.ceil(size / 512) * 512; + } + return files; +} diff --git a/applications/viewer/scripts/lib/untar.test.ts b/applications/viewer/scripts/lib/untar.test.ts new file mode 100644 index 00000000..b42591ba --- /dev/null +++ b/applications/viewer/scripts/lib/untar.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { untar } from './untar.mjs'; + +/* Build a tar archive in memory: entries of [name, body, typeflag]. */ +function tar(entries: [string, string, string?][]): Buffer { + const blocks: Buffer[] = []; + for (const [name, body, type = '0'] of entries) { + const header = Buffer.alloc(512); + header.write(name, 0, 100, 'utf8'); + header.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 12, 'utf8'); + header.write(type, 156, 1, 'utf8'); + blocks.push(header); + const data = Buffer.alloc(Math.ceil(body.length / 512) * 512); + data.write(body, 0, 'utf8'); + blocks.push(data); + } + blocks.push(Buffer.alloc(1024)); + return Buffer.concat(blocks); +} + +describe('untar', () => { + it('returns top-level regular files', () => { + const files = untar(tar([['./', '', '5'], ['./engine.json', '{"a":1}'], ['julc-x.js', 'x'.repeat(700)]])); + expect([...files.keys()]).toEqual(['engine.json', 'julc-x.js']); + expect(files.get('engine.json')!.toString()).toBe('{"a":1}'); + expect(files.get('julc-x.js')!.length).toBe(700); + }); + + it('skips paths that could escape the target folder, links and pax headers', () => { + const files = untar( + tar([ + ['../evil.js', 'x'], + ['/etc/passwd', 'x'], + ['sub/dir.js', 'x'], + ['.hidden', 'x'], + ['link.js', '', '2'], + ['./PaxHeaders/x', 'path=a', 'x'], + ['ok.js', 'y'] + ]) + ); + expect([...files.keys()]).toEqual(['ok.js']); + }); + + it('rejects a truncated archive', () => { + const t = tar([['a.js', 'x'.repeat(600)]]); + expect(() => untar(t.subarray(0, 700))).toThrow(/truncated/); + }); +}); 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..00a7cc3c 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-). + ========================================================================== */ +@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/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/hooks.server.ts b/applications/viewer/src/hooks.server.ts new file mode 100644 index 00000000..eb642ff4 --- /dev/null +++ b/applications/viewer/src/hooks.server.ts @@ -0,0 +1,33 @@ +import type { Handle, ServerInit } from '@sveltejs/kit'; +import { building } from '$app/environment'; +import { names } from '$lib/server/names'; +import { linkHeaderToTags, injectIntoHead } from '$lib/server/preloads'; + +/* Names for ids (dApps, DReps, pools, governance actions, tokens) load in the background on Mainnet: see names/. */ +export const init: ServerInit = () => { + if (!building) names.start(); +}; + +/* + * SvelteKit sends a page's CSS and JS preloads as one `Link` response header (it only writes them into when + * prerendering). On larger pages that header passes 4 KB, the default header buffer of nginx (proxy_buffer_size), so + * a viewer behind a default reverse proxy fails with 502 "upstream sent too big header". The same preloads go into + * the page's as tags instead, while the HTML streams through, and the header is dropped. + * + * nginx also buffers responses by default, which would hold a streamed page until its slowest section is done. + * `X-Accel-Buffering: no` turns that off for the viewer's responses, whatever the proxy's config. + */ +export const handle: Handle = async ({ event, resolve }) => { + // Pages stream slow sections in after the first render (busy addresses): ask nginx not to hold the response back. + event.setHeaders({ 'x-accel-buffering': 'no' }); + const response = await resolve(event); + const link = response.headers.get('link'); + if (!link) return response; + const headers = new Headers(response.headers); + headers.delete('link'); + const tags = linkHeaderToTags(link); + const html = response.headers.get('content-type')?.startsWith('text/html'); + if (!html || !response.body || !tags) return new Response(response.body, { status: response.status, statusText: response.statusText, headers }); + headers.delete('content-length'); + return new Response(injectIntoHead(response.body, tags), { status: response.status, statusText: response.statusText, headers }); +}; 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/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/analytics/insights.test.ts b/applications/viewer/src/lib/analytics/insights.test.ts new file mode 100644 index 00000000..3a30ae3b --- /dev/null +++ b/applications/viewer/src/lib/analytics/insights.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { + adaPotsSql, + dailyScriptsSql, + dailyTxsSql, + drepPowerSql, + epochBlocksSql, + poolBlocksSql, + poolStakeSql, + scriptDailySql, + stakeHistorySql, + stakeRewardsByEpochSql, + topScriptsSql +} from './insights'; +import { QueryValueError, type Tables } from './queries'; + +const t: Tables = { + transaction: 'parquet_transaction', + transactionScripts: 'parquet_transaction_scripts', + block: 'parquet_block', + adapot: 'parquet_adapot', + epochStake: 'parquet_epoch_stake', + drepDist: 'parquet_drep_dist', + reward: 'reward', + rewardRest: 'parquet_reward_rest', + stakeBalance: 'parquet_stake_address_balance', + delegation: 'parquet_delegation' +}; +const SCRIPT = 'd8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb'; +const POOL = 'ec3308cedfbe904927d51f9a0d9cb93492da29e22e32900f85a15d8c'; +const STAKE = 'stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz'; + +describe('chart queries', () => { + it('group by day from a fixed date, over the given tables', () => { + expect(dailyTxsSql('2026-07-21', t)).toBe( + "SELECT date, count(*) AS txs, sum(fee) AS fees FROM parquet_transaction WHERE date >= DATE '2026-07-21' GROUP BY date ORDER BY date" + ); + expect(dailyScriptsSql('2026-07-21', t)).toContain("FROM parquet_transaction_scripts WHERE date >= DATE '2026-07-21' GROUP BY date"); + expect(topScriptsSql('2026-10-12', 10, t)).toMatch(/GROUP BY script_hash ORDER BY runs DESC LIMIT 10$/); + expect(scriptDailySql(SCRIPT, '2026-07-21', t)).toContain(`WHERE script_hash = '${SCRIPT}' AND date >= DATE '2026-07-21'`); + }); + + it('group by epoch from a given epoch', () => { + expect(adaPotsSql(t)).toBe('SELECT epoch, treasury, reserves, fees, distributed_rewards AS rewards FROM parquet_adapot ORDER BY epoch'); + expect(epochBlocksSql(612, t)).toContain('FROM parquet_block WHERE epoch >= 612 GROUP BY epoch'); + expect(poolBlocksSql(POOL, 612, t)).toContain(`WHERE slot_leader = '${POOL}' AND epoch >= 612`); + expect(poolStakeSql(POOL, 612, t)).toContain(`WHERE pool_id = '${POOL}' AND epoch >= 612`); + expect(stakeRewardsByEpochSql(STAKE, 612, t)).toContain(`FROM reward WHERE address = '${STAKE}' AND epoch >= 612`); + expect(stakeHistorySql(STAKE, 612, t)).toContain(`FROM parquet_epoch_stake WHERE address = '${STAKE}' AND epoch >= 612`); + }); + + it('look for the latest epoch from a lower bound', () => { + expect(drepPowerSql(659, 10, t)).toContain('SELECT max(epoch) AS e FROM parquet_drep_dist WHERE epoch >= 659'); + expect(drepPowerSql(659, 10, t)).toMatch(/ORDER BY amount DESC LIMIT 10$/); + }); + + it.each([ + () => scriptDailySql(`${SCRIPT}' OR '1'='1`, '2026-07-21', t), + () => scriptDailySql(SCRIPT.toUpperCase(), '2026-07-21', t), + () => poolBlocksSql('pool1abc', 612, t), + () => poolStakeSql(POOL, 612.5, t), + () => stakeHistorySql("stake1x' --", 612, t), + () => dailyTxsSql('2026-07-21; SELECT 1', t), + () => topScriptsSql('2026-10-12', Number.NaN, t) + ])('rejects values that are not what they claim (%#)', (build) => { + expect(build).toThrow(QueryValueError); + }); + + it('are single statements', () => { + for (const sql of [dailyTxsSql('2026-07-21', t), drepPowerSql(659, 10, t), stakeHistorySql(STAKE, 612, t)]) expect(sql).not.toContain(';'); + }); +}); diff --git a/applications/viewer/src/lib/analytics/insights.ts b/applications/viewer/src/lib/analytics/insights.ts new file mode 100644 index 00000000..4986e45d --- /dev/null +++ b/applications/viewer/src/lib/analytics/insights.ts @@ -0,0 +1,69 @@ +import { dateLiteral, hashLiteral, intLiteral, stakeLiteral, type Tables } from './queries'; + +/* + * SQL for the analytics charts: aggregates by day or by epoch over Yaci Store's exports, which DuckDB answers in a + * fraction of a second. Daily charts read whole days (from `from` up to the last export), so the newest bar is never a + * day in progress. Values are checked before they are written into the SQL (see queries.ts). + */ + +/** Transactions and fees per day. */ +export function dailyTxsSql(from: string, t: Tables): string { + return `SELECT date, count(*) AS txs, sum(fee) AS fees FROM ${t.transaction} WHERE date >= ${dateLiteral(from)} GROUP BY date ORDER BY date`; +} + +/** Script runs (one per redeemer), transactions with scripts, and the ex-units they used, per day. */ +export function dailyScriptsSql(from: string, t: Tables): string { + return `SELECT date, count(*) AS runs, count(DISTINCT tx_hash) AS txs, sum(unit_steps) AS steps, sum(unit_mem) AS mem +FROM ${t.transactionScripts} WHERE date >= ${dateLiteral(from)} GROUP BY date ORDER BY date`; +} + +/** The scripts run most often since `from`. */ +export function topScriptsSql(from: string, limit: number, t: Tables): string { + return `SELECT script_hash, count(*) AS runs, count(DISTINCT tx_hash) AS txs, sum(unit_steps) AS steps, sum(unit_mem) AS mem +FROM ${t.transactionScripts} WHERE date >= ${dateLiteral(from)} GROUP BY script_hash ORDER BY runs DESC LIMIT ${intLiteral(limit)}`; +} + +/** Treasury, reserves, fees and rewards per epoch. */ +export function adaPotsSql(t: Tables): string { + return `SELECT epoch, treasury, reserves, fees, distributed_rewards AS rewards FROM ${t.adapot} ORDER BY epoch`; +} + +/** Blocks, the pools that made them, and transactions per epoch since `fromEpoch`. */ +export function epochBlocksSql(fromEpoch: number, t: Tables): string { + return `SELECT epoch, count(*) AS blocks, count(DISTINCT slot_leader) AS pools, sum(no_of_txs) AS txs +FROM ${t.block} WHERE epoch >= ${intLiteral(fromEpoch)} GROUP BY epoch ORDER BY epoch`; +} + +/** The DReps (and the abstain / no-confidence options) with the most voting power in the latest exported epoch. */ +export function drepPowerSql(minEpoch: number, limit: number, t: Tables): string { + return `WITH e AS (SELECT max(epoch) AS e FROM ${t.drepDist} WHERE epoch >= ${intLiteral(minEpoch)}) +SELECT epoch, drep_id, drep_type, amount, sum(amount) OVER () AS total, count(*) OVER () AS dreps +FROM ${t.drepDist} WHERE epoch = (SELECT e FROM e) ORDER BY amount DESC LIMIT ${intLiteral(limit)}`; +} + +/** One script's runs and ex-units per day, and the most steps a single run took. */ +export function scriptDailySql(hash: string, from: string, t: Tables): string { + return `SELECT date, count(*) AS runs, count(DISTINCT tx_hash) AS txs, sum(unit_steps) AS steps, sum(unit_mem) AS mem, max(unit_steps) AS max_steps +FROM ${t.transactionScripts} WHERE script_hash = ${hashLiteral(hash)} AND date >= ${dateLiteral(from)} GROUP BY date ORDER BY date`; +} + +/** Blocks one pool made per epoch since `fromEpoch`. */ +export function poolBlocksSql(poolHash: string, fromEpoch: number, t: Tables): string { + return `SELECT epoch, count(*) AS blocks FROM ${t.block} WHERE slot_leader = ${hashLiteral(poolHash)} AND epoch >= ${intLiteral(fromEpoch)} GROUP BY epoch ORDER BY epoch`; +} + +/** One pool's active stake and delegators per epoch since `fromEpoch`. */ +export function poolStakeSql(poolHash: string, fromEpoch: number, t: Tables): string { + return `SELECT epoch, sum(amount) AS stake, count(*) AS delegators FROM ${t.epochStake} +WHERE pool_id = ${hashLiteral(poolHash)} AND epoch >= ${intLiteral(fromEpoch)} GROUP BY epoch ORDER BY epoch`; +} + +/** A stake account's rewards per epoch (when earned) since `fromEpoch`. */ +export function stakeRewardsByEpochSql(stake: string, fromEpoch: number, t: Tables): string { + return `SELECT epoch, sum(amount) AS rewards FROM ${t.reward} WHERE address = ${stakeLiteral(stake)} AND epoch >= ${intLiteral(fromEpoch)} GROUP BY epoch ORDER BY epoch`; +} + +/** A stake account's active stake, and the pool it was with, per epoch since `fromEpoch`. */ +export function stakeHistorySql(stake: string, fromEpoch: number, t: Tables): string { + return `SELECT epoch, amount, pool_id FROM ${t.epochStake} WHERE address = ${stakeLiteral(stake)} AND epoch >= ${intLiteral(fromEpoch)} ORDER BY epoch`; +} diff --git a/applications/viewer/src/lib/analytics/queries.test.ts b/applications/viewer/src/lib/analytics/queries.test.ts new file mode 100644 index 00000000..7fcf92e9 --- /dev/null +++ b/applications/viewer/src/lib/analytics/queries.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; +import { addDays, addressLiteral, dateLiteral, QueryValueError, stakeAccountSql, stakeLiteral, stakeRewardsSql, type Tables } from './queries'; + +const A = 'addr1z84q0denmyep98ph3tmzwsmw0j7zau9ljmsqx6a4rvaau66j2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq777e2a'; +const S = 'stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz'; +const tables: Tables = { + transaction: 'parquet_transaction', + transactionScripts: 'parquet_transaction_scripts', + block: 'parquet_block', + adapot: 'parquet_adapot', + epochStake: 'parquet_epoch_stake', + drepDist: 'parquet_drep_dist', + reward: 'reward', + rewardRest: 'parquet_reward_rest', + stakeBalance: 'parquet_stake_address_balance', + delegation: 'parquet_delegation' +}; + +describe('values written into SQL', () => { + it('accepts addresses, stake addresses and dates', () => { + expect(addressLiteral(A)).toBe(`'${A}'`); + expect(addressLiteral('DdzFFzCqrhsw3prhfMFDNFowbzUku3QmrMwarfjUbWXRisodn97R436SHc1rimp4MhPNmbdYb1aTdqtGSJixMVMi5MkArDQJ6Sc1n3Ez')).toMatch(/^'Ddz/); + expect(stakeLiteral(S)).toBe(`'${S}'`); + expect(dateLiteral('2026-10-18')).toBe("DATE '2026-10-18'"); + }); + + it.each([ + ["addr1x' OR 1=1 --"], + ['addr1qq; DROP TABLE x'], + ["Ddz'"], + [''] + ])('rejects %j as an address', (bad) => { + expect(() => addressLiteral(bad)).toThrow(QueryValueError); + }); + + it('rejects anything else as a stake address or date', () => { + expect(() => stakeLiteral(`${S}'`)).toThrow(QueryValueError); + expect(() => stakeLiteral(A)).toThrow(QueryValueError); + expect(() => dateLiteral("2026-10-18' OR '1")).toThrow(QueryValueError); + expect(() => dateLiteral('2026-13-45')).toThrow(QueryValueError); + }); + + it('adds days across months and years', () => { + expect(addDays('2026-10-18', -6)).toBe('2026-10-12'); + expect(addDays('2026-01-03', -7)).toBe('2025-12-27'); + expect(addDays('2024-02-28', 1)).toBe('2024-02-29'); + }); +}); + +describe('stake queries', () => { + it('reads rewards for a range of epochs', () => { + expect(stakeRewardsSql({ stake: S, fromEpoch: 643, toEpoch: 662, tables })).toBe( + `SELECT epoch, type, pool_id, amount, spendable_epoch FROM reward WHERE address = '${S}' AND epoch >= 643 AND epoch <= 662 ORDER BY epoch DESC, type` + ); + expect(stakeRewardsSql({ stake: S, fromEpoch: 643, toEpoch: 662, rest: true, tables })).toContain('FROM parquet_reward_rest'); + }); + + it('reads the account from the latest balance and delegation', () => { + const sql = stakeAccountSql(S, tables); + expect(sql).toContain(`FROM parquet_stake_address_balance WHERE address = '${S}' ORDER BY slot DESC LIMIT 1`); + expect(sql).toContain(`FROM parquet_delegation WHERE address = '${S}' ORDER BY slot DESC LIMIT 1`); + }); + + it('rejects a range that is not whole epochs', () => { + expect(() => stakeRewardsSql({ stake: S, fromEpoch: 1.5, toEpoch: 2, tables })).toThrow(QueryValueError); + }); +}); diff --git a/applications/viewer/src/lib/analytics/queries.ts b/applications/viewer/src/lib/analytics/queries.ts new file mode 100644 index 00000000..eaee3ab0 --- /dev/null +++ b/applications/viewer/src/lib/analytics/queries.ts @@ -0,0 +1,76 @@ +/* + * SQL for Yaci Store's analytics query API (POST /analytics/query/sql, DuckDB over the store's daily exports). + * The API takes plain SQL with no bind parameters, so every value is checked against a strict pattern before it is + * written into a query, and anything else throws. + */ + +const BECH32_ADDRESS = /^(addr|addr_test)1[02-9ac-hj-np-z]{6,}$/; +const BYRON_ADDRESS = /^[1-9A-HJ-NP-Za-km-z]{20,}$/; +const BECH32_STAKE = /^(stake|stake_test)1[02-9ac-hj-np-z]{6,}$/; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; + +export class QueryValueError extends Error {} + +export function addressLiteral(address: string): string { + if (!BECH32_ADDRESS.test(address) && !BYRON_ADDRESS.test(address)) throw new QueryValueError(`Not an address: ${address}`); + return `'${address}'`; +} + +export function stakeLiteral(stake: string): string { + if (!BECH32_STAKE.test(stake)) throw new QueryValueError(`Not a stake address: ${stake}`); + return `'${stake}'`; +} + +export function dateLiteral(day: string): string { + if (!ISO_DATE.test(day) || Number.isNaN(Date.parse(day))) throw new QueryValueError(`Not a date: ${day}`); + return `DATE '${day}'`; +} + +/** A 28-byte hash in hex (script hash, pool hash). */ +export function hashLiteral(hash: string): string { + if (!/^[0-9a-f]{56}$/.test(hash)) throw new QueryValueError(`Not a 28-byte hex hash: ${hash}`); + return `'${hash}'`; +} + +export function intLiteral(n: number): string { + if (!Number.isSafeInteger(n)) throw new QueryValueError(`Not an integer: ${n}`); + return String(n); +} + +/** The ISO date `days` after (or before, if negative) `day`. */ +export function addDays(day: string, days: number): string { + const d = new Date(`${day}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +/** The table names to read: the live view where it is known to answer quickly, otherwise the exported data. */ +export interface Tables { + transaction: string; + transactionScripts: string; + block: string; + adapot: string; + epochStake: string; + drepDist: string; + reward: string; + rewardRest: string; + stakeBalance: string; + delegation: string; +} + +/** A stake account's rewards (or, with `rest`, its other rewards: refunds, treasury and reserve payouts) over epochs. */ +export function stakeRewardsSql(q: { stake: string; fromEpoch: number; toEpoch: number; rest?: boolean; tables: Tables }): string { + const s = stakeLiteral(q.stake); + const where = `address = ${s} AND epoch >= ${intLiteral(q.fromEpoch)} AND epoch <= ${intLiteral(q.toEpoch)}`; + return q.rest + ? `SELECT epoch, type, amount, spendable_epoch FROM ${q.tables.rewardRest} WHERE ${where} ORDER BY epoch DESC, type` + : `SELECT epoch, type, pool_id, amount, spendable_epoch FROM ${q.tables.reward} WHERE ${where} ORDER BY epoch DESC, type`; +} + +/** What a stake account controls, as of its latest balance change, and the pool it last delegated to. */ +export function stakeAccountSql(stake: string, tables: Tables): string { + const s = stakeLiteral(stake); + return `SELECT + (SELECT quantity FROM ${tables.stakeBalance} WHERE address = ${s} ORDER BY slot DESC LIMIT 1) AS controlled_amount, + (SELECT pool_id FROM ${tables.delegation} WHERE address = ${s} ORDER BY slot DESC LIMIT 1) AS pool_id`; +} diff --git a/applications/viewer/src/lib/analytics/types.ts b/applications/viewer/src/lib/analytics/types.ts new file mode 100644 index 00000000..f805f5d2 --- /dev/null +++ b/applications/viewer/src/lib/analytics/types.ts @@ -0,0 +1,85 @@ +/* What pages get back when a section came from Yaci Store analytics instead of the regular endpoints. */ + +/** Where analytics data came from, for the label next to it. */ +export interface AnalyticsSource { + /** The last exported day (ISO date): newer data comes from the live views where the store has them. */ + exportedTo: string | null; + live: boolean; +} + +/* Charts from analytics. Amounts are lovelace; past 2^53 they lose a few lovelace, which charts can't show anyway. */ + +export interface DayPoint { + /** ISO date. */ + date: string; + txs: number; + fees: number; + /** Script runs (redeemers) and transactions with scripts. */ + runs: number; + scriptTxs: number; + steps: number; + mem: number; +} + +export interface TopScript { + script_hash: string; + runs: number; + txs: number; + steps: number; + mem: number; +} + +export interface PotPoint { + epoch: number; + treasury: number; + reserves: number; + fees: number; + rewards: number; +} + +export interface EpochPoint { + epoch: number; + blocks: number; + pools: number; + txs: number; +} + +export interface NetworkInsights extends AnalyticsSource { + /** Days covered by `days` (from, to). */ + from: string; + days: DayPoint[] | null; + topScripts: TopScript[] | null; + /** Days the top scripts are counted over. */ + topScriptDays: number; + pots: PotPoint[] | null; + epochs: EpochPoint[] | null; +} + +export interface DRepPower extends AnalyticsSource { + epoch: number; + total: number; + dreps: number; + top: { drep_id: string | null; drep_type: string; amount: number }[]; +} + +export interface ScriptDay { + date: string; + runs: number; + txs: number; + steps: number; + mem: number; + maxSteps: number; +} + +export interface ScriptInsights extends AnalyticsSource { + from: string; + days: ScriptDay[]; +} + +export interface PoolInsights extends AnalyticsSource { + epochs: { epoch: number; blocks: number; stake: number; delegators: number }[]; +} + +export interface StakeInsights extends AnalyticsSource { + epochs: { epoch: number; rewards: number; stake: number | null; pool_id: string | null }[]; +} diff --git a/applications/viewer/src/lib/api/client.test.ts b/applications/viewer/src/lib/api/client.test.ts new file mode 100644 index 00000000..209c801f --- /dev/null +++ b/applications/viewer/src/lib/api/client.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it, vi } from 'vitest'; +import { ApiError, api } from './client'; + +const ok = () => vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => new Response('{"ok":true}', { status: 200 })); + +describe('api paths', () => { + it('sends an ordinary path to the store', async () => { + const fetch = ok(); + await api('/blocks/12', { fetch, params: { page: 1 } }); + expect(String(fetch.mock.calls[0][0])).toBe('http://localhost:8080/api/v1/blocks/12?page=1'); + }); + + // Route params arrive decoded: /blocks/..%2F..%2Factuator becomes "../../actuator". + it.each([ + '/blocks/../../../actuator/env', + '/blocks/..', + '/blocks/%2e%2e/%2E%2E/actuator', + '/blocks/.\t./x', + '/addresses/a?x=1', + '/addresses/a#frag', + '/addresses/a\\..\\b' + ])('refuses %j without calling the store', async (path) => { + const fetch = ok(); + await expect(api(path, { fetch })).rejects.toMatchObject({ status: 400 }); + await expect(api(path, { fetch })).rejects.toBeInstanceOf(ApiError); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/applications/viewer/src/lib/api/client.ts b/applications/viewer/src/lib/api/client.ts new file mode 100644 index 00000000..a6ecc28a --- /dev/null +++ b/applications/viewer/src/lib/api/client.ts @@ -0,0 +1,140 @@ +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, + /** The viewer gave up waiting. The store may still be working on it: Yaci Store doesn't cancel the query. */ + public timedOut = false + ) { + super(message); + } +} + +type Params = Record; + +export interface ApiOptions { + fetch?: typeof fetch; + params?: Params; + timeoutMs?: number; + signal?: AbortSignal; + /** POSTs this as JSON instead of a GET. */ + body?: unknown; +} + +/** + * Paths built from route params arrive already decoded (SvelteKit decodes %2F, %3F, %23), and URL() would then resolve + * `..` (also written %2e%2e, or with a tab inside: URL() strips tabs and newlines) and treat `?` and `#` as the start of + * a query or fragment. Any of these could reach endpoints outside the API on the store's host, so they are refused. + */ +const UNSAFE_PATH = /(^|\/)\.{1,2}(\/|$)|%2e|[?#\\\x00-\x1f\x7f]/i; + +function buildUrl(path: string, params?: Params): string { + const p = path.startsWith('/') ? path : `/${path}`; + if (UNSAFE_PATH.test(p)) throw new ApiError('Invalid path', 400, path); + const base = new URL(apiBase()); + const url = new URL(apiBase() + p); + if (url.origin !== base.origin || !url.pathname.startsWith(base.pathname.replace(/\/*$/, '/'))) throw new ApiError('Invalid path', 400, 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 (or POST `body` as JSON). 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 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 init: RequestInit = + opts.body === undefined + ? { signal: ctrl.signal, headers: { accept: 'application/json' } } + : { method: 'POST', signal: ctrl.signal, headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(opts.body) }; + const res = await Promise.race([f(url, init), timedOut]); + 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 Promise.race([res.text(), timedOut]); + 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, aborted); + } 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/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/api/slow.ts b/applications/viewer/src/lib/api/slow.ts new file mode 100644 index 00000000..4dcbf628 --- /dev/null +++ b/applications/viewer/src/lib/api/slow.ts @@ -0,0 +1,6 @@ +/* + * The outcome of a section a page streams in after it renders (a slow Yaci Store call): its value, or why it's + * missing. 'heavy': the store didn't answer in time, and the viewer won't ask again for a while (busy address). + * 'busy': the viewer's slots for slow calls were all taken. 'error': anything else. + */ +export type Slow = { ok: true; value: T } | { ok: false; reason: 'heavy' | 'busy' | 'error'; message: string }; 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..0790e95c --- /dev/null +++ b/applications/viewer/src/lib/assets/script.ts @@ -0,0 +1,15 @@ +/* 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'); + +/** 'V1' | 'V2' | 'V3' for Plutus script types (plutusV2, PLUTUS_V3, PlutusScriptV1), else null. */ +export function plutusVersionOf(type: string | null | undefined): 'V1' | 'V2' | 'V3' | null { + const m = String(type ?? '').match(/plutus\D*v?_?([123])/i); + return m ? (`V${m[1]}` as 'V1' | 'V2' | 'V3') : null; +} 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/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..c09d131c --- /dev/null +++ b/applications/viewer/src/lib/cbor.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { decodePlutusData, encodePlutusData, tryDecodePlutusData, type PlutusData } 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(); + }); +}); + +describe('encodePlutusData', () => { + it('encodes the way the Plutus libraries do', () => { + expect(encodePlutusData({ constructor: 0, fields: [] })).toBe('d87980'); + expect(encodePlutusData({ constructor: 0, fields: [{ int: '42' }, { bytes: 'cafe' }] })).toBe('d8799f182a42cafeff'); + expect(encodePlutusData({ constructor: 7, fields: [] })).toBe('d9050080'); + expect(encodePlutusData({ constructor: 200, fields: [] })).toBe('d8668218c880'); + expect(encodePlutusData({ int: '-1' })).toBe('20'); + expect(encodePlutusData({ map: [{ k: { int: '1' }, v: { int: '2' } }] })).toBe('a10102'); + expect(encodePlutusData({ int: (1n << 64n).toString() })).toBe('c249010000000000000000'); + expect(encodePlutusData({ int: (-(1n << 64n) - 1n).toString() })).toBe('c349010000000000000000'); + expect(encodePlutusData({ int: (-(1n << 64n)).toString() })).toBe('3bffffffffffffffff'); + }); + + it('round-trips through the decoder, including chunked bytes', () => { + const samples: PlutusData[] = [ + { bytes: 'ab'.repeat(100) }, + { constructor: 2, fields: [{ list: [{ map: [] }, { int: '18446744073709551615' }, { int: '-18446744073709551617' }] }] }, + { map: [{ k: { bytes: '' }, v: { constructor: 130, fields: [{ int: '0' }] } }, { k: { bytes: '' }, v: { list: [] } }] } + ]; + for (const d of samples) expect(decodePlutusData(encodePlutusData(d))).toEqual(d); + const long = encodePlutusData({ bytes: 'ab'.repeat(100) }); + expect(long.startsWith('5f5840')).toBe(true); + expect(long.endsWith('ff')).toBe(true); + }); +}); diff --git a/applications/viewer/src/lib/cbor.ts b/applications/viewer/src/lib/cbor.ts new file mode 100644 index 00000000..85f859dd --- /dev/null +++ b/applications/viewer/src/lib/cbor.ts @@ -0,0 +1,218 @@ +/* + * 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; + } +} + +// Not `'constructor' in d`: every object inherits a `constructor` property. +const isConstr = (d: PlutusData): d is { constructor: number; fields: PlutusData[] } => typeof (d as { constructor: unknown }).constructor === 'number'; + +/* + * Encode Plutus data to CBOR hex the way the Plutus libraries do: indefinite-length lists (definite when empty), + * byte strings over 64 bytes in 64-byte chunks, integers beyond 64 bits as bignums, and constructor tags + * 121-127 / 1280-1400 / 102. Used to hand a modified ScriptContext back to the julc VM. + */ +export function encodePlutusData(d: PlutusData): string { + const out: number[] = []; + const head = (major: number, n: bigint) => { + const m = major << 5; + if (n < 24n) out.push(m | Number(n)); + else if (n < 0x100n) out.push(m | 24, Number(n)); + else if (n < 0x10000n) out.push(m | 25, Number(n >> 8n), Number(n & 0xffn)); + else if (n < 0x100000000n) out.push(m | 26, ...[24n, 16n, 8n, 0n].map((s) => Number((n >> s) & 0xffn))); + else out.push(m | 27, ...[56n, 48n, 40n, 32n, 24n, 16n, 8n, 0n].map((s) => Number((n >> s) & 0xffn))); + }; + const bytes = (b: number[]) => { + if (b.length <= 64) { + head(2, BigInt(b.length)); + out.push(...b); + return; + } + out.push(0x5f); + for (let i = 0; i < b.length; i += 64) { + const chunk = b.slice(i, i + 64); + head(2, BigInt(chunk.length)); + out.push(...chunk); + } + out.push(0xff); + }; + const list = (items: PlutusData[]) => { + if (!items.length) return out.push(0x80); + out.push(0x9f); + items.forEach(write); + out.push(0xff); + }; + const write = (x: PlutusData): void => { + if (isConstr(x)) { + const c = x.constructor; + if (c <= 6) head(6, BigInt(121 + c)); + else if (c <= 127) head(6, BigInt(1280 + c - 7)); + else { + head(6, 102n); + out.push(0x82); + head(0, BigInt(c)); + } + list(x.fields); + } else if ('int' in x) { + const v = BigInt(x.int); + const n = v < 0n ? -1n - v : v; + if (n < 1n << 64n) head(v < 0n ? 1 : 0, n); + else { + head(6, v < 0n ? 3n : 2n); + const hex = n.toString(16); + bytes((hex.length % 2 ? '0' + hex : hex).match(/../g)!.map((h) => parseInt(h, 16))); + } + } else if ('bytes' in x) bytes((x.bytes.match(/../g) ?? []).map((h) => parseInt(h, 16))); + else if ('list' in x) list(x.list); + else { + head(5, BigInt(x.map.length)); + for (const { k, v } of x.map) { + write(k); + write(v); + } + } + }; + write(d); + return out.map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/applications/viewer/src/lib/dashboard/BlockConveyor.svelte b/applications/viewer/src/lib/dashboard/BlockConveyor.svelte new file mode 100644 index 00000000..5913292c --- /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..f3fb6b53 --- /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/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/format.test.ts b/applications/viewer/src/lib/format.test.ts new file mode 100644 index 00000000..87cb3eb1 --- /dev/null +++ b/applications/viewer/src/lib/format.test.ts @@ -0,0 +1,46 @@ +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' }); + expect(parseUnit('c'.repeat(56) + '0014df105553444d')).toMatchObject({ name: 'USDM', label: 'FT' }); + }); +}); + +describe('tokenAmount', () => { + it('applies the registered decimals', async () => { + const { tokenAmount } = await import('./format'); + expect(tokenAmount('123456789', 6)).toBe('123.456789'); + expect(tokenAmount('1000000000', 6)).toBe('1,000'); + expect(tokenAmount('45000000000000000000', 6)).toBe('45,000,000,000,000'); + expect(tokenAmount('5', 0)).toBe('5'); + expect(tokenAmount('-1500', 3)).toBe('−1.5'); + }); +}); diff --git a/applications/viewer/src/lib/format.ts b/applications/viewer/src/lib/format.ts new file mode 100644 index 00000000..4f1b11a7 --- /dev/null +++ b/applications/viewer/src/lib/format.ts @@ -0,0 +1,177 @@ +/* + * 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); +} + +/** A token quantity in its smallest unit → "1,234.5678" with the registry's decimals (trailing zeros trimmed). */ +export function tokenAmount(quantity: Amountish, decimals: number): string { + let v = toBig(quantity); + const neg = v < 0n; + if (neg) v = -v; + if (!(decimals > 0)) return (neg ? '−' : '') + group(v.toString()); + const scale = 10n ** BigInt(decimals); + const frac = (v % scale).toString().padStart(decimals, '0').replace(/0+$/, ''); + return (neg ? '−' : '') + group((v / scale).toString()) + (frac ? '.' + frac : ''); +} + +/** 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; + } +} + +/** 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); + 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/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..15fae40b --- /dev/null +++ b/applications/viewer/src/lib/governance/DRepCertList.svelte @@ -0,0 +1,70 @@ + + + + {#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..a7f560b4 --- /dev/null +++ b/applications/viewer/src/lib/governance/ProposalTable.svelte @@ -0,0 +1,46 @@ + + + `${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..73a92ff7 --- /dev/null +++ b/applications/viewer/src/lib/governance/anchor.server.ts @@ -0,0 +1,76 @@ +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; +} + +/** Lookups running at once: each can hold a socket and up to 1 MB for the whole budget. */ +const MAX_ACTIVE = 6; +let active = 0; +/** Documents fetched recently, by URL: anchors don't change, and a popular proposal shouldn't refetch per visitor. */ +const CACHE_MS = 10 * 60_000; +const CACHE_MAX = 200; +const cache = new Map(); + +export async function fetchAnchor(raw: string): Promise { + const hit = cache.get(raw); + if (hit && Date.now() - hit.at < CACHE_MS) return hit.result; + if (active >= MAX_ACTIVE) throw new AnchorError('Too many documents are being fetched right now. Try again in a moment.'); + active++; + try { + const result = await fetchUncached(raw); + cache.delete(raw); + cache.set(raw, { at: Date.now(), result }); + while (cache.size > CACHE_MAX) cache.delete(cache.keys().next().value as string); + return result; + } finally { + active--; + } +} + +async function fetchUncached(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/insights/ChartCard.svelte b/applications/viewer/src/lib/insights/ChartCard.svelte new file mode 100644 index 00000000..bb05af6e --- /dev/null +++ b/applications/viewer/src/lib/insights/ChartCard.svelte @@ -0,0 +1,37 @@ + + +
+
+
{title}
+
{value}
+ {#if sub}
{sub}
{/if} +
+ + {#if children}{@render children()}{/if} +
diff --git a/applications/viewer/src/lib/insights/DRepPower.svelte b/applications/viewer/src/lib/insights/DRepPower.svelte new file mode 100644 index 00000000..bf661e80 --- /dev/null +++ b/applications/viewer/src/lib/insights/DRepPower.svelte @@ -0,0 +1,43 @@ + + +
+
+
+

Voting power

+

+ The 10 largest of {num(power.dreps)} DReps and options in epoch {power.epoch}: {percent(shown, power.total, 0)} of ₳{adaCompact(power.total)} delegated. +

+
+ +
+
    + {#each power.top as d, i (d.drep_id ?? d.drep_type)} +
  1. + {i + 1} + {#if d.drep_id} + {shorten(d.drep_id, 12, 6)} +
  2. + {/each} +
+
diff --git a/applications/viewer/src/lib/insights/NetworkInsights.svelte b/applications/viewer/src/lib/insights/NetworkInsights.svelte new file mode 100644 index 00000000..04ec7970 --- /dev/null +++ b/applications/viewer/src/lib/insights/NetworkInsights.svelte @@ -0,0 +1,116 @@ + + +
+
+
+

Network insights

+

The last {days.length || 90} days and recent epochs, from Yaci Store analytics.

+
+ +
+ + {#if days.length && lastDay} +
+ num(Math.round(v))} + /> + num(Math.round(v))} + /> + `${compact(v)} steps`} + /> + +
+ {/if} + +
+ {#if lastPot} + p.treasury)} color="rgb(var(--c-mint))" href="/adapot" /> + p.reserves)} color="rgb(var(--c-blue))" href="/adapot" /> + {/if} + {#if lastEpoch} + e.pools)} + color="rgb(var(--c-violet))" + href="/epochs/{lastEpoch.epoch}" + /> + {/if} +
+ + {#if s.topScripts?.length} +
+
+

Most-run scripts

+ last {s.topScriptDays} days +
+
    + {#each s.topScripts as t, i (t.script_hash)} +
  1. + {i + 1} + {shorten(t.script_hash, 10, 6)} +
  2. + {/each} +
+
+ {/if} +
diff --git a/applications/viewer/src/lib/insights/PoolHistory.svelte b/applications/viewer/src/lib/insights/PoolHistory.svelte new file mode 100644 index 00000000..782ebbdf --- /dev/null +++ b/applications/viewer/src/lib/insights/PoolHistory.svelte @@ -0,0 +1,39 @@ + + +{#if e.length} +
+ + {#snippet actions()}{/snippet} +
+
+
Blocks per epoch
+ `${num(v)} blocks`} /> +
+
+
Active stake
+ `₳${adaCompact(Math.round(v))}`} /> +
+
+
Delegators
+ num(v)} /> +
+
+
+
+{/if} diff --git a/applications/viewer/src/lib/insights/ScriptUsage.svelte b/applications/viewer/src/lib/insights/ScriptUsage.svelte new file mode 100644 index 00000000..dec2797c --- /dev/null +++ b/applications/viewer/src/lib/insights/ScriptUsage.svelte @@ -0,0 +1,38 @@ + + +
+ + {#snippet actions()}{/snippet} + {#if usage.days.length} +
+
+
Runs per day
+ `${num(v)} runs`} /> +
+
+
CPU steps per day
+ `${compact(v)} steps`} /> +
+
+
Costliest run each day · peak {compact(peak)} steps
+ `${compact(v)} steps`} /> +
+
+ {/if} +
+
diff --git a/applications/viewer/src/lib/insights/StakeHistory.svelte b/applications/viewer/src/lib/insights/StakeHistory.svelte new file mode 100644 index 00000000..53e4e398 --- /dev/null +++ b/applications/viewer/src/lib/insights/StakeHistory.svelte @@ -0,0 +1,34 @@ + + +{#if e.length} +
+ + {#snippet actions()}{/snippet} +
+
+
Rewards per epoch
+ ({ label: epochLabel(x.epoch), value: x.rewards }))} color="rgb(var(--c-mint))" height={100} label="Rewards per epoch" format={ada} /> +
+
+
Active stake
+ ({ label: epochLabel(x.epoch), value: x.stake ?? 0 }))} kind="area" color="rgb(var(--c-blue))" height={100} label="Active stake" format={ada} /> +
+
+
+
+{/if} diff --git a/applications/viewer/src/lib/insights/labels.ts b/applications/viewer/src/lib/insights/labels.ts new file mode 100644 index 00000000..60142699 --- /dev/null +++ b/applications/viewer/src/lib/insights/labels.ts @@ -0,0 +1,7 @@ +/* Axis and tooltip labels for the analytics charts. */ + +/** "2026-10-17" → "17 Oct". */ +export const dayLabel = (iso: string) => new Date(`${iso}T00:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short', timeZone: 'UTC' }); + +/** 662 → "Epoch 662". */ +export const epochLabel = (epoch: number) => `Epoch ${epoch}`; diff --git a/applications/viewer/src/lib/julc/CodeView.svelte b/applications/viewer/src/lib/julc/CodeView.svelte new file mode 100644 index 00000000..a9a3682b --- /dev/null +++ b/applications/viewer/src/lib/julc/CodeView.svelte @@ -0,0 +1,194 @@ + + +
+
+ + {#if needle.length >= 2} + {hits.length ? `${current + 1} / ${hits.length}${hits.length >= 5000 ? '+' : ''}` : 'No matches'} + + + {/if} + {lines.length.toLocaleString()} lines + + + +
+ + {#if wrap} + +
{text}
+ {:else} +
(scrollTop = e.currentTarget.scrollTop)} + role="region" + aria-label={label} + tabindex="-1" + > + +
+
+ {#each visible as line (line.n)} + {@const isActive = inActive(line.n)} + {@const isStart = isActive && active?.startLine === line.n + 1} + {@const isBreak = breakpoints?.has(line.n + 1)} +
+ {#if ontoggle} + + {:else} + {line.n + 1} + {/if} + {#each line.tokens as t}{#if needle.length >= 2 && hitSet.has(line.n)}{#each splitMatches(t.text, needle) as part}{part.text}{/each}{:else}{t.text}{/if}{/each} +
+ {/each} +
+
+
+ {/if} +
diff --git a/applications/viewer/src/lib/julc/ScriptActivity.svelte b/applications/viewer/src/lib/julc/ScriptActivity.svelte new file mode 100644 index 00000000..ba108553 --- /dev/null +++ b/applications/viewer/src/lib/julc/ScriptActivity.svelte @@ -0,0 +1,114 @@ + + + + {#snippet icon()}{/snippet} + {#if runs.length} +
+ {#each [{ l: 'Executions', v: num(runs.length), s: purposes.map(([p, n]) => `${n} ${p}`).join(' · ') }, { l: 'Median CPU', v: num(median(steps)), s: `max ${num(Math.max(...steps))}` }, { l: 'Median memory', v: num(median(mems)), s: `max ${num(Math.max(...mems))}` }, { l: 'Peak of a tx limit', v: `${peakPct.toFixed(1)}%`, s: 'CPU or memory, declared' }] as f (f.l)} +
+
{f.l}
+
{f.v}
+
{f.s}
+
+ {/each} +
+ +
Declared CPU per execution
+ + {#each chart as r, i} + {@const h = Math.max(2, (Number(r.unit_steps) / maxSteps) * 76)} + {num(r.unit_steps)} CPU · {num(r.unit_mem)} mem · tx {shorten(r.tx_hash, 8, 6)} + {/each} + + +
Redeemer variants
+
    + {#each variants as [name, g] (name)} +
  • + + {#if openVariant === name && g.sample}
    {/if} +
  • + {/each} +
+ +
Recent executions
+
+ + + + {#each runs.slice(0, 25) as r (r.tx_hash + r.purpose + r.tx_index)} + {@const purpose = purposeOf(r.purpose) ?? r.purpose} + + + + + + + + + {/each} + +
TransactionPurposeRedeemerCPUMemory
{shorten(r.tx_hash, 8, 6)}{purpose} #{r.tx_index}{shapeOf(r.redeemer_data_hash ? activity.redeemers[r.redeemer_data_hash] : undefined)}{num(r.unit_steps)}{num(r.unit_mem)}Debug
+
+

Budgets are as declared in each transaction. Re-run one from its Scripts tab, or open it in the debugger, to see what it actually used.

+ {/if} +
diff --git a/applications/viewer/src/lib/julc/ScriptCode.svelte b/applications/viewer/src/lib/julc/ScriptCode.svelte new file mode 100644 index 00000000..3f99bde0 --- /dev/null +++ b/applications/viewer/src/lib/julc/ScriptCode.svelte @@ -0,0 +1,164 @@ + + + + {#snippet icon()}{/snippet} + {#snippet actions()} + {#if view === 'cbor'}{/if} + {/snippet} + +
+ + {#if view === 'cbor'} + {cbor} + {:else if installed === false} + + {:else if !supported} + + {:else if !started} +
+ +

Read this script

+

+ Decode the compiled code into readable UPLC, see which builtins it uses and check its hash. It runs in your browser with the julc engine, + a few megabytes downloaded once. +

+ + +
+ {:else if failure} +
+ +
+

{julc.status === 'unsupported' ? 'This browser can’t run the julc engine' : 'Couldn’t read this script'}

+

{failure}

+ {#if julc.status !== 'unsupported'} + + {/if} +
+
+ {:else if !decoded} +
+ + {julc.status === 'loading' ? 'Starting the julc engine… (first time only)' : 'Decoding…'} +
+ + {:else if info} +
+
Language
Plutus {info.language ?? '—'}
+
Plutus Core
{info.programVersion ?? '—'}
+
Size
{num(info.flatBytes)} bytes
+
Terms
{num(info.termCount)}
+
Parameters applied
{info.paramsApplied}
+
+
Hash
+ {#if verified} +
Verified
+ {:else if verified === false} +
Mismatch
+ {:else} +
—
+ {/if} +
+
+ + {#if verified === false} +

+ This code hashes to {info.scriptHash}, not to this script's hash. The store may hold the wrong body, or the language was misread. +

+ {/if} + {#if info.warnings?.length} +
    + {#each info.warnings as w}
  • {w}
  • {/each} +
+ {/if} + {#if info.builtins?.length} +
+
Builtins used ({info.builtins.length})
+
+ {#each info.builtins as b} + + {/each} +
+
+ {/if} + + {/if} +
diff --git a/applications/viewer/src/lib/julc/ScriptRuns.svelte b/applications/viewer/src/lib/julc/ScriptRuns.svelte new file mode 100644 index 00000000..1681166c --- /dev/null +++ b/applications/viewer/src/lib/julc/ScriptRuns.svelte @@ -0,0 +1,244 @@ + + +{#if runnable && installed !== false} +
+
+ +
+

Re-run in your browser

+

+ {#if !supported} + This browser can’t run the julc engine (it needs WebAssembly GC and exception handling). Use a current Chrome, Edge, Firefox or Safari. + {:else} + Execute {runnable === 1 ? 'the script' : `all ${runnable} scripts`} again with julc, using the cost model of epoch {page.epoch ?? 'latest'}, and compare with the budget declared on chain. + {/if} +

+ {#if invalid} +

This transaction failed script validation on chain. A re-run shows which script failed and why.

+ {/if} +
+ {#if supported} +
+ {#if phase === 'loading' || phase === 'running'} + + + {phase === 'loading' ? 'Rebuilding the transaction…' : julc.status === 'loading' ? 'Starting the julc engine…' : 'Running…'} + + {:else} + + {/if} + + Fork this transaction +
+ {/if} +
+ {#if phase === 'done' && done.length} +
+ {#if summary.exact}{summary.exact} reproduced exactly{/if} + {#if summary.margin}{summary.margin} reproduced with the builder’s margin{/if} + {#if summary.passed - summary.exact - summary.margin}{summary.passed - summary.exact - summary.margin} passed{/if} + {#if summary.failed}{summary.failed} failed{/if} + {#if summary.other}{summary.other} couldn’t run{/if} +
+
+ {/if} + {#if failure}

{failure}

{/if} +
+{/if} + +
+ {#each page.scripts as s, i (s.script_hash + i)} + {@const key = keyOf(s)} + {@const r = key ? results[key] : undefined} + + {#if r} +
+
+ julc re-run + {#if r.state === 'running'} + Waiting… + {:else if r.state === 'skipped' || r.state === 'error'} + Couldn’t run + {:else if passed(r)} + Passed + {@const m = margin(r)} + {#if exact(r)} + Reproduced exactly + {:else if m != null} + Reproduced · declared with a {m}% margin + {/if} + {:else} + {r.evaluation?.status === 'budgetExhausted' ? 'Out of budget' : 'Failed'} + {/if} + + {#if r.ms != null}{r.ms} ms{/if} + {#if r.args && r.state === 'done'} + {passed(r) ? 'Debug' : 'Debug the failure'} + {/if} + +
+ + {#if r.error}

{r.error}

{/if} + + {#if r.evaluation} + {@const ev = r.evaluation} + {#if r.plan.declared} +
+ {#each [{ label: 'Memory', got: ev.mem, want: r.plan.declared.mem }, { label: 'CPU steps', got: ev.cpu, want: r.plan.declared.steps }] as b (b.label)} +
+ + {b.label} + {num(b.got)} / {num(b.want)} declared + {#if b.got !== b.want} + {pct(b.got, b.want)}% + {/if} +
+ {/each} +
+ {/if} + {#if !passed(r) && ev.message}

{ev.message}

{/if} + {#if invalid} +

+ {passed(r) ? 'Passed here, though the transaction failed on chain: another script in it may have failed, or the rebuilt context differs.' : 'Failed on chain too.'} +

+ {/if} + {#if ev.traces?.length} +
+
Traces
+
    + {#each ev.traces as t, n}
  1. {n + 1}{t}
  2. {/each} +
+
+ {/if} + {#if !passed(r) && ev.lastBuiltins?.length} +
+ Last builtins + {#each ev.lastBuiltins as b}{b}{/each} +
+ {/if} + {#if ev.scriptContextCbor} +
+ What the validator saw (ScriptContext) +
+
+ {/if} + {/if} + + {#if r.plan.notes.length && r.state !== 'skipped'} +
    + {#each r.plan.notes as note}
  • {note}
  • {/each} +
+ {/if} +
+ {/if} +
+ {/each} +
diff --git a/applications/viewer/src/lib/julc/SourceBadge.svelte b/applications/viewer/src/lib/julc/SourceBadge.svelte new file mode 100644 index 00000000..6c0bae8e --- /dev/null +++ b/applications/viewer/src/lib/julc/SourceBadge.svelte @@ -0,0 +1,27 @@ + + +{#if source === 'cbor'} + + {compact ? 'CBOR' : 'From the transaction’s CBOR'} + +{:else if source === 'store'} + {#if compact} + Rebuilt + {:else} + + + Rebuilt from Yaci Store data. {rebuiltWhy} + + {/if} +{/if} diff --git a/applications/viewer/src/lib/julc/activity.server.ts b/applications/viewer/src/lib/julc/activity.server.ts new file mode 100644 index 00000000..acf8ef1a --- /dev/null +++ b/applications/viewer/src/lib/julc/activity.server.ts @@ -0,0 +1,24 @@ +import { tryApi } from '$lib/api/client'; +import type { ScriptActivity, ScriptExecution } from './activity'; + +/* + * A script's recent executions (newest first) from Yaci Store, with the redeemers' data, for the Activity panel on + * the script page. Runs on the server, as some stores don't allow these endpoints from the browser. + */ + +export async function loadScriptActivity(hash: string, fetch: typeof globalThis.fetch, count = 100): Promise { + const [executions, params] = await Promise.all([ + tryApi(`/blockfrost/scripts/${hash}/redeemers`, { fetch, params: { count, order: 'desc' }, timeoutMs: 15000 }), + tryApi<{ max_tx_ex_mem?: number; max_tx_ex_steps?: number }>('/epochs/latest/parameters', { fetch }) + ]); + if (!Array.isArray(executions)) return null; + const wanted = [...new Set(executions.map((e) => e.redeemer_data_hash).filter((h): h is string => !!h))].slice(0, 40); + const found = await Promise.all(wanted.map(async (h) => [h, (await tryApi<{ cbor: string }>(`/scripts/datum/${h}/cbor`, { fetch, timeoutMs: 8000 }))?.cbor] as const)); + const redeemers: Record = {}; + for (const [h, c] of found) if (c) redeemers[h] = c; + return { + executions, + redeemers, + limits: { mem: Number(params?.max_tx_ex_mem) || 14_000_000, steps: Number(params?.max_tx_ex_steps) || 10_000_000_000 } + }; +} diff --git a/applications/viewer/src/lib/julc/activity.ts b/applications/viewer/src/lib/julc/activity.ts new file mode 100644 index 00000000..a9b12629 --- /dev/null +++ b/applications/viewer/src/lib/julc/activity.ts @@ -0,0 +1,18 @@ +/* A script's recent executions, as loaded by activity.server.ts for the Activity panel. */ + +export interface ScriptExecution { + tx_hash: string; + tx_index: number; + purpose: string; + redeemer_data_hash: string | null; + unit_mem: string; + unit_steps: string; + fee?: string | null; +} + +export interface ScriptActivity { + executions: ScriptExecution[]; + /** Redeemer data hash → CBOR, for the distinct redeemers (at most 40). */ + redeemers: Record; + limits: { mem: number; steps: number }; +} diff --git a/applications/viewer/src/lib/julc/authoring/JavaDebugger.svelte b/applications/viewer/src/lib/julc/authoring/JavaDebugger.svelte new file mode 100644 index 00000000..e26d691f --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/JavaDebugger.svelte @@ -0,0 +1,224 @@ + + +{#if phase === 'opening'} +

Compiling a debug build and running it…

+{:else if phase === 'error'} +
+ +

Can’t debug the Java source

{failure}

+
+{:else} +
+ + Experimental (julc 0.1.0-pre18). {warning ?? 'This debug build is compiled without the optimizer, so its budget differs from your script’s.'} Only some steps map to Java lines (reliably the one a validator fails on), and many locals aren’t tracked yet. +
+ {#if unmapped}

julc mapped none of this run’s steps to a Java line, so there is nothing to stop on. The UPLC debugger steps through every term.

{/if} +
+
+
+ + + + + step {num(String(snap?.step ?? 0))} / {num(String(timeline?.totalSteps ?? 0))} +
+
    + {#each lines as text, i (i)} + {@const n = i + 1} + {@const can = executable.includes(n)} +
  1. + + {text || ' '} +
  2. + {/each} +
+
+
+ +
+
Java line
{javaLine ?? '—'}
+
Stopped by
{snap?.stopReason ?? '—'}
+
CPU
{num(String(snap?.cpu ?? 0))}
+
Memory
{num(String(snap?.mem ?? 0))}
+
+ {#if snap?.finished}

{snap.status === 'success' ? 'The validator returned true.' : (snap.error ?? 'The validator failed.')}

{/if} + {#if snap?.traces?.length}
Traces
    {#each snap.traces as t, i (i)}
  • {t}
  • {/each}
{/if} +
+ + {#if localsNote && !locals.length}

{localsNote}

{/if} + {#each locals as scope (scope.kind + scope.variables.length)} +
{scope.kind}
+
    + {#each scope.variables as v (v.name)} +
  • +
    + {v.name}{v.declaredType} + {#if v.value} + {v.value.summary} + {#if v.value.childrenHandle}{/if} + {:else} + {AVAIL[v.availability ?? ''] ?? v.availability} + {/if} +
    + {#if v.value?.childrenHandle && expanded[v.value.childrenHandle]} +
      + {#each expanded[v.value.childrenHandle] as c, i (i)}
    • {c.name} {c.value?.summary}
    • {/each} +
    + {/if} +
  • + {/each} +
+ {/each} +
+
+
+ {#if failure}

{failure}

{/if} +{/if} diff --git a/applications/viewer/src/lib/julc/authoring/JavaEditor.svelte b/applications/viewer/src/lib/julc/authoring/JavaEditor.svelte new file mode 100644 index 00000000..1da80f24 --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/JavaEditor.svelte @@ -0,0 +1,57 @@ + + +
+ + +
diff --git a/applications/viewer/src/lib/julc/authoring/ValidatorLab.svelte b/applications/viewer/src/lib/julc/authoring/ValidatorLab.svelte new file mode 100644 index 00000000..866e08fe --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/ValidatorLab.svelte @@ -0,0 +1,692 @@ + + +{#if phase === 'error'} +
+ +

Can’t run validators here

{failure}

+
+{:else} +
+
+ + + {#snippet actions()} +
+ {#each [['java', 'Java'], ['cbor', 'Compiled'], ['chain', 'On chain']] as [id, name] (id)} + + {/each} +
+ {/snippet} + + {#if mode === 'java'} +
+ Start from + {#each EXAMPLES as e (e.id)} + + {/each} +
+ +
+ {#if phase === 'starting' || (compiling && !compiled)}{julc.status === 'loading' ? 'Starting the julc engine…' : 'Compiling…'} + {:else if compiled?.compiledCode} + Compiled + {check?.contractName} · {String(check?.purpose ?? '').toLowerCase()} · {num(compiled.scriptSizeBytes)} bytes + {#if compiling}{/if} + + {:else if compiled || compileError} + Doesn’t compile + {/if} +
+ {#if compileError}

{compileError}

{/if} + {#each (compiled?.diagnostics ?? []).concat(compiled?.compiledCode ? [] : (check?.diagnostics ?? [])) as d, i (i)} +

line {d.startLine ?? '?'} · {d.message}{#if d.suggestion} — {d.suggestion}{/if}

+ {/each} + {#if compiled?.params?.length} +
+
Parameters
+
+ {#each compiled.params as p, i (p.name)} + + {/each} +
+
+ {/if} + {#if compiled?.uplcText} +
+ Compiled UPLC +
+
+ {/if} + {:else if mode === 'cbor'} + +
+ Plutus +
{#each ['V1', 'V2', 'V3'] as v (v)}{/each}
+
+ {:else} +
+ e.key === 'Enter' && loadChainScript()} /> + +
+ {#if chainError}

{chainError}

{/if} + {#if chainScript}

Plutus {versionOf(chainScript.type).slice(1)} · {shorten(chainScript.hash, 10, 6)}

{/if} + {/if} + {#if scriptError}

{scriptError}

{/if} + {#if script} +
+ Hash{script.params.length ? ' with parameters' : ''} + {shorten(script.hash, 10, 8)} + + {#if purpose === 'spend'}address {shorten(scriptAddress(script.hash, mainnet), 12, 6)}{/if} +
+ {/if} +
+ + + + {#snippet actions()} +
+ {#each [['spend', 'Spend'], ['mint', 'Mint'], ['reward', 'Withdraw']] as [id, name] (id)} + + {/each} +
+ {/snippet} +
+ e.key === 'Enter' && searchUtxos()} /> + +
+ {#if utxoError}

{utxoError}

{/if} + {#if utxoChoices.length} +
    + {#each utxoChoices as u (u.tx_hash + u.output_index)} +
  • + {/each} +
+ {/if} + {#if utxo} +
+
+ {shorten(utxo.tx_hash, 10, 6)}#{utxo.output_index} + {ada(lovelaceOf(utxo), 'auto')} ada + {#if utxo.amount.length > 1}+{utxo.amount.length - 1} token{utxo.amount.length > 2 ? 's' : ''}{/if} + {#if utxoDatum}datum{/if} + +
+ {#if utxo.address}
from {utxo.address}
{/if} +
+ {:else} +

No UTxO chosen: a sample 10 ada one is used.

+ {/if} + + {#if purpose === 'spend'} +
+
+ Datum + {#if utxoDatum && datum !== utxoDatum}{/if} + {#if check?.datumFields?.length}{/if} +
+ c && (datum = c)} /> + {#if schemaOf('datum')}
{/if} +
+ {:else if purpose === 'mint'} +
+
Mint under this policy
+ {#each mint as m, i (i)} +
+ + + +
+ {/each} + +
+ {:else} + + {/if} +
+ + + +
+ Redeemer + {#each check?.redeemerVariants ?? [] as v (v.tag)} + + {/each} +
+ c && (redeemer = c)} /> + {#if schemaOf('redeemer')}
{/if} + +
Signers
+
+ {#each signers as s (s)} +
+ {s} + +
+ {/each} +
+ e.key === 'Enter' && addSigner()} /> + + {#if utxo?.address && paymentKeyHash(utxo.address) && !signers.includes(paymentKeyHash(utxo.address)!)}{/if} +
+
+ +
Valid between slots
+
+ {#each [['from', 'Valid from'], ['to', 'Valid until']] as [which, name] (which)} + {@const text = which === 'from' ? validFrom : validTo} + {@const posix = posixOf(text)} +
+
+ (which === 'from' ? (validFrom = e.currentTarget.value) : (validTo = e.currentTarget.value))} placeholder="{name} (slot, empty: no bound)" aria-label="{name} slot" /> + {#if tipSlot != null}{/if} +
+ {#if posix != null}
{posix} ms · {new Date(Number(posix)).toISOString().replace('.000Z', 'Z')}
{/if} +
+ {/each} +
+ +
Other withdrawals · e.g. a withdraw-zero stake script the validator expects
+ {#each withdrawals as w, i (i)} +
+ + + +
+ {/each} + + +
Outputs
+ {#each outputs as o, i (i)} +
+ + + +
+ {/each} +
+ + +
+
+
+ + +
+ + {#snippet actions()}{#if running || compiling || phase === 'starting'}{/if}{/snippet} + {#if runError} +
{runError}
+ {:else if !script} +

{mode === 'java' ? 'The result appears once the validator compiles.' : mode === 'cbor' ? 'Paste a compiled script.' : 'Load a script from this chain.'}

+ {:else if !result} +

Running…

+ {:else if result.state !== 'done'} +
{result.error}
+ {:else} + {#key generation} +
+ {#if accepted}{:else}{/if} +
+

{accepted ? 'The validator accepts it' : outcome(result) === 'out of budget' ? 'Out of budget' : 'The validator rejects it'}

+ {#if !accepted && result.evaluation?.error}

{result.evaluation.error}

{/if} + {#if !accepted && failedAt}{/if} +
+
+ {/key} +
+
CPU
{num(result.evaluation!.cpu)}
{share(result.evaluation!.cpu, limits.cpu)}
+
Memory
{num(result.evaluation!.mem)}
{share(result.evaluation!.mem, limits.mem)}
+
+ {#if result.evaluation?.traces?.length} +
Traces
+
    {#each result.evaluation.traces as t, i (i)}
  • {t}
  • {/each}
+ {/if} + {#if result.plan.notes.length}
    {#each result.plan.notes as n (n)}
  • {n}
  • {/each}
{/if} +
+ {#if result.args}{/if} + {#if mode === 'java' && compiled?.compiledCode}{/if} + {#if result.args}{/if} +
+ {/if} +
+

Runs in your browser with this chain’s current cost model and limits. The transaction is built from your inputs; it isn’t signed or submitted.

+
+
+ + {#if debugging && result?.args} +
+
+

{debugging === 'java' ? 'Java source' : 'UPLC'} debugger

+ +
+ {#key generation} + {#if debugging === 'java'} + + {:else} + + {/if} + {/key} +
+ {/if} +{/if} diff --git a/applications/viewer/src/lib/julc/authoring/examples.ts b/applications/viewer/src/lib/julc/authoring/examples.ts new file mode 100644 index 00000000..7d2a143e --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/examples.ts @@ -0,0 +1,87 @@ +/* Starter validators for the validator lab, in julc Java. Each compiles with julc 0.1.0-pre18. */ + +export interface Example { + id: string; + name: string; + summary: string; + source: string; +} + +export const EXAMPLES: Example[] = [ + { + id: 'vesting', + name: 'Vesting', + summary: 'Spend: the beneficiary can claim after a time; the owner can cancel at any time.', + source: `import java.math.BigInteger; + +@SpendingValidator +class Vesting { + record VestingDatum(byte[] owner, byte[] beneficiary, BigInteger unlockAt) {} + + sealed interface Action { + record Claim() implements Action {} + record Cancel() implements Action {} + } + + @Entrypoint + static boolean validate(VestingDatum datum, Action action, ScriptContext ctx) { + TxInfo txInfo = ctx.txInfo(); + if (action instanceof Action.Cancel cancel) { + ContextsLib.trace("cancel: needs the owner's signature"); + return ContextsLib.signedBy(txInfo, datum.owner()); + } + ContextsLib.trace("claim: needs the beneficiary, after unlockAt"); + boolean signed = ContextsLib.signedBy(txInfo, datum.beneficiary()); + BigInteger from = IntervalLib.finiteLowerBound(txInfo.validRange()); + return signed && from.compareTo(datum.unlockAt()) >= 0; + } +} +` + }, + { + id: 'owner', + name: 'Owner only', + summary: 'Spend: a script parameter fixes the one key that may spend.', + source: `@SpendingValidator +class OwnerOnly { + @Param static byte[] owner; + + @Entrypoint + static boolean validate(PlutusData datum, PlutusData redeemer, ScriptContext ctx) { + boolean signed = ContextsLib.signedBy(ctx.txInfo(), owner); + if (!signed) ContextsLib.trace("not signed by the owner"); + return signed; + } +} +` + }, + { + id: 'mint', + name: 'Signed mint', + summary: 'Mint: minting needs the issuer’s signature; anyone may burn.', + source: `import java.math.BigInteger; + +@MintingValidator +class SignedMint { + @Param static byte[] issuer; + + sealed interface Action { + record Mint(BigInteger amount) implements Action {} + record Burn() implements Action {} + } + + @Entrypoint + static boolean validate(Action action, ScriptContext ctx) { + if (action instanceof Action.Mint mint) { + if (mint.amount().compareTo(BigInteger.ZERO) <= 0) { + ContextsLib.trace("mint amount must be positive"); + return false; + } + return ContextsLib.signedBy(ctx.txInfo(), issuer); + } + return true; + } +} +` + } +]; diff --git a/applications/viewer/src/lib/julc/authoring/lab.test.ts b/applications/viewer/src/lib/julc/authoring/lab.test.ts new file mode 100644 index 00000000..59ef9ee0 --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/lab.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { bech32Encode } from '$lib/bech32'; +import { decodePlutusData, type PlutusData } from '$lib/cbor'; +import { labPlan, labPurposeOf, paramData, paymentKeyHash, scriptAddress, templateOf, type LabTx } from './lab'; +import { utxoOf } from './utxo'; + +const key = 'a0f1aa7dca95017c11e7e373aebcf0c4568cf47ec12b94f8eb5bba8b'; +const addr = bech32Encode('addr_test', new Uint8Array([0x60, ...key.match(/../g)!.map((h) => parseInt(h, 16))])); +const utxo = { tx_hash: 'ff'.repeat(32), output_index: 1, address: addr, amount: [{ unit: 'lovelace', quantity: '5000000' }, { unit: 'bb'.repeat(28) + '01', quantity: '2' }] }; +const script = { cbor: '4e4d01000033222220051200120011', language: 'V3' as const, params: [], hash: 'cc'.repeat(28) }; +const tx = (over: Partial = {}): LabTx => ({ purpose: 'spend', utxo, atScript: true, datum: 'd87980', redeemer: 'd87980', referenceInputs: [], outputs: [{ address: addr, lovelace: '4800000' }], mint: [], withdrawal: '0', signers: [key], validFrom: null, validTo: null, fee: '200000', ...over }); +const slots = { zeroTime: 1792193424000, zeroSlot: 0, slotLength: 200 }; + +describe('labPlan', () => { + it('spends a real UTxO as if locked at the script, with the datum inline', () => { + const plan = labPlan(script, tx({ validFrom: 10 }), { params: null, slots }); + const t = plan.request!.transaction; + expect(plan.key).toBe('spend#0'); + expect(t.inputs[0]).toMatchObject({ txId: utxo.tx_hash, index: 1n, address: { payment: '$self' }, datum: { kind: 'inline', data: { format: 'cbor', value: 'd87980' } } }); + expect(t.inputs[0].value.assets).toEqual([{ policyId: 'bb'.repeat(28), tokenName: '01', quantity: '2' }]); + expect(t.validRange.from).toBe(1792193426000n); + expect(t.signatories).toEqual([key]); + expect(plan.datum).toBe('d87980'); + }); + + it('mints under the script’s policy and withdraws from its stake credential', () => { + const mint = labPlan(script, tx({ purpose: 'mint', mint: [{ tokenName: 'cafe', quantity: '5' }] }), { params: null, slots: null }); + expect(mint.request!.transaction.purpose).toEqual({ type: 'mint', target: script.hash }); + expect(mint.request!.transaction.mint).toEqual([{ policyId: script.hash, tokenName: 'cafe', quantity: '5' }]); + expect(mint.request!.transaction.inputs[0].address.payment).toBe(addr); + const reward = labPlan(script, tx({ purpose: 'reward' }), { params: null, slots: null }); + expect(reward.request!.transaction.withdrawals).toEqual([{ credential: `script:${script.hash}`, amount: '0' }]); + }); + + it('applies parameters, the epoch’s cost model and limits', () => { + const plan = labPlan({ ...script, params: ['4100'] }, tx(), { params: { cost_models_raw: { PlutusV3: [1, 2] }, protocol_major_ver: 10, max_tx_ex_steps: '10' }, slots }); + expect(plan.request!.script.params).toEqual([{ format: 'cbor', value: '4100' }]); + expect(plan.request!.costModel!.parameters).toEqual([1n, 2n]); + expect(plan.request!.target.protocol).toBe(10); + expect(plan.request!.maxCpu).toBe(10n); + }); + + it('explains what it can’t build', () => { + expect(() => labPlan(script, tx({ validFrom: 5 }), { params: null, slots: null })).toThrow(/Slot times/); + expect(() => labPlan({ ...script, language: 'V2' }, tx({ datum: null }), { params: null, slots })).toThrow(/needs a datum/); + expect(() => labPlan(script, tx({ signers: ['zz'] }), { params: null, slots })).toThrow(/key hash/); + expect(labPlan(script, tx({ utxo: null }), { params: null, slots }).notes[0]).toMatch(/sample/); + }); +}); + +describe('lab helpers', () => { + it('types parameters by their declared type', () => { + expect(decodePlutusData(paramData('byte[]', key, JSON.parse))).toEqual({ bytes: key }); + expect(decodePlutusData(paramData('BigInteger', '42', JSON.parse))).toEqual({ int: '42' }); + expect(decodePlutusData(paramData('PlutusData', '{"constructor":1,"fields":[]}', JSON.parse))).toEqual({ constructor: 1, fields: [] }); + expect(() => paramData('byte[]', 'xyz', JSON.parse)).toThrow(/hex/); + }); + + it('makes templates, purposes and addresses', () => { + expect(decodePlutusData(templateOf(1, [{ type: 'Integer' }, { type: 'ByteString' }, { type: 'Address' }]))).toEqual({ constructor: 1, fields: [{ int: '0' }, { bytes: '' }, { constructor: 0, fields: [] }] } as PlutusData); + expect(labPurposeOf('MINTING')).toBe('mint'); + expect(labPurposeOf('WITHDRAW')).toBe('reward'); + expect(labPurposeOf('VOTING')).toBeNull(); + expect(paymentKeyHash(addr)).toBe(key); + expect(paymentKeyHash(bech32Encode('addr_test', new Uint8Array([0x70, ...Array(28).fill(1)])))).toBeNull(); + expect(scriptAddress('cc'.repeat(28), false)).toMatch(/^addr_test1w/); + expect(scriptAddress('cc'.repeat(28), true)).toMatch(/^addr1w/); + }); + + it('reads the store’s UTxO rows', () => { + expect(utxoOf({ tx_hash: 'ab', output_index: 2, owner_addr: addr, amounts: [{ unit: 'lovelace', quantity: 7 }], inline_datum: '01' })).toEqual({ tx_hash: 'ab', output_index: 2, address: addr, amount: [{ unit: 'lovelace', quantity: '7' }], data_hash: null, inline_datum: '01', reference_script_hash: null }); + expect(utxoOf(null)).toBeNull(); + }); +}); + +describe('labPlan: other withdrawals', () => { + it('adds them in ledger order, script credentials first', () => { + const stake = bech32Encode('stake_test', new Uint8Array([0xe0, ...Array(28).fill(0xaa)])); + const plan = labPlan(script, tx({ purpose: 'reward', withdrawals: [{ credential: stake, amount: '5' }, { credential: 'script:' + '11'.repeat(28), amount: '0' }] }), { params: null, slots: null }); + expect(plan.request!.transaction.withdrawals.map((w) => w.credential)).toEqual(['script:' + '11'.repeat(28), `script:${script.hash}`, stake]); + expect(() => labPlan(script, tx({ withdrawals: [{ credential: 'nope', amount: '0' }] }), { params: null, slots: null })).toThrow(/stake address/); + }); +}); diff --git a/applications/viewer/src/lib/julc/authoring/lab.ts b/applications/viewer/src/lib/julc/authoring/lab.ts new file mode 100644 index 00000000..9dd3198b --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/lab.ts @@ -0,0 +1,221 @@ +import { bech32Decode, bech32Encode } from '$lib/bech32'; +import { encodePlutusData, type PlutusData } from '$lib/cbor'; +import type { DataInput, MockTransaction, MockTxIn, PlutusVersion, Target, TransactionRequest } from '../types'; +import { value, type RunPlan, type StoreUtxo } from '../replay/build'; +import { slotToPosixMs, type SlotConfig } from '../replay/slots'; + +/* + * The validator lab: run a script that isn't (necessarily) on chain against real chain state. A UTxO from the store + * is spent as if it were locked at the script (or a policy mints, or a stake script withdraws), with the redeemer, + * datum, signers, validity range and outputs the user sets. The result is a RunPlan, so the re-run pipeline + * (context fixes), the debugger and fixture export work as for on-chain runs. + */ + +export type LabPurpose = 'spend' | 'mint' | 'reward'; + +export interface LabScript { + /** CBOR of the compiled script, before parameters are applied. */ + cbor: string; + language: PlutusVersion; + /** Script parameters (CBOR data), applied by julc in order. */ + params: string[]; + /** Hash with the parameters applied: the script's address, policy id or stake credential. */ + hash: string; +} + +export interface LabTx { + purpose: LabPurpose; + /** The UTxO the script spends (spend), or a UTxO that pays the fee (mint, reward). */ + utxo: StoreUtxo | null; + /** Spend: treat the UTxO as locked at the script, with `datum`. */ + atScript: boolean; + /** Spend: CBOR of the datum the script sees (inline for V2/V3). */ + datum: string | null; + redeemer: string; + referenceInputs: StoreUtxo[]; + outputs: { address: string; lovelace: string }[]; + /** Mint: token name (hex) → quantity, under the script's policy. */ + mint: { tokenName: string; quantity: string }[]; + /** Reward: amount withdrawn from the script's stake credential (usually 0, "withdraw zero"). */ + withdrawal: string; + /** Other withdrawals in the transaction: a stake address or "script:" / "key:", and lovelace. */ + withdrawals?: { credential: string; amount: string }[]; + signers: string[]; + validFrom: number | null; + validTo: number | null; + fee: string; +} + +export interface LabEnv { + params: { cost_models_raw?: Record | null; protocol_major_ver?: number | null; max_tx_ex_mem?: number | string | null; max_tx_ex_steps?: number | string | null } | null; + slots: SlotConfig | null; +} + +/** A placeholder UTxO for when the chain has none to offer (10 ada at a dummy key address). */ +export const SAMPLE_UTXO: StoreUtxo = { tx_hash: 'c0ffee'.padEnd(64, '0'), output_index: 0, address: '', amount: [{ unit: 'lovelace', quantity: '10000000' }] }; + +const hexRe = /^([0-9a-f]{2})*$/i; + +/** The payment key hash of a Shelley address with a key payment part, else null. */ +export function paymentKeyHash(address: string): string | null { + const d = bech32Decode(address); + if (!d || d.bytes.length < 29 || !/^addr/.test(d.hrp)) return null; + const type = d.bytes[0] >> 4; + // Header types 0, 2, 4, 6: key payment credential. + if (type > 7 || type % 2 !== 0) return null; + return [...d.bytes.slice(1, 29)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +/** A script parameter typed as text: hex for byte types, an integer for number types, else Plutus data (JSON or CBOR). */ +export function paramData(type: string | null | undefined, text: string, parseData: (t: string) => PlutusData): string { + const t = String(type ?? '').toLowerCase(); + const v = text.trim(); + if (/byte|bytestring|hash|policyid|tokenname/.test(t)) { + if (!hexRe.test(v)) throw new Error('expected hex'); + return encodePlutusData({ bytes: v.toLowerCase() }); + } + if (/^(biginteger|integer|int|long|posixtime)$/.test(t)) { + if (!/^-?\d+$/.test(v)) throw new Error('expected a whole number'); + return encodePlutusData({ int: String(BigInt(v)) }); + } + return encodePlutusData(parseData(v)); +} + +/** A placeholder value for a field type from julc's `check` (Integer, ByteString, records…). */ +function placeholder(type: string | null | undefined): PlutusData { + const t = String(type ?? '').toLowerCase(); + if (/^(integer|biginteger|int|long|posixtime|lovelace)$/.test(t)) return { int: '0' }; + if (/byte|hash|policyid|tokenname|txid/.test(t)) return { bytes: '' }; + if (t === 'boolean') return { constructor: 0, fields: [] }; + if (/list/.test(t)) return { list: [] }; + if (/map/.test(t)) return { map: [] }; + return { constructor: 0, fields: [] }; +} + +/** Constructor `tag` with a placeholder per field: a starting point for a datum or redeemer. */ +export function templateOf(tag: number, fields: { type: string | null }[] | null | undefined): string { + return encodePlutusData({ constructor: tag, fields: (fields ?? []).map((f) => placeholder(f.type)) }); +} + +/** julc's purpose names (SPENDING, MINTING, …) as the lab's. */ +export function labPurposeOf(purpose: string | null | undefined): LabPurpose | null { + const p = String(purpose ?? '').toUpperCase(); + if (p.startsWith('SPEND')) return 'spend'; + if (p.startsWith('MINT')) return 'mint'; + if (p.startsWith('WITHDRAW') || p.startsWith('REWARD')) return 'reward'; + return null; +} + +/** The enterprise address of a script hash (header 0x70 / 0x71). */ +export function scriptAddress(hash: string, mainnet: boolean): string { + const bytes = new Uint8Array([mainnet ? 0x71 : 0x70, ...hash.match(/../g)!.map((h) => parseInt(h, 16))]); + return bech32Encode(mainnet ? 'addr' : 'addr_test', bytes); +} + +/** Ledger order of credentials: script hashes before key hashes, then by hash. */ +function credentialKey(credential: string): string { + const m = credential.match(/^(script|key):([0-9a-f]{56})$/i); + if (m) return (m[1].toLowerCase() === 'script' ? '0' : '1') + m[2].toLowerCase(); + const d = bech32Decode(credential); + if (!d || d.bytes.length !== 29 || !/^stake/.test(d.hrp)) throw new Error(`“${credential}” isn’t a stake address or script:/key: hash.`); + return ((d.bytes[0] & 0x10) !== 0 ? '0' : '1') + [...d.bytes.slice(1)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} + +function withdrawalsOf(script: LabScript, tx: LabTx): { credential: string; amount: string }[] { + const list = (tx.withdrawals ?? []).filter((w) => w.credential.trim()).map((w) => ({ credential: w.credential.trim(), amount: w.amount.trim() || '0' })); + if (tx.purpose === 'reward') list.push({ credential: `script:${script.hash}`, amount: tx.withdrawal.trim() || '0' }); + for (const w of list) if (!/^\d+$/.test(w.amount)) throw new Error('Withdrawal amounts must be whole numbers of lovelace.'); + const keyed = list.map((w) => ({ w, k: credentialKey(w.credential) })); + return keyed.sort((a, b) => (a.k < b.k ? -1 : a.k > b.k ? 1 : 0)).map((x) => x.w); +} + +const cbor = (value: string): DataInput => ({ format: 'cbor', value }); +const byRef = (a: { txId: string; index: bigint }, b: { txId: string; index: bigint }) => (a.txId < b.txId ? -1 : a.txId > b.txId ? 1 : Number(a.index - b.index)); + +/** Build the run plan for the lab's transaction; throws with a readable message when an input is invalid. */ +export function labPlan(script: LabScript, tx: LabTx, env: LabEnv): RunPlan { + const notes: string[] = []; + if (!hexRe.test(tx.redeemer) || !tx.redeemer) throw new Error('The redeemer isn’t valid Plutus data.'); + const utxo = tx.utxo ?? SAMPLE_UTXO; + const spending = tx.purpose === 'spend'; + const own = spending && tx.atScript; + if (own && script.language !== 'V3' && !tx.datum) throw new Error(`A Plutus ${script.language} spending script needs a datum.`); + + const inputOf = (u: StoreUtxo, mine: boolean): MockTxIn => ({ + txId: u.tx_hash, + index: BigInt(u.output_index), + address: { payment: mine || !u.address ? (mine ? '$self' : 'key:' + '00'.repeat(28)) : u.address }, + value: value(u.amount), + datum: mine && tx.datum ? (script.language === 'V1' ? { kind: 'hash', hash: '', data: cbor(tx.datum) } : { kind: 'inline', data: cbor(tx.datum) }) : u.inline_datum ? { kind: 'inline', data: cbor(u.inline_datum) } : { kind: 'none' }, + referenceScript: u.reference_script_hash || null + }); + const inputs = [inputOf(utxo, own)].sort(byRef); + const refs = tx.referenceInputs.map((u) => inputOf(u, false)).sort(byRef); + if (!tx.utxo) notes.push('No UTxO was chosen, so a sample one (10 ada) is spent.'); + + const toPosix = (slot: number | null) => { + if (slot == null) return null; + if (!env.slots) throw new Error('Slot times for this network are unknown, so a validity range can’t be set.'); + return slotToPosixMs(slot, env.slots); + }; + + let purpose: MockTransaction['purpose']; + if (spending) purpose = { type: 'spend', index: inputs.findIndex((i) => i.txId === utxo.tx_hash && i.index === BigInt(utxo.output_index)) }; + else if (tx.purpose === 'mint') purpose = { type: 'mint', target: script.hash }; + else purpose = { type: 'reward', target: `script:${script.hash}` }; + + const mint = tx.purpose === 'mint' ? tx.mint.filter((m) => m.quantity.trim()) : []; + for (const m of mint) { + if (!hexRe.test(m.tokenName) || m.tokenName.length > 64) throw new Error(`Token name “${m.tokenName}” must be hex (up to 32 bytes).`); + if (!/^-?\d+$/.test(m.quantity.trim()) || BigInt(m.quantity.trim()) === 0n) throw new Error('Mint quantities must be non-zero whole numbers.'); + } + if (!/^\d+$/.test(tx.fee.trim())) throw new Error('The fee must be a whole number of lovelace.'); + for (const s of tx.signers) if (!/^[0-9a-f]{56}$/i.test(s)) throw new Error(`Signer “${s}” isn’t a 28-byte key hash.`); + for (const o of tx.outputs) if (!/^\d+$/.test(o.lovelace.trim())) throw new Error('Output amounts must be whole numbers of lovelace.'); + + const transaction: MockTransaction = { + purpose, + redeemer: cbor(tx.redeemer), + inputs, + referenceInputs: refs, + outputs: tx.outputs.map((o) => ({ address: { payment: o.address.trim() || 'key:' + '00'.repeat(28) }, value: { lovelace: o.lovelace.trim(), assets: [] }, datum: { kind: 'none' as const } })), + fee: tx.fee.trim(), + mint: mint.map((m) => ({ policyId: script.hash, tokenName: m.tokenName.toLowerCase(), quantity: m.quantity.trim() })).sort((a, b) => (a.tokenName < b.tokenName ? -1 : 1)), + certificates: [], + withdrawals: withdrawalsOf(script, tx), + validRange: { from: toPosix(tx.validFrom), fromInclusive: true, to: toPosix(tx.validTo), toInclusive: false }, + signatories: [...new Set(tx.signers.map((s) => s.toLowerCase()))].sort(), + datums: [], + txId: '7ab0'.padEnd(64, '0'), + votes: [], + proposals: [] + }; + + const pv = env.params?.protocol_major_ver ?? null; + const protocol = pv == null ? 11 : Math.min(11, Math.max(10, pv)); + const target: Target = { language: `Plutus${script.language}`, protocol }; + const costs = env.params?.cost_models_raw?.[`Plutus${script.language}`]; + const request: TransactionRequest = { + script: { script: script.cbor, language: script.language, ...(script.params.length ? { params: script.params.map(cbor) } : {}) }, + transaction, + target, + costModel: costs?.length ? { target, parameters: costs.map((c) => BigInt(c)) } : null, + ...(env.params?.max_tx_ex_steps ? { maxCpu: BigInt(env.params.max_tx_ex_steps) } : {}), + ...(env.params?.max_tx_ex_mem ? { maxMem: BigInt(env.params.max_tx_ex_mem) } : {}) + }; + return { + key: `${tx.purpose}#${purpose.index ?? 0}`, + purpose: tx.purpose, + index: purpose.index ?? 0, + scriptHash: script.hash, + language: script.language, + declared: null, + redeemer: tx.redeemer, + datum: own ? tx.datum : null, + hashedDatums: [], + witnessDatums: [], + proposals: [], + request, + notes + }; +} diff --git a/applications/viewer/src/lib/julc/authoring/source-debug.ts b/applications/viewer/src/lib/julc/authoring/source-debug.ts new file mode 100644 index 00000000..7f6a5771 --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/source-debug.ts @@ -0,0 +1,31 @@ +import { julc } from '../engine.svelte'; +import type { RunPlan } from '../replay/build'; + +type Session = { + ok: boolean; + sessionId?: string; + timeline?: { errorStep?: bigint | null } | null; + goto(step: bigint): Promise<{ snapshot?: { javaLocation?: { line: number; fragment: string | null } | null } | null }>; + close(): Promise; +}; + +/** + * Where a Java validator failed: julc's source-debug build maps the failing step back to a Java line. Null when the + * run doesn't fail there or julc can't map it (the debug build is experimental in 0.1.0-pre18). + */ +export async function javaFailureLine(source: string, params: string[], plan: RunPlan): Promise<{ line: number; fragment: string | null } | null> { + const req = plan.request!; + const sd = await julc.run(async (c) => { + const open = (c as unknown as { sourceDebug?: { open(r: unknown): Promise } }).sourceDebug?.open; + return open ? (c as unknown as { sourceDebug: { open(r: unknown): Promise } }).sourceDebug.open({ source, params: params.map((value) => ({ format: 'cbor', value })), transaction: req.transaction, target: req.target, costModel: req.costModel, maxCpu: req.maxCpu, maxMem: req.maxMem, locals: false }) : null; + }); + if (!sd?.ok || !sd.sessionId) return null; + try { + const at = sd.timeline?.errorStep; + if (at == null) return null; + const loc = (await sd.goto(BigInt(at))).snapshot?.javaLocation; + return loc?.line ? { line: loc.line, fragment: loc.fragment } : null; + } finally { + sd.close().catch(() => {}); + } +} diff --git a/applications/viewer/src/lib/julc/authoring/utxo.ts b/applications/viewer/src/lib/julc/authoring/utxo.ts new file mode 100644 index 00000000..ac6ee815 --- /dev/null +++ b/applications/viewer/src/lib/julc/authoring/utxo.ts @@ -0,0 +1,44 @@ +import { tryApi } from '$lib/api/client'; +import type { StoreUtxo } from '../replay/build'; + +/* Finding chain state for the validator lab: one UTxO by reference, or the unspent outputs at an address. */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/** A /utxos/{tx}/{index} or /addresses/{a}/utxos row as the replay builder's UTxO. */ +export function utxoOf(u: Any): StoreUtxo | null { + if (!u?.tx_hash) return null; + return { + tx_hash: u.tx_hash, + output_index: Number(u.output_index), + address: u.owner_addr ?? u.address ?? '', + amount: (u.amounts ?? u.amount ?? []).map((a: Any) => ({ unit: a.unit, quantity: String(a.quantity) })), + data_hash: u.data_hash ?? null, + inline_datum: u.inline_datum ?? null, + reference_script_hash: u.reference_script_hash ?? null + }; +} + +export const parseRef = (text: string) => text.trim().match(/^([0-9a-f]{64})[#_:](\d+)$/i); + +/** Look up "#" (spent or not) or an address's unspent outputs (newest first, up to 20). */ +export async function findUtxos(query: string): Promise<{ utxos: StoreUtxo[]; error?: string }> { + const q = query.trim(); + const ref = parseRef(q); + if (ref) { + const u = utxoOf(await tryApi(`/utxos/${ref[1].toLowerCase()}/${ref[2]}`)); + return u ? { utxos: [u] } : { utxos: [], error: 'The store has no such output.' }; + } + if (/^addr(_test)?1[0-9a-z]+$/.test(q)) { + const rows = await tryApi(`/addresses/${q}/utxos`, { params: { count: 20, order: 'desc' } }); + const utxos = (rows ?? []).map(utxoOf).filter((u): u is StoreUtxo => !!u); + return utxos.length ? { utxos } : { utxos: [], error: 'No unspent outputs at this address.' }; + } + return { utxos: [], error: 'Enter # or an address (addr…).' }; +} + +/** The CBOR of a datum given by hash, if the store has it. */ +export async function datumByHash(hash: string): Promise { + return (await tryApi<{ cbor?: string }>(`/scripts/datum/${hash}/cbor`))?.cbor ?? null; +} diff --git a/applications/viewer/src/lib/julc/blueprint/BlueprintPanel.svelte b/applications/viewer/src/lib/julc/blueprint/BlueprintPanel.svelte new file mode 100644 index 00000000..cf914e3c --- /dev/null +++ b/applications/viewer/src/lib/julc/blueprint/BlueprintPanel.svelte @@ -0,0 +1,73 @@ + + + + {#snippet icon()}{/snippet} + {#snippet actions()} + attach(e.currentTarget.files?.[0])} /> + + {/snippet} + {#if matched.length} +
    + {#each matched as m (m.blueprint.title + m.validator.title)} +
  • + + {m.validator.title} + {#if m.validator.purpose}{m.validator.purpose}{/if} + {#if m.validator.redeemer}redeemer {typeNameOf(m.validator.redeemer, m.blueprint.definitions)}{/if} + {#if m.validator.datum}datum {typeNameOf(m.validator.datum, m.blueprint.definitions)}{/if} + {#if m.validator.parameters.length}{m.validator.parameters.length} parameter{m.validator.parameters.length === 1 ? '' : 's'}{/if} +
  • + {/each} +
+

Redeemers and datums of this script show a Typed view on transaction pages, and its activity names redeemer variants.

+ {:else} +

No attached blueprint describes this script. Attach the plutus.json your contract build produces; it stays in this browser.

+ {/if} + {#if error}

{error}

{/if} + {#if added}

{added}

{/if} + {#if blueprints.list.length} +
+
Attached in this browser
+
+ {#each blueprints.list as b (b.title)} + {b.title} · {b.validators.length} validator{b.validators.length === 1 ? '' : 's'} + + {/each} +
+
+ {/if} +
diff --git a/applications/viewer/src/lib/julc/blueprint/TypedTree.svelte b/applications/viewer/src/lib/julc/blueprint/TypedTree.svelte new file mode 100644 index 00000000..7f48889b --- /dev/null +++ b/applications/viewer/src/lib/julc/blueprint/TypedTree.svelte @@ -0,0 +1,34 @@ + + +
+
+ {#if node.field}{node.field}:{/if} + {#if node.kind === 'constructor'} + {#if node.type && node.type !== node.variant}{node.type}.{/if}{node.variant} + {:else if node.kind === 'int'} + {node.value} + {:else if node.kind === 'bytes'} + #{node.value}{#if text}“{text}”{/if} + {:else if node.kind === 'list'} + {node.type ? `${node.type} ` : ''}[{node.children?.length ?? 0}] + {:else if node.kind === 'map'} + {node.type ? `${node.type} ` : ''}{'{'}{node.children?.length ?? 0}{'}'} + {/if} + {#if node.type && node.kind !== 'constructor' && node.kind !== 'list' && node.kind !== 'map'}{node.type}{/if} + {#if node.mismatch}not as in blueprint{/if} +
+ {#if node.children?.length} +
+ {#each node.children as child}{/each} +
+ {/if} +
diff --git a/applications/viewer/src/lib/julc/blueprint/blueprint.test.ts b/applications/viewer/src/lib/julc/blueprint/blueprint.test.ts new file mode 100644 index 00000000..7c5a83d3 --- /dev/null +++ b/applications/viewer/src/lib/julc/blueprint/blueprint.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { decodePlutusData } from '$lib/cbor'; +import { findValidator, parseBlueprint, scriptHashOf, typeData, typeNameOf, variantOf } from './blueprint'; + +// An Aiken 1.x style blueprint: one entry per handler, sharing a hash; schemas referencing definitions. +const json = { + preamble: { title: 'acme/dex', version: '0.1.0', plutusVersion: 'v3' }, + validators: [ + { + title: 'pool.pool.spend', + hash: 'aa'.repeat(28), + datum: { schema: { $ref: '#/definitions/pool~1PoolDatum' } }, + redeemer: { schema: { $ref: '#/definitions/pool~1Action' } } + }, + { title: 'pool.pool.mint', hash: 'aa'.repeat(28), redeemer: { schema: { $ref: '#/definitions/Int' } } }, + { title: 'pool.pool.else', hash: 'aa'.repeat(28), redeemer: { schema: {} } } + ], + definitions: { + Int: { dataType: 'integer' }, + ByteArray: { dataType: 'bytes' }, + 'pool/PoolDatum': { + title: 'PoolDatum', + anyOf: [{ title: 'PoolDatum', dataType: 'constructor', index: 0, fields: [{ title: 'owner', $ref: '#/definitions/ByteArray' }, { title: 'reserves', $ref: '#/definitions/List$Int' }] }] + }, + 'List$Int': { dataType: 'list', items: { $ref: '#/definitions/Int' } }, + 'pool/Action': { + title: 'Action', + anyOf: [ + { title: 'Swap', dataType: 'constructor', index: 0, fields: [{ title: 'min_out', $ref: '#/definitions/Int' }] }, + { title: 'Close', dataType: 'constructor', index: 1, fields: [] } + ] + } + } +}; + +describe('blueprints', () => { + it('parses validators with purposes and finds them by hash and purpose', () => { + const bp = parseBlueprint(json); + expect(bp.validators.map((v) => v.purpose)).toEqual(['spend', 'mint', 'else']); + expect(findValidator([bp], 'AA'.repeat(28), 'mint')?.validator.title).toBe('pool.pool.mint'); + expect(findValidator([bp], 'aa'.repeat(28), 'reward')?.validator.title).toBe('pool.pool.spend'); + expect(findValidator([bp], 'bb'.repeat(28))).toBeNull(); + expect(() => parseBlueprint({ hello: 1 })).toThrow(/validators/); + }); + + it('computes a script hash from compiled code like the ledger', () => { + const cbor = readFileSync(new URL('./fixture-c3e28c.cbor.txt', import.meta.url), 'utf8').trim(); + expect(scriptHashOf(cbor, 'v2')).toBe('c3e28c36c3447315ba5a56f33da6a6ddc1770a876a8d9f0cb3a97c4c'); + const bp = parseBlueprint({ preamble: { plutusVersion: 'v2' }, validators: [{ title: 'x.spend', compiledCode: cbor }] }); + expect(bp.validators[0].hash).toBe('c3e28c36c3447315ba5a56f33da6a6ddc1770a876a8d9f0cb3a97c4c'); + }); + + it('names types, variants and fields', () => { + const bp = parseBlueprint(json); + const v = bp.validators[0]; + // PoolDatum { owner: #cafe, reserves: [1, 2] } + const datum = typeData(decodePlutusData('d8799f42cafe9f0102ffff'), v.datum, bp.definitions); + expect(datum).toMatchObject({ type: 'PoolDatum', variant: 'PoolDatum', kind: 'constructor' }); + expect(datum.children!.map((c) => [c.field, c.kind])).toEqual([ + ['owner', 'bytes'], + ['reserves', 'list'] + ]); + expect(datum.children![1].children!.map((c) => c.value)).toEqual(['1', '2']); + + const swap = decodePlutusData('d8799f182aff'); // Constr 0 [42] + expect(variantOf(swap, v.redeemer, bp.definitions)).toBe('Action.Swap'); + expect(typeData(swap, v.redeemer, bp.definitions).children![0]).toMatchObject({ field: 'min_out', value: '42' }); + expect(variantOf(decodePlutusData('d87a80'), v.redeemer, bp.definitions)).toBe('Action.Close'); + }); + + it('names schema types and does not repeat a field name as its type', () => { + const bp = parseBlueprint(json); + expect(typeNameOf(bp.validators[0].redeemer, bp.definitions)).toBe('Action'); + expect(typeNameOf({ $ref: '#/definitions/Int' }, bp.definitions)).toBe('Int'); + const t = typeData(decodePlutusData('d8799f182aff'), { title: 'T', dataType: 'constructor', index: 0, fields: [{ title: 'opaque' }] }, {}); + expect(t.children![0]).toMatchObject({ field: 'opaque', kind: 'int', value: '42' }); + expect(t.children![0].type).toBeUndefined(); + }); + + it('marks data that does not fit the schema', () => { + const bp = parseBlueprint(json); + const t = typeData(decodePlutusData('d87b80'), bp.validators[0].redeemer, bp.definitions); // Constr 2: no such variant + expect(t.mismatch).toBe(true); + expect(variantOf(decodePlutusData('d87b80'), bp.validators[0].redeemer, bp.definitions)).toBeNull(); + }); +}); diff --git a/applications/viewer/src/lib/julc/blueprint/blueprint.ts b/applications/viewer/src/lib/julc/blueprint/blueprint.ts new file mode 100644 index 00000000..37bd5487 --- /dev/null +++ b/applications/viewer/src/lib/julc/blueprint/blueprint.ts @@ -0,0 +1,173 @@ +import type { PlutusData } from '$lib/cbor'; +import { blake2b } from '$lib/governance/blake2b'; + +/* + * CIP-57 blueprints (plutus.json): find a script's datum and redeemer schemas and use them to show Plutus data with + * type, variant and field names. Aiken 1.x lists one validator per handler (title "module.name.spend", ".mint", + * ".withdraw", …) sharing one hash, so a schema is looked up by script hash and purpose. + */ + +export type Schema = Record; + +export interface BlueprintValidator { + title: string; + hash: string; + /** spend, mint, reward, cert, vote, propose; null when the title doesn't say. */ + purpose: string | null; + datum: Schema | null; + redeemer: Schema | null; + parameters: { title: string; schema: Schema }[]; +} + +export interface Blueprint { + title: string; + version: string | null; + plutusVersion: string | null; + validators: BlueprintValidator[]; + definitions: Record; +} + +const HANDLERS: Record = { spend: 'spend', mint: 'mint', withdraw: 'reward', publish: 'cert', vote: 'vote', propose: 'propose', else: 'else' }; + +const hexToBytes = (hex: string) => new Uint8Array((hex.match(/../g) ?? []).map((h) => parseInt(h, 16))); +const toHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); + +/** Script hash of a blueprint's compiledCode (CBOR-wrapped flat): BLAKE2b-224 of the language tag and the script. */ +export function scriptHashOf(compiledCode: string, plutusVersion: string | null): string { + const tag = { v1: 1, v2: 2, v3: 3 }[String(plutusVersion ?? 'v3').toLowerCase()] ?? 3; + return toHex(blake2b(new Uint8Array([tag, ...hexToBytes(compiledCode)]), 28)); +} + +export function parseBlueprint(json: unknown): Blueprint { + const b = json as { preamble?: Record; validators?: Record[]; definitions?: Record }; + if (!b || typeof b !== 'object' || !Array.isArray(b.validators)) throw new Error('Not a CIP-57 blueprint: there is no "validators" list.'); + const plutusVersion = b.preamble?.plutusVersion ?? null; + const validators = b.validators.map((v) => { + const title = String(v.title ?? 'validator'); + const handler = title.split('.').at(-1) ?? ''; + const hash = typeof v.hash === 'string' ? v.hash : typeof v.compiledCode === 'string' ? scriptHashOf(v.compiledCode, plutusVersion) : ''; + const schemaOf = (x: unknown) => ((x as { schema?: Schema })?.schema ?? null) as Schema | null; + return { + title, + hash: hash.toLowerCase(), + purpose: HANDLERS[handler] ?? null, + datum: schemaOf(v.datum), + redeemer: schemaOf(v.redeemer), + parameters: (Array.isArray(v.parameters) ? v.parameters : []).map((p: { title?: string; schema?: Schema }) => ({ title: p.title ?? '', schema: p.schema ?? {} })) + }; + }); + if (!validators.some((v) => v.hash)) throw new Error('The blueprint has no validator hashes or compiled code.'); + return { title: b.preamble?.title ?? 'Blueprint', version: b.preamble?.version ?? null, plutusVersion, validators, definitions: b.definitions ?? {} }; +} + +/** The validator entry for a script hash and purpose (falling back to any entry for that hash). */ +export function findValidator(blueprints: Blueprint[], hash: string, purpose?: string | null): { blueprint: Blueprint; validator: BlueprintValidator } | null { + for (const blueprint of blueprints) { + const matches = blueprint.validators.filter((v) => v.hash === hash.toLowerCase()); + if (!matches.length) continue; + const validator = matches.find((v) => v.purpose === purpose) ?? matches.find((v) => v.purpose !== 'else') ?? matches[0]; + return { blueprint, validator }; + } + return null; +} + +// --- typing data with a schema --- + +export interface TypedNode { + /** Field title, when the schema names the field. */ + field?: string; + /** Type (definition) title, e.g. "Action" or "VerificationKeyHash". */ + type?: string; + /** Constructor title, e.g. "Swap". */ + variant?: string; + kind: 'constructor' | 'int' | 'bytes' | 'list' | 'map' | 'data'; + value?: string; + children?: TypedNode[]; + /** The data doesn't fit the schema here; shown untyped. */ + mismatch?: boolean; +} + +function resolve(schema: Schema, defs: Record, depth = 0): Schema { + const ref = schema.$ref; + if (typeof ref !== 'string' || depth > 20) return schema; + const key = ref.replace(/^#\/definitions\//, '').replace(/~1/g, '/').replace(/~0/g, '~'); + const target = defs[key]; + return target ? { ...resolve(target, defs, depth + 1), title: target.title ?? key.split('/').at(-1) } : schema; +} + +const isConstr = (d: PlutusData): d is { constructor: number; fields: PlutusData[] } => typeof (d as { constructor: unknown }).constructor === 'number'; + +function untyped(d: PlutusData, field?: string, mismatch = false): TypedNode { + if (isConstr(d)) return { field, kind: 'constructor', variant: `Constr ${d.constructor}`, children: d.fields.map((x) => untyped(x)), mismatch }; + if ('int' in d) return { field, kind: 'int', value: d.int, mismatch }; + if ('bytes' in d) return { field, kind: 'bytes', value: d.bytes, mismatch }; + if ('list' in d) return { field, kind: 'list', children: d.list.map((x) => untyped(x)), mismatch }; + return { field, kind: 'map', children: d.map.map((e) => ({ kind: 'data' as const, children: [untyped(e.k, 'key'), untyped(e.v, 'value')] })), mismatch }; +} + +export function typeData(d: PlutusData, raw: Schema | null | undefined, defs: Record, field?: string): TypedNode { + if (!raw) return untyped(d, field); + const schema = resolve(raw, defs); + // An inline field schema's title is the field's name, not a type. + const type = typeof schema.title === 'string' && schema.title !== field ? schema.title : undefined; + const anyOf = schema.anyOf as Schema[] | undefined; + if (Array.isArray(anyOf)) { + if (!isConstr(d)) { + // e.g. {"anyOf": [{"dataType": "integer"}, {"dataType": "bytes"}]} + const kind = 'int' in d ? 'integer' : 'bytes' in d ? 'bytes' : 'list' in d ? 'list' : 'map'; + const alt = anyOf.map((a) => resolve(a, defs)).find((a) => a.dataType === kind); + return alt ? { ...typeData(d, alt, defs, field), type } : untyped(d, field, true); + } + const alt = anyOf.map((a) => resolve(a, defs)).find((a) => a.index === d.constructor || (a.dataType === 'constructor' && a.index === d.constructor)); + if (!alt) return untyped(d, field, true); + return { ...typeData(d, alt, defs, field), type }; + } + switch (schema.dataType) { + case 'constructor': { + if (!isConstr(d) || (typeof schema.index === 'number' && schema.index !== d.constructor)) return untyped(d, field, true); + const fields = (schema.fields as Schema[] | undefined) ?? []; + return { + field, + type, + variant: typeof schema.title === 'string' ? schema.title : `Constr ${d.constructor}`, + kind: 'constructor', + children: d.fields.map((x, i) => typeData(x, fields[i], defs, typeof fields[i]?.title === 'string' ? (fields[i].title as string) : undefined)), + mismatch: fields.length !== d.fields.length || undefined + }; + } + case 'integer': + return 'int' in d ? { field, type, kind: 'int', value: d.int } : untyped(d, field, true); + case 'bytes': + return 'bytes' in d ? { field, type, kind: 'bytes', value: d.bytes } : untyped(d, field, true); + case 'list': { + if (!('list' in d)) return untyped(d, field, true); + const items = schema.items; + return { field, type, kind: 'list', children: d.list.map((x, i) => typeData(x, Array.isArray(items) ? (items[i] as Schema) : (items as Schema), defs)) }; + } + case 'map': { + if (!('map' in d)) return untyped(d, field, true); + return { field, type, kind: 'map', children: d.map.map((e) => ({ kind: 'data' as const, children: [typeData(e.k, schema.keys as Schema, defs, 'key'), typeData(e.v, schema.values as Schema, defs, 'value')] })) }; + } + default: + // Opaque "Data", or a schema shape this viewer doesn't know: show the data as is. + return { ...untyped(d, field), type }; + } +} + +/** A schema's type name: the definition's title (or name) for a $ref, else its own title or data type. */ +export function typeNameOf(s: Schema | null | undefined, defs: Record): string | null { + if (!s) return null; + if (typeof s.$ref === 'string') { + const key = s.$ref.replace(/^#\/definitions\//, '').replace(/~1/g, '/').replace(/~0/g, '~'); + return (defs[key]?.title as string | undefined) ?? key.split('/').at(-1) ?? key; + } + return (s.title as string | undefined) ?? (s.dataType as string | undefined) ?? 'Data'; +} + +/** The redeemer variant name (e.g. "Action.Swap") for a script's redeemer, if a blueprint describes it. */ +export function variantOf(d: PlutusData, schema: Schema | null, defs: Record): string | null { + if (!schema) return null; + const t = typeData(d, schema, defs); + if (t.mismatch || t.kind !== 'constructor' || !t.variant || t.variant.startsWith('Constr ')) return null; + return t.type && t.type !== t.variant ? `${t.type}.${t.variant}` : t.variant; +} diff --git a/applications/viewer/src/lib/julc/blueprint/fixture-c3e28c.cbor.txt b/applications/viewer/src/lib/julc/blueprint/fixture-c3e28c.cbor.txt new file mode 100644 index 00000000..122ede56 --- /dev/null +++ b/applications/viewer/src/lib/julc/blueprint/fixture-c3e28c.cbor.txt @@ -0,0 +1 @@ +590a600100003332323232323232323222222533300832323232533300c3370e900118058008991919299980799b87480000084cc004dd5980a180a980a980a980a980a980a98068030060a99980799b87480080084c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc080cdc3a4000002264646600200200e44a66604c00229404c8c94ccc094cdc78010028a51133004004001302a002375c60500026eb8c094c07800854ccc080cdc3a40040022646464646600200202844a66605000229404c8c94ccc09ccdd798161812981618129816181698128010028a51133004004001302c002302a0013374a9001198131ba90014bd701bae3026001301e002153330203370e900200089980900419ba548000cc090cdd2a400466048604a603c00497ae04bd70099981019b87375a6044604a66446464a66604866e1d200200114bd6f7b63009bab302930220023022001323300100100322533302700114c103d87a800013232323253330283371e00e004266e9520003302c374c00297ae0133006006003375660520066eb8c09c008c0ac008c0a4004c8cc004004030894ccc09400452f5bded8c0264646464a66604c66e3d22100002100313302a337606ea4008dd3000998030030019bab3027003375c604a0046052004604e0026eb8c094c07800920004a0944c078004c08c004c06c060c8c8c8c8c8c8c94ccc08ccdc3a40000022646464646464646464646464646464646464a6660706076004264646464646464649319299981e99b87480000044c8c94ccc108c1140084c92632375a60840046eb4c10000458c8cdd81822000982218228009bac3043001303b0091533303d3370e90010008a999820181d8048a4c2c2c607601064a66607866e1d2000001132323232323232325333047304a002132498c09401458cdc3a400460886ea8c120004c120008dd6982300098230011822000982200119b8748008c0f8dd51821000981d0060a99981e19b87480080044c8c8c8c8c8c94ccc114c1200084c926302300316375a608c002608c0046088002608800466e1d2002303e3754608400260740182a66607866e1d2004001132323232323232325333047304a002132498c09401458dd6982400098240011bad30460013046002304400130440023370e9001181f1baa3042001303a00c1533303c3370e9003000899191919191919192999823982500109924c604a00a2c66e1d200230443754609000260900046eb4c118004c118008c110004c110008cdc3a4004607c6ea8c108004c0e803054ccc0f0cdc3a40100022646464646464a66608a60900042649319299982199b87480000044c8c8c8c94ccc128c13400852616375a609600260960046eb4c124004c10401854ccc10ccdc3a4004002264646464a666094609a0042930b1bad304b001304b002375a6092002608200c2c608200a2c66e1d200230423754608c002608c0046eb4c110004c110008c108004c0e803054ccc0f0cdc3a401400226464646464646464a66608e60940042649318130038b19b8748008c110dd5182400098240011bad30460013046002375a60880026088004608400260740182a66607866e1d200c001132323232323232325333047304a002132498c09801458cdc3a400460886ea8c120004c120008dd6982300098230011822000982200119b8748008c0f8dd51821000981d0060a99981e19b87480380044c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc134c14000852616375a609c002609c0046eb4c130004c130008dd6982500098250011bad30480013048002375a608c002608c0046eb4c110004c110008cdc3a4004607c6ea8c108004c0e803054ccc0f0cdc3a4020002264646464646464646464a66609260980042649318140048b19b8748008c118dd5182500098250011bad30480013048002375a608c002608c0046eb4c110004c110008c108004c0e803054ccc0f0cdc3a40240022646464646464a66608a60900042646493181200219198008008031129998238008a4c2646600600660960046464a66608c66e1d2000001132323232533304d3050002132498c0b400c58cdc3a400460946ea8c138004c138008c130004c11000858c110004c12400458dd698230009823001182200098220011bac3042001303a00c1533303c3370e900a0008a99981f981d0060a4c2c2c6074016603a018603001a603001c602c01e602c02064a66606c66e1d200000113232533303b303e002149858dd7181e000981a0090a99981b19b87480080044c8c94ccc0ecc0f800852616375c607800260680242a66606c66e1d200400113232533303b303e002149858dd7181e000981a0090a99981b19b87480180044c8c94ccc0ecc0f800852616375c607800260680242c60680222c607200260720046eb4c0dc004c0dc008c0d4004c0d4008c0cc004c0cc008c0c4004c0c4008c0bc004c0bc008c0b4004c0b4008c0ac004c0ac008c0a4004c08407858c0840748c94ccc08ccdc3a40000022a66604c60420042930b0a99981199b87480080044c8c94ccc0a0c0ac00852616375c605200260420042a66604666e1d2004001132325333028302b002149858dd7181480098108010b1810800919299981119b87480000044c8c8c8c94ccc0a4c0b00084c8c9263253330283370e9000000899192999816981800109924c64a66605666e1d20000011323253330303033002132498c04400458c0c4004c0a400854ccc0accdc3a40040022646464646464a666068606e0042930b1bad30350013035002375a606600260660046eb4c0c4004c0a400858c0a400458c0b8004c09800c54ccc0a0cdc3a40040022a666056604c0062930b0b181300118050018b18150009815001181400098100010b1810000919299981099b87480000044c8c94ccc098c0a400852616375a604e002603e0042a66604266e1d20020011323253330263029002149858dd69813800980f8010b180f800919299981019b87480000044c8c94ccc094c0a000852616375a604c002603c0042a66604066e1d20020011323253330253028002149858dd69813000980f0010b180f000919299980f99b87480000044c8c8c8c94ccc098c0a400852616375c604e002604e0046eb8c094004c07400858c0740048c94ccc078cdc3a400000226464a666046604c0042930b1bae3024001301c0021533301e3370e900100089919299981198130010a4c2c6eb8c090004c07000858c070004dd618100009810000980f8011bab301d001301d001301c00237566034002603400260320026030002602e0046eb0c054004c0340184cc004dd5980a180a980a980a980a980a980a980680300591191980080080191299980a8008a50132323253330153375e00c00229444cc014014008c054008c064008c05c004c03001cc94ccc034cdc3a40000022a666020601600e2930b0a99980699b874800800454ccc040c02c01c526161533300d3370e90020008a99980818058038a4c2c2c601600c2c60200026020004601c002600c00229309b2b118029baa001230033754002ae6955ceaab9e5573eae815d0aba24c126d8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affff004c0126d8799fd87a9f581cc8b0cc61374d409ff9c8512317003e7196a3e4d48553398c656cc124ffff0001 diff --git a/applications/viewer/src/lib/julc/blueprint/store.svelte.ts b/applications/viewer/src/lib/julc/blueprint/store.svelte.ts new file mode 100644 index 00000000..6eaaa940 --- /dev/null +++ b/applications/viewer/src/lib/julc/blueprint/store.svelte.ts @@ -0,0 +1,55 @@ +import { browser } from '$app/environment'; +import { findValidator, parseBlueprint, type Blueprint } from './blueprint'; + +/* + * Blueprints the user attached, kept in this browser (localStorage). Datums and redeemers of matching scripts are + * shown with type, variant and field names everywhere in the viewer. + */ +const KEY = 'viewer:blueprints'; + +class Blueprints { + list = $state.raw([]); + + constructor() { + if (!browser) return; + try { + const saved = JSON.parse(localStorage.getItem(KEY) ?? '[]'); + if (Array.isArray(saved)) this.list = saved; + } catch { + /* nothing saved, or storage is blocked */ + } + } + + #save() { + try { + localStorage.setItem(KEY, JSON.stringify(this.list)); + } catch { + /* storage full or blocked: kept for this page only */ + } + } + + /** Parse and keep a blueprint (replacing one with the same title). Throws with a readable message if invalid. */ + add(text: string): Blueprint { + let json: unknown; + try { + json = JSON.parse(text); + } catch { + throw new Error('That file is not JSON.'); + } + const bp = parseBlueprint(json); + this.list = [...this.list.filter((b) => b.title !== bp.title), bp]; + this.#save(); + return bp; + } + + remove(title: string) { + this.list = this.list.filter((b) => b.title !== title); + this.#save(); + } + + find(hash: string, purpose?: string | null) { + return findValidator(this.list, hash, purpose); + } +} + +export const blueprints = new Blueprints(); diff --git a/applications/viewer/src/lib/julc/debug/Debugger.svelte b/applications/viewer/src/lib/julc/debug/Debugger.svelte new file mode 100644 index 00000000..9991cb4c --- /dev/null +++ b/applications/viewer/src/lib/julc/debug/Debugger.svelte @@ -0,0 +1,382 @@ + + + + +{#if phase === 'checking' || phase === 'preparing'} + +
+ + {phase === 'checking' ? 'Checking the julc engine…' : julc.status === 'loading' ? 'Starting the julc engine…' : 'Rebuilding the transaction and replaying the run…'} +
+ +
+{:else if phase === 'error'} +
+ +
+

Can’t debug this run

+

{failure}

+ {#if page.hash}Back to the transaction’s scripts{/if} +
+
+{:else} + +
+
+ + + + + + + + {#if errorStep != null} + + {/if} + Step {num(step)} / {num(last)} + {#if busy}{/if} + {#if keepUrl}{/if} +
+
+ + {#if failure}

{failure}

{/if} +
+ +
+
+ + +
+ + + {#if lineBreaks.size} + Lines {[...lineBreaks].sort((a, b) => a - b).join(', ')} + {/if} +
+ {#if builtins.length} +
Break on builtin
+
+ {#each builtins as b} + + {/each} +
+ {/if} +
+
+ +
+ {#if run?.plan.source}{/if} + {#if run?.plan.notes.length} +
    + {#each run.plan.notes as note}
  • {note}
  • {/each} +
+ {/if} + + {#if snap} +
+
Phase
{snap.phase ?? '—'}
+
Term
{snap.termKind ?? '—'}
+
Stopped by
{snap.stopReason ?? '—'}
+
Stack depth
{snap.stackDepth}
+
+ {#if snap.value} +
Value
+
{snap.value}
+ {/if} + {#if snap.finished} +
+ {#if snap.status === 'success'}{:else}{/if} + {snap.status === 'success' ? 'The script finished successfully.' : snap.error || 'The script failed.'} +
+ {/if} + {/if} +
+ + + {#if snap && timeline} + {#each [{ label: 'CPU', used: snap.cpu, delta: snap.cpuDelta, end: timeline.cpu, want: declared?.steps }, { label: 'Memory', used: snap.mem, delta: snap.memDelta, end: timeline.mem, want: declared?.mem }] as b (b.label)} +
+
+ {b.label} + {num(b.used)} / {num(b.end)} this run{b.want ? ` · ${num(b.want)} declared` : ''} +
+
+
+{num(b.delta)} this step
+
+ {/each} + {/if} +
+ + + {#if snap?.environment?.length} +
    + {#each snap.environment as v} +
  • {v.name} = {v.value}
  • + {/each} +
+ {/if} +
+ + + {#if snap?.frames?.length} +
    + {#each snap.frames as f, i} +
  1. + {i} + {f.kind} + {f.detail} + {#if f.span}· line {f.span.startLine}{/if} +
  2. + {/each} +
+ {/if} +
+ + + {#if snap?.traces?.length} +
    + {#each snap.traces as t, i}
  1. {i + 1}{t}
  2. {/each} +
+ {/if} +
+ +
+
+{/if} diff --git a/applications/viewer/src/lib/julc/debug/Timeline.svelte b/applications/viewer/src/lib/julc/debug/Timeline.svelte new file mode 100644 index 00000000..106ea179 --- /dev/null +++ b/applications/viewer/src/lib/julc/debug/Timeline.svelte @@ -0,0 +1,72 @@ + + +
+
(dragging = false)} + onpointerleave={() => (hover = null)} + > +
+ {#each traceMarks as t}{/each} + {#if error != null}{/if} + + + + {#if hover != null && !dragging} + {num(hover)} + {/if} +
+
+ 0 + + {#if traces.length}{traces.length} trace{traces.length === 1 ? '' : 's'}{/if} + {#if error != null}failure at {num(error)}{/if} + + {num(Math.max(0, total - 1))} +
+
diff --git a/applications/viewer/src/lib/julc/engine.svelte.ts b/applications/viewer/src/lib/julc/engine.svelte.ts new file mode 100644 index 00000000..41f88f14 --- /dev/null +++ b/applications/viewer/src/lib/julc/engine.svelte.ts @@ -0,0 +1,142 @@ +import { browser } from '$app/environment'; +import { env } from '$env/dynamic/public'; +import type { JulcClient, JulcError, Manifest } from './types'; + +/* + * The julc engine (Script Lab), loaded on first use and shared by every page. It runs in its own worker, so a long + * evaluation never blocks the page. Nothing is downloaded until a feature asks for the client; installs without the + * engine (JULC_SKIP) report 'absent' and the features hide themselves. + */ + +export type EngineStatus = 'idle' | 'checking' | 'absent' | 'unsupported' | 'loading' | 'ready' | 'error'; + +const AUTO_KEY = 'viewer:julc:auto'; + +/* + * julc is a GraalVM Web Image: it needs WasmGC and the final Wasm exception handling (exnref, try_table). Both are + * checked with tiny modules before anything is downloaded. Older browsers (e.g. Chrome before 2025) lack exnref. + */ +const WASM_GC = [0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 95, 1, 120, 0]; +const WASM_EXNREF = [0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 10, 8, 1, 6, 0, 31, 64, 0, 11, 11]; + +export function wasmSupported(): boolean { + try { + return typeof WebAssembly === 'object' && WebAssembly.validate(new Uint8Array(WASM_GC)) && WebAssembly.validate(new Uint8Array(WASM_EXNREF)); + } catch { + return false; + } +} + +export function engineBaseUrl(): URL { + const configured = env.PUBLIC_JULC_BASE_URL; + const raw = (configured || '/julc/').replace(/\/?$/, '/'); + return new URL(raw, browser ? location.href : 'http://localhost/'); +} + +/** Workers must be same-origin; for an engine on another host, start a tiny worker that imports julc's. */ +function workerFactory(url: URL): Worker { + if (url.origin === location.origin) return new Worker(url); + const shim = new Blob([`importScripts(${JSON.stringify(url.href)});`], { type: 'text/javascript' }); + return new Worker(URL.createObjectURL(shim)); +} + +class JulcEngine { + status = $state('idle'); + error = $state(null); + manifest = $state(null); + /** How long the last engine start took, in ms. */ + loadMs = $state(null); + + #manifest: Promise | null = null; + #client: Promise | null = null; + + /** Is an engine installed with this viewer? Reads engine.json (a few hundred bytes). */ + available(): Promise { + if (!browser) return Promise.resolve(null); + this.#manifest ??= (async () => { + if (this.status === 'idle') this.status = 'checking'; + try { + const res = await fetch(new URL('engine.json', engineBaseUrl()), { cache: 'no-cache' }); + const manifest = res.ok ? ((await res.json()) as Manifest) : null; + this.manifest = manifest; + if (this.status === 'checking') this.status = manifest ? 'idle' : 'absent'; + return manifest; + } catch { + if (this.status === 'checking') this.status = 'absent'; + return null; + } + })(); + return this.#manifest; + } + + /** The engine client, started on first call. A failed start can be retried. */ + client(): Promise { + this.#client ??= this.#start().catch((e) => { + this.#client = null; + throw e; + }); + return this.#client; + } + + /** Run a call against the engine. When the worker dies (a timeout disposes it), the next call starts a new one. */ + async run(fn: (client: JulcClient) => Promise): Promise { + const client = await this.client(); + try { + return await fn(client); + } catch (e) { + const code = (e as JulcError)?.code; + if (code === 'timeout' || /closed|worker/i.test((e as Error)?.message ?? '')) { + this.#client = null; + this.status = 'idle'; + } + throw e; + } + } + + get auto(): boolean { + try { + return browser && localStorage.getItem(AUTO_KEY) === '1'; + } catch { + return false; + } + } + + set auto(on: boolean) { + try { + if (on) localStorage.setItem(AUTO_KEY, '1'); + else localStorage.removeItem(AUTO_KEY); + } catch { + /* storage blocked: the choice lasts for this page only */ + } + } + + async #start(): Promise { + if (!browser) throw new Error('The julc engine runs in the browser only.'); + const manifest = await this.available(); + if (!manifest) { + this.status = 'absent'; + throw new Error('Script Lab is not installed with this viewer.'); + } + if (!wasmSupported()) { + this.status = 'unsupported'; + throw new Error('The julc engine needs WebAssembly GC and exception handling, which this browser does not support. Use a current Chrome, Edge, Firefox or Safari.'); + } + this.status = 'loading'; + this.error = null; + const started = performance.now(); + try { + const baseUrl = engineBaseUrl(); + const mod = await import(/* @vite-ignore */ new URL(`julc-wasm.js?engine=${encodeURIComponent(manifest.version)}`, baseUrl).href); + const client = (await mod.createJulc({ baseUrl, manifest, workerFactory, timeoutMs: 60_000 })) as JulcClient; + this.loadMs = Math.round(performance.now() - started); + this.status = 'ready'; + return client; + } catch (e) { + this.status = 'error'; + this.error = (e as Error)?.message || 'The julc engine failed to start.'; + throw e; + } + } +} + +export const julc = new JulcEngine(); diff --git a/applications/viewer/src/lib/julc/fork/DataEditor.svelte b/applications/viewer/src/lib/julc/fork/DataEditor.svelte new file mode 100644 index 00000000..c71d0814 --- /dev/null +++ b/applications/viewer/src/lib/julc/fork/DataEditor.svelte @@ -0,0 +1,119 @@ + + +
+
+ {label} + {#if edited}edited{/if} +
+ {#if edited} + + {/if} +
+ {#each [['json', 'JSON'], ['cbor', 'CBOR']] as [id, name] (id)} + + {/each} +
+
+
+ + {#if error}

{error}

{/if} +
diff --git a/applications/viewer/src/lib/julc/fork/ForkLab.svelte b/applications/viewer/src/lib/julc/fork/ForkLab.svelte new file mode 100644 index 00000000..821dec91 --- /dev/null +++ b/applications/viewer/src/lib/julc/fork/ForkLab.svelte @@ -0,0 +1,452 @@ + + +{#if phase === 'loading'} + +
{julc.status === 'loading' ? 'Starting the julc engine…' : 'Rebuilding the transaction and running its scripts…'}
+ +
+{:else if phase === 'error'} +
+ +

Can’t fork this transaction

{failure}

+
+{:else} +
+ +
+ +
+ {#each plans.filter((p) => p.redeemer) as p (p.key)} + setIn('redeemers', p.key, cbor)} + /> + {/each} +
+
+ + {#if spendInputs.length} + +
+ {#each spendInputs as s (s.ref)} + setIn('datums', s.ref, cbor)} /> + {/each} +
+
+ {/if} + + +
Required signers
+
+ {#each signers as s (s)} +
+ {s} + +
+ {:else} +

No required signers.

+ {/each} +
+ e.key === 'Enter' && addSigner()} /> + +
+
+
+ {#each [{ key: 'validFrom' as const, label: 'Valid from (slot)', value: validFrom }, { key: 'validTo' as const, label: 'Valid until (slot, TTL)', value: validTo }] as b (b.key)} +
+
+ {b.label} + {#if edits[b.key] !== undefined}edited{/if} +
+ setBound(b.key, e.currentTarget.value.trim(), e.currentTarget.value.trim() === '')} /> +
{b.value != null ? slotTime(b.value) : 'No bound'}
+
+ {/each} +
+
+ + +
+ {#each tx.outputs as o (o.output_index)} + {@const e = edits.outputs?.[o.output_index]} +
+
+ #{o.output_index} + {#if e && !e.removed}edited{/if} + +
+ {#if !e?.removed} + outputEdit(o.output_index, { address: ev.currentTarget.value.trim() === o.address ? undefined : ev.currentTarget.value.trim() })} /> +
+ {#each o.amount as a (a.unit)} + {@const onChain = String(a.quantity)} + + {/each} +
+ {/if} +
+ {/each} +
+
+ + +
Mint (negative burns, 0 removes)
+
+ {#each [...new Set([...page.mints.map((m) => m.unit), ...Object.keys(edits.mints ?? {})])] as unit (unit)} + {@const onChain = String(page.mints.find((m) => m.unit === unit)?.quantity ?? '0')} + + {/each} +
+ + + +
+
+ {#if page.withdrawals.length} +
Withdrawals (lovelace)
+ {#each page.withdrawals as w (w.address)} + + {/each} + {/if} +
Fee (lovelace)
+ /^\d+$/.test(ev.currentTarget.value.trim()) && (edits.fee = ev.currentTarget.value.trim() === String(tx.fees) ? undefined : ev.currentTarget.value.trim())} /> +
+ + +
+
+
Cost model from epoch
+
+ e.key === 'Enter' && applyEpoch()} /> + +
+
{epochError ?? (otherParams ? `Using epoch ${otherParams.epoch}; the transaction ran in epoch ${page.epoch}.` : `The transaction ran in epoch ${page.epoch ?? '—'}.`)}
+ {#if epochNote && otherParams}
{epochNote}
{/if} +
+
+
Protocol version
+ +
+
+
+
+ + +
+
+
+ +
+

Fork vs on chain

+

{editCount ? `${editCount} change${editCount === 1 ? '' : 's'}` : 'No changes yet: edit anything on the left'}{changedOutcomes ? ` · ${changedOutcomes} outcome${changedOutcomes === 1 ? '' : 's'} changed` : ''}

+
+ + {#if running}{/if} + {#if editCount}{/if} + +
+ {#if plans[0]?.source}
{/if} +
+ + {#each plans as p (p.key)} + {@const o = original[p.key]} + {@const f = forked[p.key]} + {@const flipped = outcome(o) !== outcome(f)} +
+
+ {p.purpose} #{p.index} + Plutus {p.language ?? '?'} · + {#if flipped} + {passed(f) ? 'Now passes' : outcome(f) === 'couldn’t run' ? 'Can’t run now' : 'Now fails'} + {/if} +
+ {#if !p.request} +

{p.skip}

+ {:else} +
+ CPUMemory + {#each [{ name: 'On chain', r: o }, { name: 'Fork', r: f }] as row (row.name)} + + {#if passed(row.r)}{:else if row.r?.state === 'done'}{:else}{/if} + {row.name} + + {row.r?.evaluation ? num(row.r.evaluation.cpu) : '—'} + {row.r?.evaluation ? num(row.r.evaluation.mem) : '—'} + {row.r?.ms != null ? `${row.r.ms} ms` : ''} + {/each} + {#if o?.evaluation && f?.evaluation} + {@const dc = delta(o.evaluation.cpu, f.evaluation.cpu)} + {@const dm = delta(o.evaluation.mem, f.evaluation.mem)} + Change + + 0 ? 'text-amber' : 'text-mint'}>{dc ? `${dc > 0 ? '+' : ''}${dc}%` : '='} + 0 ? 'text-amber' : 'text-mint'}>{dm ? `${dm > 0 ? '+' : ''}${dm}%` : '='} + + {/if} +
+ {#if f && !passed(f)} +

{f.error ?? f.evaluation?.message ?? f.evaluation?.error ?? ''}

+ {#if f.evaluation?.traces?.length}

Last trace: {f.evaluation.traces.at(-1)}

{/if} + {/if} +
+ {#if f?.args}{/if} + {#if f?.args}{/if} + On-chain debugger +
+ {/if} +
+ {/each} +
+
+ + {#if debugging && forked[debugging]?.args} +
+
+ +

Debugging the fork · {debugging.replace('#', ' #')}

+ {editCount ? 'with your changes' : 'unchanged'} · reopens after each edit + +
+ {#key `${generation}/${debugging}`} + {@const r = forked[debugging]} + + {/key} +
+ {/if} +{/if} diff --git a/applications/viewer/src/lib/julc/governance/GuardrailLab.svelte b/applications/viewer/src/lib/julc/governance/GuardrailLab.svelte new file mode 100644 index 00000000..f2b1c884 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/GuardrailLab.svelte @@ -0,0 +1,391 @@ + + +
+ +
+ + {#snippet actions()} +
+ {#each [['param', 'Parameter change'], ['treasury', 'Treasury withdrawal']] as [id, name] (id)} + + {/each} +
+ {/snippet} + + {#if recent.length} + + {/if} + + {#if draft.kind === 'param'} + {#if changed.length || draft.costModels} +
+ + + + {#each changed as def (def.name)} + {@const h = hint(def, draft.values[def.name] ?? '')} + {@const v = each[def.name]} + + + + + + + {/each} + {#if draft.costModels} + + + + + + + {/if} + +
ParameterNowProposed
+
{def.label}
+
{def.name.replace(/^.*\./, '')} · key {def.key}
+
{showCurrent(def)} + (draft.values[def.name] = e.currentTarget.value)} aria-label="New value for {def.label}" spellcheck="false" /> + {#if h}
{h.text}
{/if} +
+ {#if v === 'ok'}allowed{:else if v === 'rejected'}rejected{:else if v === 'invalid'}invalid{/if} + +
Cost models
key 18
—Plutus {Object.keys(draft.costModels).map((l) => `V${Number(l) + 1}`).join(', ')} from the proposal + {#if each.costModels === 'ok'}allowed{:else if each.costModels === 'rejected'}rejected{/if} + +
+
+ {:else} +

Add the parameters the proposal changes. Each starts at its current value.

+ {/if} +
+ +
+

Fractions like 3/10 or decimals for ratios; lovelace for amounts. Prices, execution limits and voting thresholds are set as a whole by the ledger, so values you don't change keep their current value.

+ {:else} +
+ {#each draft.withdrawals as w, i (i)} + {@const amount = toInt(w.amount)} +
+ (draft.withdrawals[i].address = e.currentTarget.value)} aria-label="Withdrawal {i + 1} stake address" spellcheck="false" /> +
+ (draft.withdrawals[i].amount = e.currentTarget.value)} aria-label="Withdrawal {i + 1} amount in lovelace" /> + {#if amount != null}
= {ada(amount, 'auto')} ada
{/if} +
+ +
+ {/each} + +
+ {/if} +
+ + + {#snippet icon()}{/snippet} + {#if script} +
+ {shorten(script.hash, 12, 8)} + {#if script.hash === constitutionScript}the constitution’s guardrail{:else}custom script{/if} + {#if script.hash !== defaultScript?.hash && defaultScript?.cbor} + + {/if} +
+ {:else} +

{scriptNote ?? 'This chain’s constitution has no guardrail script, so proposals aren’t checked on chain.'} Use a script on this chain or paste one to try it.

+ {/if} +
+ Check against another script +
+ + + + {#if customError}

{customError}

{/if} +
+
+
+
+ + +
+ {#if phase === 'error'} +
+ +

Can’t run the guardrail

{failure}

+
+ {:else} + + {#snippet actions()}{#if running || phase === 'starting'}{/if}{/snippet} + {#if !script} +

Choose a guardrail script to check against.

+ {:else if isEmpty(draft)} +

{draft.kind === 'param' ? 'Add a parameter to check.' : 'Add a withdrawal to check.'}

+ {:else if !result} +

{julc.status === 'loading' ? 'Starting the julc engine…' : 'Checking…'}

+ {:else if result.state !== 'done'} +
+ +

Can’t check this proposal yet

{result.error}

+
+ {:else} + {#key generation} +
+ {#if accepted}{:else}{/if} +
+

{accepted ? 'The guardrail accepts this proposal' : verdict === 'out of budget' ? 'The guardrail ran out of budget' : 'The guardrail rejects this proposal'}

+

+ {#if accepted}It could be submitted with this guardrail as its policy.{:else if Object.values(each).includes('rejected')}Not allowed: {changed.filter((d) => each[d.name] === 'rejected').map((d) => d.label).concat(each.costModels === 'rejected' ? ['Cost models'] : []).join(', ')}.{:else if Object.keys(each).length}Each change passes on its own, but not together.{:else}The ledger would refuse the transaction that proposes it.{/if} +

+
+
+ {/key} +
+
CPU
{num(result.evaluation!.cpu)}
{share(Number(result.evaluation!.cpu), limits.cpu)} of a tx
+
Memory
{num(result.evaluation!.mem)}
{share(Number(result.evaluation!.mem), limits.mem)} of a tx
+
+ {#if result.evaluation?.traces?.length} +
Traces
+
    {#each result.evaluation.traces as t, i (i)}
  • {t}
  • {/each}
+ {/if} +
+ {#if result.args}{/if} + +
+ {/if} +
+

+ The guardrail sees the proposal as the ledger would show it to the script; the deposit is the current {params?.gov_action_deposit ? `${ada(String(params.gov_action_deposit), 0)} ada` : 'one'}, and the rest of the + transaction is a placeholder. Run with this epoch’s cost model{params?.protocol_major_ver ? ` and protocol ${Math.min(11, Math.max(10, Number(params.protocol_major_ver)))} rules` : ''}. +

+ {/if} +
+
+ +{#if debugging && result?.args} +
+
+

Guardrail run

+ +
+ {#key generation} + + {/key} +
+{/if} diff --git a/applications/viewer/src/lib/julc/governance/GuardrailPanel.svelte b/applications/viewer/src/lib/julc/governance/GuardrailPanel.svelte new file mode 100644 index 00000000..24dbc892 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/GuardrailPanel.svelte @@ -0,0 +1,134 @@ + + +{#if isParam || proposal.type === 'TREASURY_WITHDRAWALS_ACTION'} + + {#snippet icon()}{/snippet} + {#snippet actions()} + Try changes + {#if policy && canRun && phase !== 'running'}{/if} + {/snippet} + + {#if isParam} +
+ + + + {#each rows as def (def.name)} + + + + + + + {/each} + {#if change.costModels} + + + + + + + {/if} + +
ParameterNowProposed
{def.label} {def.name.replace(/^.*\./, '')}{show(def, params?.[def.current])}{show(def, change.values[def.name])}{#if each[def.name] === 'ok'}allowed{:else if each[def.name] === 'rejected'}rejected{/if}
Cost models—Plutus {Object.keys(change.costModels).map((l) => `V${Number(l) + 1}`).join(', ')}{#if each.costModels === 'ok'}allowed{:else if each.costModels === 'rejected'}rejected{/if}
+
+ {/if} + +
+ {#if !policy} +

The constitution had no guardrail script, so nothing checked this proposal’s values. “Try changes” checks them against any guardrail script.

+ {:else if phase === 'running'} +

{julc.status === 'loading' ? 'Starting the julc engine…' : 'Running the guardrail…'}

+ {:else if phase === 'error'} +

{failure}

+ {:else if phase === 'done' && result} +
+ {#if accepted}{:else}{/if} + {accepted ? 'The guardrail accepts it' : 'The guardrail rejects it'} + · {num(result.evaluation!.cpu)} CPU · {num(result.evaluation!.mem)} memory · in your browser +
+

Re-run with today’s cost model. The proposal transaction’s own run (with its declared budget) is on its Scripts tab.

+ {:else if canRun} +

Run the guardrail script on this proposal in your browser, to see which values it checks.

+ {:else} +

This browser or viewer can’t run scripts (Script Lab).

+ {/if} +
+
+{/if} diff --git a/applications/viewer/src/lib/julc/governance/action.test.ts b/applications/viewer/src/lib/julc/governance/action.test.ts new file mode 100644 index 00000000..3c957b47 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/action.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { bech32Encode } from '$lib/bech32'; +import type { PlutusData } from '$lib/cbor'; +import { ParamError, credentialOf, encodeAction, encodeParamChange, encodeProcedure, paramChangeOf, toRational } from './action'; + +const i = (n: number | bigint): PlutusData => ({ int: String(n) }); +const b = (hex: string): PlutusData => ({ bytes: hex }); +const c = (constructor: number, ...fields: PlutusData[]): PlutusData => ({ constructor, fields }); +const list = (...items: PlutusData[]): PlutusData => ({ list: items }); +const keys = (d: PlutusData) => (d as { map: { k: { int: string } }[] }).map.map((e) => Number(e.k.int)); +const valueAt = (d: PlutusData, key: number) => (d as { map: { k: { int: string }; v: PlutusData }[] }).map.find((e) => Number(e.k.int) === key)?.v; +const rat = (n: number, d: number) => list(i(n), i(d)); + +describe('toRational', () => { + it('reads fractions, decimals, exponents and the store’s {numerator, denominator}', () => { + expect(toRational('3/10')).toEqual({ n: 3n, d: 10n }); + expect(toRational('0.0577')).toEqual({ n: 577n, d: 10000n }); + expect(toRational('7.21e-05')).toEqual({ n: 721n, d: 10000000n }); + expect(toRational(7.21e-5)).toEqual({ n: 721n, d: 10000000n }); + expect(toRational('0.30')).toEqual({ n: 3n, d: 10n }); + expect(toRational('1e3')).toEqual({ n: 1000n, d: 1n }); + expect(toRational({ numerator: 1, denominator: 10 })).toEqual({ n: 1n, d: 10n }); + expect(toRational('abc')).toBeNull(); + expect(toRational('1/0')).toBeNull(); + }); +}); + +describe('encodeParamChange', () => { + it('encodes a Mainnet proposal’s update in ledger key order (execution limits as [mem, steps])', () => { + // gov_action14dr5yg75pchr2sz42djtuflpvx5qnsek29qg7s7cft8lzrqt5vrqqtqntpk + const change = paramChangeOf({ maxTxExMem: 17500000, minPoolCost: 75000000, maxTxExSteps: 10000000000, maxBlockExMem: 77500000, maxBlockExSteps: 20000000000 }); + const d = encodeParamChange(change); + expect(keys(d)).toEqual([16, 20, 21]); + expect(valueAt(d, 16)).toEqual(i(75000000)); + expect(valueAt(d, 20)).toEqual(list(i(17500000), i(10000000000))); + expect(valueAt(d, 21)).toEqual(list(i(77500000), i(20000000000))); + }); + + it('fills the unchanged parts of a composite value from the current parameters', () => { + const current = { price_mem: 0.0577, price_step: 7.21e-5, dvt_motion_no_confidence: 0.67, dvt_committee_normal: 0.67, dvt_committee_no_confidence: 0.6, dvt_update_to_constitution: 0.75, dvt_hard_fork_initiation: 0.6, dvt_p_p_network_group: 0.67, dvt_p_p_economic_group: 0.67, dvt_p_p_technical_group: 0.67, dvt_p_p_gov_group: 0.75, dvt_treasury_withdrawal: 0.67 }; + const prices = encodeParamChange({ values: { priceMem: '0.06' } }, current); + expect(valueAt(prices, 19)).toEqual(list(rat(3, 50), rat(721, 10000000))); + const thresholds = encodeParamChange({ values: { 'drepVotingThresholds.dvtPPGovGroup': '4/5' } }, current); + const t = valueAt(thresholds, 26) as { list: PlutusData[] }; + expect(t.list).toHaveLength(10); + expect(t.list[8]).toEqual(rat(4, 5)); + expect(t.list[0]).toEqual(rat(67, 100)); + expect(() => encodeParamChange({ values: { priceMem: '0.06' } }, null)).toThrow(ParamError); + }); + + it('rejects values that aren’t numbers', () => { + expect(() => encodeParamChange({ values: { minPoolCost: '1.5' } })).toThrow(/whole number/); + expect(() => encodeParamChange({ values: { treasuryGrowthRate: 'x' } })).toThrow(/fraction/); + }); + + it('reads the store’s names case-insensitively, thresholds and cost models', () => { + const change = paramChangeOf({ nopt: 500, treasuryGrowthRate: { numerator: 1, denominator: 10 }, poolVotingThresholds: { pvtPPSecurityGroup: { numerator: 51, denominator: 100 } }, costModels: { '2': '9f0102ff' }, costModelsHash: 'ab' }); + expect(change.values).toEqual({ nOpt: '500', treasuryGrowthRate: '1/10', 'poolVotingThresholds.pvtPPSecurityGroup': '51/100' }); + const d = encodeParamChange({ values: { nOpt: '500' }, costModels: change.costModels }); + expect(keys(d)).toEqual([8, 18]); + expect(valueAt(d, 18)).toEqual({ map: [{ k: i(2), v: list(i(1), i(2)) }] }); + }); +}); + +describe('governance actions', () => { + const key = 'aa'.repeat(28); + const script = '11'.repeat(28); + it('reads credentials from stake addresses and reward accounts', () => { + expect(credentialOf('e1' + key)).toEqual({ script: false, hash: key }); + expect(credentialOf('f1' + script)).toEqual({ script: true, hash: script }); + expect(credentialOf(bech32Encode('stake', new Uint8Array([0xe1, ...Array(28).fill(0xaa)])))).toEqual({ script: false, hash: key }); + expect(credentialOf('nope')).toBeNull(); + }); + + it('orders treasury withdrawals as the ledger does: script credentials first', () => { + const d = encodeAction('TREASURY_WITHDRAWALS_ACTION', { policyHash: 'fa'.repeat(28), withdrawals: { ['e1' + key]: 5, ['f1' + script]: 7 } }); + expect(d).toEqual(c(2, { map: [{ k: c(1, b(script)), v: i(7) }, { k: c(0, b(key)), v: i(5) }] }, c(0, b('fa'.repeat(28))))); + }); + + it('encodes a parameter change with its previous action and policy, inside a procedure', () => { + const action = encodeAction('PARAMETER_CHANGE_ACTION', { policyHash: 'fa'.repeat(28), govActionId: { transactionId: 'cc'.repeat(32), gov_action_index: 0 }, protocolParamUpdate: { committeeMinSize: 5 } }); + expect(action).toEqual(c(0, c(0, c(0, b('cc'.repeat(32)), i(0))), { map: [{ k: i(27), v: i(5) }] }, c(0, b('fa'.repeat(28))))); + expect(encodeProcedure(100000000000, 'e0' + key, action)).toEqual(c(0, i(100000000000), c(0, b(key)), action)); + expect(encodeAction('INFO_ACTION', {})).toEqual(c(6)); + expect(encodeAction('HARD_FORK_INITIATION_ACTION', { protocolVersion: { _1: 11, _2: 0 } })).toEqual(c(1, c(1), c(0, i(11), i(0)))); + }); +}); diff --git a/applications/viewer/src/lib/julc/governance/action.ts b/applications/viewer/src/lib/julc/governance/action.ts new file mode 100644 index 00000000..d9ad8a80 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/action.ts @@ -0,0 +1,297 @@ +import { bech32Decode } from '$lib/bech32'; +import { decodePlutusData, type PlutusData } from '$lib/cbor'; + +/* + * Governance actions as Plutus V3 sees them: the ProposalProcedure in a proposing script's ScriptInfo and in + * txInfoProposalProcedures. julc 0.1.0-pre18 builds parameter changes with an empty change set (and other actions + * only roughly), so the viewer encodes the real action from Yaci Store data and puts it into the context + * (fix-context.ts). + * + * Encodings follow cardano-ledger's ToPlutusData: + * ProposalProcedure Constr 0 [deposit, return credential, action] + * ParameterChange Constr 0 [Maybe prevId, Map key value, Maybe policy] + * HardForkInitiation Constr 1 [Maybe prevId, Constr 0 [major, minor]] + * TreasuryWithdrawals Constr 2 [Map credential amount, Maybe policy] + * NoConfidence 3, UpdateCommittee 4, NewConstitution 5, InfoAction 6 + * In a parameter change, rationals are [numerator, denominator] lists, ex-units [mem, steps], prices + * [mem, steps] of rationals, cost models a map from language (0, 1, 2) to the list of costs, and voting + * thresholds lists of rationals in ledger order. + */ + +export type Rational = { n: bigint; d: bigint }; + +export type ParamKind = 'int' | 'rational'; + +export interface ParamDef { + /** Field name in Yaci Store's protocolParamUpdate (yaci core). Thresholds use "group.field". */ + name: string; + /** Key in the ledger's PParamsUpdate. */ + key: number; + /** Position within a composite value (prices, ex-units, thresholds). */ + slot?: number; + kind: ParamKind; + label: string; + /** Field of /epochs/{n}/parameters with the current value. */ + current: string; + group: 'Network' | 'Economic' | 'Technical' | 'Governance'; + unit?: 'lovelace' | 'bytes' | 'epochs'; +} + +const P = (name: string, key: number, kind: ParamKind, label: string, current: string, group: ParamDef['group'], extra: Partial = {}): ParamDef => ({ name, key, kind, label, current, group, ...extra }); + +const POOL_THRESHOLDS = ['pvtMotionNoConfidence', 'pvtCommitteeNormal', 'pvtCommitteeNoConfidence', 'pvtHardForkInitiation', 'pvtPPSecurityGroup']; +const DREP_THRESHOLDS = ['dvtMotionNoConfidence', 'dvtCommitteeNormal', 'dvtCommitteeNoConfidence', 'dvtUpdateToConstitution', 'dvtHardForkInitiation', 'dvtPPNetworkGroup', 'dvtPPEconomicGroup', 'dvtPPTechnicalGroup', 'dvtPPGovGroup', 'dvtTreasuryWithdrawal']; +const POOL_CURRENT = ['pvt_motion_no_confidence', 'pvt_committee_normal', 'pvt_committee_no_confidence', 'pvt_hard_fork_initiation', 'pvt_p_p_security_group']; +const DREP_CURRENT = ['dvt_motion_no_confidence', 'dvt_committee_normal', 'dvt_committee_no_confidence', 'dvt_update_to_constitution', 'dvt_hard_fork_initiation', 'dvt_p_p_network_group', 'dvt_p_p_economic_group', 'dvt_p_p_technical_group', 'dvt_p_p_gov_group', 'dvt_treasury_withdrawal']; +const POOL_LABELS = ['no confidence', 'update committee', 'committee in no confidence', 'hard fork', 'security parameters']; +const DREP_LABELS = ['no confidence', 'update committee', 'committee in no confidence', 'new constitution', 'hard fork', 'network parameters', 'economic parameters', 'technical parameters', 'governance parameters', 'treasury withdrawal']; + +/** The parameters a parameter-change proposal can set (cost models aside), in ledger key order. */ +export const PARAMS: ParamDef[] = [ + P('minFeeA', 0, 'int', 'Fee per byte', 'min_fee_a', 'Economic'), + P('minFeeB', 1, 'int', 'Base fee', 'min_fee_b', 'Economic', { unit: 'lovelace' }), + P('maxBlockSize', 2, 'int', 'Max block body size', 'max_block_size', 'Network', { unit: 'bytes' }), + P('maxTxSize', 3, 'int', 'Max transaction size', 'max_tx_size', 'Network', { unit: 'bytes' }), + P('maxBlockHeaderSize', 4, 'int', 'Max block header size', 'max_block_header_size', 'Network', { unit: 'bytes' }), + P('keyDeposit', 5, 'int', 'Stake key deposit', 'key_deposit', 'Economic', { unit: 'lovelace' }), + P('poolDeposit', 6, 'int', 'Pool deposit', 'pool_deposit', 'Economic', { unit: 'lovelace' }), + P('maxEpoch', 7, 'int', 'Max pool retirement', 'e_max', 'Technical', { unit: 'epochs' }), + P('nOpt', 8, 'int', 'Target number of pools (k)', 'n_opt', 'Technical'), + P('poolPledgeInfluence', 9, 'rational', 'Pledge influence (a0)', 'a0', 'Technical'), + P('expansionRate', 10, 'rational', 'Monetary expansion (ρ)', 'rho', 'Economic'), + P('treasuryGrowthRate', 11, 'rational', 'Treasury cut (τ)', 'tau', 'Economic'), + P('minPoolCost', 16, 'int', 'Min pool cost', 'min_pool_cost', 'Economic', { unit: 'lovelace' }), + P('adaPerUtxoByte', 17, 'int', 'Lovelace per UTxO byte', 'coins_per_utxo_size', 'Economic'), + P('priceMem', 19, 'rational', 'Price per memory unit', 'price_mem', 'Economic', { slot: 0 }), + P('priceStep', 19, 'rational', 'Price per CPU step', 'price_step', 'Economic', { slot: 1 }), + P('maxTxExMem', 20, 'int', 'Max memory per transaction', 'max_tx_ex_mem', 'Network', { slot: 0 }), + P('maxTxExSteps', 20, 'int', 'Max CPU steps per transaction', 'max_tx_ex_steps', 'Network', { slot: 1 }), + P('maxBlockExMem', 21, 'int', 'Max memory per block', 'max_block_ex_mem', 'Network', { slot: 0 }), + P('maxBlockExSteps', 21, 'int', 'Max CPU steps per block', 'max_block_ex_steps', 'Network', { slot: 1 }), + P('maxValSize', 22, 'int', 'Max value size', 'max_val_size', 'Network', { unit: 'bytes' }), + P('collateralPercent', 23, 'int', 'Collateral (% of fee)', 'collateral_percent', 'Technical'), + P('maxCollateralInputs', 24, 'int', 'Max collateral inputs', 'max_collateral_inputs', 'Network'), + ...POOL_THRESHOLDS.map((f, i) => P(`poolVotingThresholds.${f}`, 25, 'rational', `SPO threshold: ${POOL_LABELS[i]}`, POOL_CURRENT[i], 'Governance', { slot: i })), + ...DREP_THRESHOLDS.map((f, i) => P(`drepVotingThresholds.${f}`, 26, 'rational', `DRep threshold: ${DREP_LABELS[i]}`, DREP_CURRENT[i], 'Governance', { slot: i })), + P('committeeMinSize', 27, 'int', 'Committee min size', 'committee_min_size', 'Governance'), + P('committeeMaxTermLength', 28, 'int', 'Committee max term', 'committee_max_term_length', 'Governance', { unit: 'epochs' }), + P('govActionLifetime', 29, 'int', 'Governance action lifetime', 'gov_action_lifetime', 'Governance', { unit: 'epochs' }), + P('govActionDeposit', 30, 'int', 'Governance action deposit', 'gov_action_deposit', 'Governance', { unit: 'lovelace' }), + P('drepDeposit', 31, 'int', 'DRep deposit', 'drep_deposit', 'Governance', { unit: 'lovelace' }), + P('drepActivity', 32, 'int', 'DRep activity period', 'drep_activity', 'Governance', { unit: 'epochs' }), + P('minFeeRefScriptCostPerByte', 33, 'rational', 'Reference script fee per byte', 'min_fee_ref_script_cost_per_byte', 'Economic') +]; + +export const paramByName: Record = Object.fromEntries(PARAMS.map((p) => [p.name, p])); +/** Store field names are matched ignoring case (Jackson writes nOpt as "nopt"). */ +const byLowerName: Record = Object.fromEntries(PARAMS.map((p) => [p.name.toLowerCase(), p])); + +/** Parse "12", "3/4", "0.0577", "7.21e-05" or {numerator, denominator}; null when it isn't a number. */ +export function toRational(v: unknown): Rational | null { + if (v && typeof v === 'object' && 'numerator' in v && 'denominator' in v) { + try { + const r = v as { numerator: number | string; denominator: number | string }; + const d = BigInt(r.denominator); + return d > 0n ? { n: BigInt(r.numerator), d } : null; + } catch { + return null; + } + } + const s = String(v ?? '').trim(); + const frac = s.match(/^(-?\d+)\s*\/\s*(\d+)$/); + if (frac) return BigInt(frac[2]) > 0n ? { n: BigInt(frac[1]), d: BigInt(frac[2]) } : null; + const dec = s.match(/^(-?)(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i); + if (!dec || (!dec[2] && !dec[3])) return null; + let digits = (dec[2] || '') + (dec[3] || ''); + let scale = (dec[3] || '').length - Number(dec[4] || 0); + if (scale < 0) { + digits += '0'.repeat(-scale); + scale = 0; + } + const n = BigInt((dec[1] || '') + (digits.replace(/^0+(?=\d)/, '') || '0')); + return reduce({ n, d: 10n ** BigInt(scale) }); +} + +function gcd(a: bigint, b: bigint): bigint { + a = a < 0n ? -a : a; + while (b) [a, b] = [b, a % b]; + return a || 1n; +} +const reduce = (r: Rational): Rational => { + const g = gcd(r.n, r.d); + return { n: r.n / g, d: r.d / g }; +}; + +export const formatRational = (r: Rational) => (r.d === 1n ? String(r.n) : `${r.n}/${r.d}`); + +/** An integer from a form or the store; null when it isn't one. */ +export function toInt(v: unknown): bigint | null { + const s = String(v ?? '').trim().replace(/[_,\s]/g, ''); + return /^-?\d+$/.test(s) ? BigInt(s) : null; +} + +/** A parameter change as the viewer edits it: values by parameter name (strings), plus raw cost models. */ +export interface ParamChange { + values: Record; + /** Language (0 = V1, 1 = V2, 2 = V3) → CBOR list of costs, as Yaci Store gives them. */ + costModels?: Record; +} + +/** Yaci Store's protocolParamUpdate → the editable form. */ +export function paramChangeOf(update: Record | null | undefined): ParamChange { + const values: Record = {}; + const costModels: Record = {}; + for (const [k, v] of Object.entries(update ?? {})) { + if (v == null) continue; + if (k === 'costModels' && typeof v === 'object') { + for (const [lang, hex] of Object.entries(v as Record)) costModels[lang] = String(hex); + } else if ((k === 'poolVotingThresholds' || k === 'drepVotingThresholds') && typeof v === 'object') { + for (const [f, r] of Object.entries(v as Record)) { + const rat = toRational(r); + const def = byLowerName[`${k}.${f}`.toLowerCase()]; + if (rat && def) values[def.name] = formatRational(rat); + } + } else if (byLowerName[k.toLowerCase()]) { + const def = byLowerName[k.toLowerCase()]; + const parsed = def.kind === 'rational' ? toRational(v) : toInt(v); + if (parsed != null) values[def.name] = typeof parsed === 'bigint' ? String(parsed) : formatRational(parsed); + } + } + return Object.keys(costModels).length ? { values, costModels } : { values }; +} + +const I = (n: bigint | number): PlutusData => ({ int: String(n) }); +const B = (hex: string): PlutusData => ({ bytes: hex.toLowerCase() }); +const C = (constructor: number, fields: PlutusData[] = []): PlutusData => ({ constructor, fields }); +const L = (list: PlutusData[]): PlutusData => ({ list }); +const rat = (r: Rational): PlutusData => L([I(r.n), I(r.d)]); +const just = (d: PlutusData): PlutusData => C(0, [d]); +const nothing: PlutusData = C(1); + +export class ParamError extends Error { + constructor( + readonly param: string, + message: string + ) { + super(message); + } +} + +/** + * The ChangedParameters map. A composite value (prices, ex-units, thresholds) is set as a whole in the ledger, so + * the parts a change leaves out take their current value from `current` (an /epochs/{n}/parameters response). + */ +export function encodeParamChange(change: ParamChange, current: Record | null = null): PlutusData { + const parsed = new Map(); + for (const [name, raw] of Object.entries(change.values)) { + const def = paramByName[name]; + if (!def) continue; + const v = def.kind === 'rational' ? toRational(raw) : toInt(raw); + if (v == null) throw new ParamError(name, `${def.label}: “${raw}” is not ${def.kind === 'rational' ? 'a number or fraction' : 'a whole number'}.`); + parsed.set(name, v); + } + const keys = new Map(); + const valueOf = (def: ParamDef): bigint | Rational => { + const v = parsed.get(def.name); + if (v != null) return v; + const c = def.kind === 'rational' ? toRational(current?.[def.current]) : toInt(current?.[def.current]); + if (c == null) throw new ParamError(def.name, `${def.label} is part of a value that is changed as a whole, and its current value is unknown. Set it too.`); + return c; + }; + const encode = (def: ParamDef, v: bigint | Rational) => (typeof v === 'bigint' ? I(v) : rat(v)); + for (const def of PARAMS) { + if (keys.has(def.key) || ![...parsed.keys()].some((n) => paramByName[n].key === def.key)) continue; + if (def.slot == null) keys.set(def.key, encode(def, parsed.get(def.name)!)); + else { + const parts = PARAMS.filter((p) => p.key === def.key).sort((a, b) => a.slot! - b.slot!); + keys.set(def.key, L(parts.map((p) => encode(p, valueOf(p))))); + } + } + if (change.costModels && Object.keys(change.costModels).length) { + const entries = Object.entries(change.costModels) + .map(([lang, hex]) => ({ k: I(Number(lang)), v: decodePlutusData(hex) })) + .sort((a, b) => Number((a.k as { int: string }).int) - Number((b.k as { int: string }).int)); + keys.set(18, { map: entries }); + } + return { map: [...keys.entries()].sort((a, b) => a[0] - b[0]).map(([k, v]) => ({ k: I(k), v })) }; +} + +/** A credential from a stake address (bech32) or reward account (hex, header byte first). */ +export function credentialOf(address: string): { script: boolean; hash: string } | null { + let bytes: Uint8Array | null = null; + if (/^[0-9a-f]{58}$/i.test(address)) bytes = new Uint8Array(address.match(/../g)!.map((h) => parseInt(h, 16))); + else bytes = bech32Decode(address)?.bytes ?? null; + if (!bytes || bytes.length !== 29) return null; + return { script: (bytes[0] & 0x10) !== 0, hash: [...bytes.slice(1)].map((b) => b.toString(16).padStart(2, '0')).join('') }; +} + +const credentialData = (c: { script: boolean; hash: string }): PlutusData => C(c.script ? 1 : 0, [B(c.hash)]); +/** Ledger order of credentials: script hashes first, then by hash. */ +const credOrder = (a: { script: boolean; hash: string }, b: { script: boolean; hash: string }) => (a.script === b.script ? (a.hash < b.hash ? -1 : a.hash > b.hash ? 1 : 0) : a.script ? -1 : 1); + +type GovActionId = { transactionId?: string; gov_action_index?: number } | null | undefined; +const prevId = (id: GovActionId): PlutusData => (id?.transactionId ? just(C(0, [B(id.transactionId), I(id.gov_action_index ?? 0)])) : nothing); +const policy = (h: string | null | undefined): PlutusData => (h ? just(B(h)) : nothing); + +/** Yaci Store's proposal details (`details` of /governance/proposals) → GovernanceAction data. */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function encodeAction(type: string, details: any, current: Record | null = null): PlutusData { + switch (type) { + case 'PARAMETER_CHANGE_ACTION': + return C(0, [prevId(details?.govActionId), encodeParamChange(paramChangeOf(details?.protocolParamUpdate), current), policy(details?.policyHash)]); + case 'HARD_FORK_INITIATION_ACTION': { + const v = details?.protocolVersion ?? {}; + return C(1, [prevId(details?.govActionId), C(0, [I(v._1 ?? v.major ?? 0), I(v._2 ?? v.minor ?? 0)])]); + } + case 'TREASURY_WITHDRAWALS_ACTION': + return C(2, [encodeWithdrawals(Object.entries(details?.withdrawals ?? {}).map(([address, amount]) => ({ address, amount: String(amount) }))), policy(details?.policyHash)]); + case 'NO_CONFIDENCE': + return C(3, [prevId(details?.govActionId)]); + case 'UPDATE_COMMITTEE': { + const cred = (c: { type?: string; hash?: string }) => ({ script: c?.type === 'SCRIPTHASH', hash: String(c?.hash ?? '') }); + const removed = ((details?.membersForRemoval ?? []) as { type?: string; hash?: string }[]).map(cred).sort(credOrder); + const added = Object.entries((details?.newMembersAndTerms ?? {}) as Record) + .map(([k, epoch]) => { + let c: { type?: string; hash?: string } = {}; + try { + c = JSON.parse(k); + } catch { + /* not a JSON key */ + } + return { c: cred(c), epoch }; + }) + .sort((a, b) => credOrder(a.c, b.c)); + const q = toRational(details?.threshold) ?? { n: 0n, d: 1n }; + return C(4, [prevId(details?.govActionId), L(removed.map(credentialData)), { map: added.map((a) => ({ k: credentialData(a.c), v: I(a.epoch) })) }, C(0, [I(q.n), I(q.d)])]); + } + case 'NEW_CONSTITUTION': + return C(5, [prevId(details?.govActionId), C(0, [policy(details?.constitution?.scripthash)])]); + default: + return C(6); + } +} + +export function encodeWithdrawals(list: { address: string; amount: string }[]): PlutusData { + const entries = list.map((w) => { + const c = credentialOf(w.address); + if (!c) throw new ParamError('withdrawals', `“${w.address}” is not a stake address.`); + const amount = toInt(w.amount); + if (amount == null) throw new ParamError('withdrawals', `“${w.amount}” is not a whole number of lovelace.`); + return { c, amount }; + }); + return { map: entries.sort((a, b) => credOrder(a.c, b.c)).map((e) => ({ k: credentialData(e.c), v: I(e.amount) })) }; +} + +/** ProposalProcedure: deposit, return credential (from the stake address) and action. */ +export function encodeProcedure(deposit: string | number, returnAddress: string, action: PlutusData): PlutusData { + const c = credentialOf(returnAddress) ?? { script: false, hash: '00'.repeat(28) }; + return C(0, [I(BigInt(deposit)), credentialData(c), action]); +} + +/** julc's name for an action type in a MockTransaction proposal. */ +export function mockActionType(type: string): string { + if (type === 'PARAMETER_CHANGE_ACTION') return 'parameterChange'; + if (type === 'TREASURY_WITHDRAWALS_ACTION') return 'treasuryWithdrawals'; + if (type === 'NO_CONFIDENCE') return 'noConfidence'; + return 'info'; +} diff --git a/applications/viewer/src/lib/julc/governance/draft.test.ts b/applications/viewer/src/lib/julc/governance/draft.test.ts new file mode 100644 index 00000000..591fa592 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/draft.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { actionOf, isEmpty, queryOf } from './draft'; + +describe('draft links', () => { + it('keeps parameter values, withdrawals and a custom script in the URL', () => { + expect(queryOf({ kind: 'param', values: { treasuryGrowthRate: '1/10', minPoolCost: '170000000' }, withdrawals: [], from: null }, null)).toBe('p.treasuryGrowthRate=1%2F10&p.minPoolCost=170000000'); + expect(queryOf({ kind: 'treasury', values: {}, withdrawals: [{ address: 'stake1x', amount: '5' }, { address: ' ', amount: '' }], from: null }, 'ab')).toBe('type=treasury&w=stake1x%3A5&script=ab'); + // Cost models are too long for a link: point at the proposal they came from. + expect(queryOf({ kind: 'param', values: {}, costModels: { '2': '80' }, withdrawals: [], from: 'tx_0' }, null)).toBe('from=tx_0'); + }); + + it('turns a draft into a guardrail action', () => { + expect(actionOf({ kind: 'treasury', values: {}, withdrawals: [{ address: ' e1aa ', amount: '7 ' }, { address: '', amount: '' }], from: null })).toEqual({ type: 'TREASURY_WITHDRAWALS_ACTION', withdrawals: [{ address: 'e1aa', amount: '7' }] }); + expect(isEmpty({ kind: 'param', values: {}, withdrawals: [], from: null })).toBe(true); + }); +}); diff --git a/applications/viewer/src/lib/julc/governance/draft.ts b/applications/viewer/src/lib/julc/governance/draft.ts new file mode 100644 index 00000000..12ebda67 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/draft.ts @@ -0,0 +1,38 @@ +import { PARAMS } from './action'; +import type { GuardrailAction } from './guardrail'; + +/* A proposal drafted on the guardrail check page, and how it is kept in a shareable URL. */ + +export interface Draft { + kind: 'param' | 'treasury'; + /** Parameter values by name (strings as typed). */ + values: Record; + /** Cost models of a proposal the draft started from (too long for a URL; `from` brings them back). */ + costModels?: Record; + withdrawals: { address: string; amount: string }[]; + /** The proposal the draft started from ("_"), if any. */ + from: string | null; +} + +export function actionOf(d: Draft): GuardrailAction { + return d.kind === 'treasury' + ? { type: 'TREASURY_WITHDRAWALS_ACTION', withdrawals: d.withdrawals.filter((w) => w.address.trim() || w.amount.trim()).map((w) => ({ address: w.address.trim(), amount: w.amount.trim() })) } + : { type: 'PARAMETER_CHANGE_ACTION', change: { values: Object.fromEntries(Object.entries(d.values).map(([k, v]) => [k, v.trim()])), ...(d.costModels ? { costModels: d.costModels } : {}) } }; +} + +export const isEmpty = (d: Draft) => (d.kind === 'treasury' ? !d.withdrawals.some((w) => w.address.trim()) : !Object.keys(d.values).length && !d.costModels); + +/** The query string that reopens this draft (and script, when it isn't the default). */ +export function queryOf(d: Draft, script: string | null): string { + const q = new URLSearchParams(); + if (d.kind === 'treasury') { + q.set('type', 'treasury'); + for (const w of d.withdrawals) if (w.address.trim()) q.append('w', `${w.address.trim()}:${w.amount.trim()}`); + } else { + // Cost models can't go into a link; a draft that still has them points at its proposal instead. + if (d.costModels && d.from) q.set('from', d.from); + for (const p of PARAMS) if (d.values[p.name] != null) q.set(`p.${p.name}`, d.values[p.name]); + } + if (script) q.set('script', script); + return q.toString(); +} diff --git a/applications/viewer/src/lib/julc/governance/guardrail.ts b/applications/viewer/src/lib/julc/governance/guardrail.ts new file mode 100644 index 00000000..bd780668 --- /dev/null +++ b/applications/viewer/src/lib/julc/governance/guardrail.ts @@ -0,0 +1,155 @@ +import { encodePlutusData } from '$lib/cbor'; +import { julc } from '../engine.svelte'; +import type { RunPlan } from '../replay/build'; +import { fixContext } from '../replay/fix-context'; +import { vmRequest, type RunResult } from '../replay/run'; +import type { MockTransaction, Target } from '../types'; +import { PARAMS, ParamError, encodeParamChange, encodeProcedure, encodeWithdrawals, paramByName, type ParamChange } from './action'; + +/* + * The guardrail check: run the constitution's guardrail script against a proposal that isn't on chain, as the + * ledger would when the proposal is submitted. julc builds a proposing ScriptContext around a placeholder + * transaction, the viewer puts the real governance action into it (fix-context.ts) and the VM runs the script. + * Only parameter changes and treasury withdrawals carry a guardrail ("policy") in Conway. + */ + +export type GuardrailAction = { type: 'PARAMETER_CHANGE_ACTION'; change: ParamChange } | { type: 'TREASURY_WITHDRAWALS_ACTION'; withdrawals: { address: string; amount: string }[] }; + +/** An /epochs/{n}/parameters response: cost models, protocol, limits, and current values for composite parameters. */ +export type EpochParams = Record & { + cost_models_raw?: Record | null; + protocol_major_ver?: number | null; + gov_action_deposit?: number | string | null; + max_tx_ex_mem?: number | string | null; + max_tx_ex_steps?: number | string | null; +}; + +export interface GuardrailScript { + hash: string; + cbor: string; +} + +const RETURN_ADDRESS = 'e0' + '00'.repeat(28); + +/** The action's ProposalProcedure as CBOR; throws ParamError for an invalid value. */ +export function procedureOf(action: GuardrailAction, params: EpochParams | null, guardrail: string): string { + const policy = { constructor: 0, fields: [{ bytes: guardrail }] }; + const data = + action.type === 'PARAMETER_CHANGE_ACTION' + ? { constructor: 0, fields: [{ constructor: 1, fields: [] }, encodeParamChange(action.change, params), policy] } + : { constructor: 2, fields: [encodeWithdrawals(action.withdrawals), policy] }; + return encodePlutusData(encodeProcedure(String(params?.gov_action_deposit ?? 100_000_000_000), RETURN_ADDRESS, data)); +} + +let placeholder: Promise | null = null; + +/** julc's sample proposing transaction (one key input and output), fetched once. */ +function placeholderTx(): Promise { + placeholder ??= julc.run(async (c) => { + if (!c.uplc?.defaultTransaction) throw new Error('This julc engine can’t build transactions (VM-only build).'); + return (await c.uplc.defaultTransaction({ purpose: 'propose' })) as MockTransaction; + }); + placeholder.catch(() => (placeholder = null)); + return placeholder; +} + +/** A run plan for the check, so the result can be shown and debugged like a re-run. */ +export async function guardrailPlan(action: GuardrailAction, script: GuardrailScript, params: EpochParams | null): Promise { + const procedure = procedureOf(action, params, script.hash); + const pv = params?.protocol_major_ver ?? null; + const protocol = pv == null ? 11 : Math.min(11, Math.max(10, pv)); + const target: Target = { language: 'PlutusV3', protocol }; + const costs = params?.cost_models_raw?.PlutusV3; + const tx = await placeholderTx(); + return { + key: 'propose#0', + purpose: 'propose', + index: 0, + scriptHash: script.hash, + language: 'V3', + declared: null, + redeemer: 'd87980', + datum: null, + hashedDatums: [], + witnessDatums: [], + proposals: [procedure], + notes: [], + request: { + script: { script: script.cbor, language: 'V3' }, + transaction: { + ...tx, + purpose: { type: 'propose', index: 0 }, + redeemer: { format: 'cbor', value: 'd87980' }, + proposals: [ + { + deposit: String(params?.gov_action_deposit ?? 100_000_000_000), + returnCredential: 'key:' + '00'.repeat(28), + actionType: action.type === 'PARAMETER_CHANGE_ACTION' ? 'parameterChange' : 'treasuryWithdrawals', + guardrail: script.hash, + withdrawals: null + } + ] + } as MockTransaction, + target, + costModel: costs?.length ? { target, parameters: costs.map((c) => BigInt(c)) } : null, + ...(params?.max_tx_ex_steps ? { maxCpu: BigInt(String(params.max_tx_ex_steps)) } : {}), + ...(params?.max_tx_ex_mem ? { maxMem: BigInt(String(params.max_tx_ex_mem)) } : {}) + } + }; +} + +/** Run the guardrail on an action. A RunResult like the Scripts tab's, so verdicts and the debugger apply. */ +export async function checkGuardrail(action: GuardrailAction, script: GuardrailScript, params: EpochParams | null): Promise { + let plan: RunPlan; + try { + plan = await guardrailPlan(action, script, params); + } catch (e) { + const stub = { key: 'propose#0', purpose: 'propose', index: 0, scriptHash: script.hash, language: 'V3', declared: null, redeemer: null, datum: null, hashedDatums: [], witnessDatums: [], proposals: [], request: null, notes: [] } as RunPlan; + return { state: 'error', plan: stub, error: (e as Error).message }; + } + const started = performance.now(); + try { + const first = await julc.run((c) => c.uplc!.evaluateTransaction(plan.request!)); + if (!first.scriptContextCbor) return { state: 'error', plan, error: first.error ?? 'julc could not build the proposal’s context.' }; + const fixed = fixContext(first.scriptContextCbor, 'V3', null, [], [], plan.proposals); + if (!fixed?.restoredProposals) return { state: 'error', plan, error: 'The proposal couldn’t be put into julc’s context.' }; + const args = [{ format: 'cbor' as const, value: fixed.cbor }]; + const evaluation = await julc.run((c) => c.vm.evaluate(vmRequest(plan, args))); + if (evaluation.ok === false && evaluation.error) return { state: 'error', plan, error: evaluation.error }; + return { state: 'done', plan, evaluation: { ...evaluation, scriptContextCbor: fixed.cbor, language: 'PlutusV3' }, args, ms: Math.round(performance.now() - started) }; + } catch (e) { + return { state: 'error', plan, error: (e as Error)?.message ?? String(e) }; + } +} + +/** + * Which changed parameters the guardrail rejects on their own. Values set together in the ledger (prices, ex-units, + * a threshold group) are checked together. Returns parameter names by verdict. + */ +export async function checkEachParam(change: ParamChange, script: GuardrailScript, params: EpochParams | null): Promise> { + const groups = new Map(); + for (const name of Object.keys(change.values)) { + const def = paramByName[name]; + if (def) groups.set(def.key, [...(groups.get(def.key) ?? []), name]); + } + const verdicts: Record = {}; + for (const names of groups.values()) { + const part: ParamChange = { values: Object.fromEntries(names.map((n) => [n, change.values[n]])) }; + let verdict: 'ok' | 'rejected' | 'invalid'; + try { + encodeParamChange(part, params); + const r = await checkGuardrail({ type: 'PARAMETER_CHANGE_ACTION', change: part }, script, params); + verdict = r.state === 'done' && (r.evaluation?.accepted ?? r.evaluation?.status === 'success') ? 'ok' : 'rejected'; + } catch (e) { + verdict = e instanceof ParamError ? 'invalid' : 'rejected'; + } + for (const n of names) verdicts[n] = verdict; + } + if (change.costModels && Object.keys(change.costModels).length) { + const r = await checkGuardrail({ type: 'PARAMETER_CHANGE_ACTION', change: { values: {}, costModels: change.costModels } }, script, params); + verdicts.costModels = r.state === 'done' && (r.evaluation?.accepted ?? r.evaluation?.status === 'success') ? 'ok' : 'rejected'; + } + return verdicts; +} + +export { PARAMS }; diff --git a/applications/viewer/src/lib/julc/highlight.test.ts b/applications/viewer/src/lib/julc/highlight.test.ts new file mode 100644 index 00000000..76861007 --- /dev/null +++ b/applications/viewer/src/lib/julc/highlight.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { splitMatches, tokenize } from './highlight'; + +// Adjacent tokens of the same kind (names and spaces) are merged into one span. +const kinds = (line: string, lang: 'uplc') => tokenize(line, lang).filter((t) => t.text.trim()).map((t) => [t.text.trim(), t.kind]); + +describe('tokenize', () => { + it('highlights UPLC keywords, builtins, types and constants', () => { + expect(kinds('[(builtin addInteger) (con integer -5)]', 'uplc')).toEqual([ + ['[(', 'punct'], + ['builtin', 'kw'], + ['addInteger', 'builtin'], + [')', 'punct'], + ['(', 'punct'], + ['con', 'kw'], + ['integer', 'type'], + ['-5', 'num'], + [')]', 'punct'] + ]); + expect(kinds('(con bytestring #c0ffee)', 'uplc')).toContainEqual(['#c0ffee', 'bytes']); + expect(kinds('(con string "a \\"b\\"")', 'uplc')).toContainEqual(['"a \\"b\\""', 'str']); + expect(kinds('(lam i_1 i_1)', 'uplc')).toEqual([ + ['(', 'punct'], + ['lam', 'kw'], + ['i_1 i_1', 'plain'], + [')', 'punct'] + ]); + }); + + it('keeps every character', () => { + const line = ' [(force (builtin ifThenElse)) (con bool True) #ff "q" ~]'; + expect(tokenize(line, 'uplc').map((t) => t.text).join('')).toBe(line); + }); +}); + +describe('splitMatches', () => { + it('marks case-insensitive hits', () => { + expect(splitMatches('addInteger AddInteger', 'addinteger')).toEqual([ + { text: 'addInteger', hit: true }, + { text: ' ', hit: false }, + { text: 'AddInteger', hit: true } + ]); + expect(splitMatches('abc', '')).toEqual([{ text: 'abc', hit: false }]); + }); +}); diff --git a/applications/viewer/src/lib/julc/highlight.ts b/applications/viewer/src/lib/julc/highlight.ts new file mode 100644 index 00000000..3b89dfbe --- /dev/null +++ b/applications/viewer/src/lib/julc/highlight.ts @@ -0,0 +1,59 @@ +/* + * Line-by-line syntax highlighting for UPLC. Only the lines on screen are tokenised, so even very large scripts stay + * fast. Tokens are rendered as text, never as HTML. + */ + +export type TokenKind = 'kw' | 'builtin' | 'type' | 'num' | 'str' | 'bytes' | 'punct' | 'plain'; +export interface Token { + text: string; + kind: TokenKind; +} +export type CodeLanguage = 'uplc' | 'plain'; + +const UPLC_KW = new Set(['program', 'lam', 'delay', 'force', 'builtin', 'con', 'error', 'constr', 'case']); +const UPLC_TYPES = new Set(['integer', 'bytestring', 'string', 'bool', 'unit', 'data', 'list', 'pair', 'array', 'value', 'bls12_381_G1_element', 'bls12_381_G2_element', 'bls12_381_MlResult']); + +const UPLC_RE = /("(?:[^"\\]|\\.)*")|(#[0-9a-fA-F]*)|(-?\d+)|([A-Za-z_][\w']*)|([()[\]{},.<>])|(\s+)|(.)/g; + +export function tokenize(line: string, lang: CodeLanguage): Token[] { + if (lang === 'plain') return [{ text: line, kind: 'plain' }]; + const out: Token[] = []; + const push = (text: string, kind: TokenKind) => { + const last = out[out.length - 1]; + if (last && last.kind === kind) last.text += text; + else out.push({ text, kind }); + }; + let prevWord = ''; + for (const m of line.matchAll(UPLC_RE)) { + const [text, str, bytes, num, word, punct] = m; + if (str) push(text, 'str'); + else if (bytes) push(text, 'bytes'); + else if (num) push(text, 'num'); + else if (word) { + if (UPLC_KW.has(word)) push(text, 'kw'); + else if (prevWord === 'builtin') push(text, 'builtin'); + else if (UPLC_TYPES.has(word) || prevWord === 'con') push(text, 'type'); + else if (word === 'True' || word === 'False' || word === 'Constr' || word === 'Map' || word === 'List' || word === 'I' || word === 'B') push(text, 'num'); + else push(text, 'plain'); + prevWord = word; + } else if (punct) push(text, 'punct'); + else push(text, 'plain'); + } + return out; +} + +/** Split a line into plain and matched parts, for highlighting search hits (case-insensitive). */ +export function splitMatches(text: string, query: string): { text: string; hit: boolean }[] { + if (!query) return [{ text, hit: false }]; + const parts: { text: string; hit: boolean }[] = []; + const lower = text.toLowerCase(); + const q = query.toLowerCase(); + let at = 0; + for (let i = lower.indexOf(q); i !== -1; i = lower.indexOf(q, i + q.length)) { + if (i > at) parts.push({ text: text.slice(at, i), hit: false }); + parts.push({ text: text.slice(i, i + q.length), hit: true }); + at = i + q.length; + } + if (at < text.length) parts.push({ text: text.slice(at), hit: false }); + return parts; +} diff --git a/applications/viewer/src/lib/julc/monitor/monitor.svelte.ts b/applications/viewer/src/lib/julc/monitor/monitor.svelte.ts new file mode 100644 index 00000000..4b6a6ca1 --- /dev/null +++ b/applications/viewer/src/lib/julc/monitor/monitor.svelte.ts @@ -0,0 +1,176 @@ +import { api, tryApi } from '$lib/api/client'; +import type { LiveBlock } from '$lib/live/chain.svelte'; +import { purposeOf, type StoreScriptRun } from '../replay/build'; +import { loadPageTx } from '../replay/load'; +import { prepareRuns, runAll, type RunResult } from '../replay/run'; +import { exact, margin, outcome, type Outcome } from '../replay/verdict'; + +/* + * The live script monitor: for every new block, find the transactions that ran scripts and re-run them in the + * browser, one transaction at a time. On public networks only a few script transactions per block are re-run + * (`cap`), as each needs several store requests. + */ + +export interface MonitoredRun { + key: string; + purpose: string; + index: number; + scriptHash: string; + language: string | null; + outcome: Outcome; + verdict: 'exact' | 'margin' | null; + cpu: bigint | null; + mem: bigint | null; + /** Share of the per-transaction limits, in percent. */ + cpuPct: number; + memPct: number; + error: string | null; +} + +export interface MonitoredTx { + hash: string; + block: number; + time: number; + runs: MonitoredRun[]; + ms: number; + /** Replayed from the stored CBOR or rebuilt from the store's JSON. */ + source?: 'cbor' | 'store'; +} + +/** A run using at least this share of a per-transaction limit is flagged. */ +export const NEAR_LIMIT = 70; + +const hasRedeemer = (s: StoreScriptRun) => !!s.redeemer; + +function summarize(r: RunResult, limits: { mem: bigint; steps: bigint }): MonitoredRun { + const ev = r.evaluation; + const m = margin(r); + const pct = (a: bigint | null | undefined, b: bigint) => (a == null || !b ? 0 : Number((a * 10000n) / b) / 100); + return { + key: r.plan.key, + purpose: r.plan.purpose, + index: r.plan.index, + scriptHash: r.plan.scriptHash, + language: r.plan.language, + outcome: outcome(r), + verdict: exact(r) ? 'exact' : m != null ? 'margin' : null, + cpu: ev?.cpu ?? null, + mem: ev?.mem ?? null, + cpuPct: pct(ev?.cpu, limits.steps), + memPct: pct(ev?.mem, limits.mem), + error: r.error ?? (ev && outcome(r) !== 'passed' ? (ev.message ?? ev.error) : null) + }; +} + +export class ScriptMonitor { + running = $state(false); + entries = $state.raw([]); + queued = $state(0); + busy = $state(false); + lastBlock = $state(null); + failure = $state(null); + /** Script transactions re-run per block; Infinity for all. */ + cap = $state(Infinity); + stats = $state({ blocks: 0, scriptTxs: 0, skipped: 0, runs: 0, reproduced: 0, failed: 0, couldnt: 0, near: 0, peak: 0 }); + + #seen = new Set(); + #queue: { hash: string; block: LiveBlock; scripts: StoreScriptRun[] }[] = []; + #limits: { mem: bigint; steps: bigint } | null = null; + + start() { + this.running = true; + this.failure = null; + } + + stop() { + this.running = false; + this.#queue = []; + this.queued = 0; + } + + clear() { + this.entries = []; + this.stats = { blocks: 0, scriptTxs: 0, skipped: 0, runs: 0, reproduced: 0, failed: 0, couldnt: 0, near: 0, peak: 0 }; + } + + /** A new block arrived (or a recent one, to fill the feed when starting). */ + async block(b: LiveBlock) { + if (!this.running || this.#seen.has(b.number)) return; + this.#seen.add(b.number); + this.lastBlock = Math.max(this.lastBlock ?? 0, b.number); + this.stats.blocks++; + if (!b.txCount) return; + const txs = (await tryApi<{ tx_hash: string }[]>(`/blocks/${b.number}/txs`)) ?? []; + // Which transactions ran scripts: a few requests at a time. + const withScripts: { hash: string; scripts: StoreScriptRun[] }[] = []; + for (let i = 0; i < txs.length; i += 4) { + const batch = await Promise.all(txs.slice(i, i + 4).map(async (t) => ({ hash: t.tx_hash, scripts: (await tryApi(`/txs/${t.tx_hash}/scripts`)) ?? [] }))); + for (const t of batch) if (t.scripts.some(hasRedeemer)) withScripts.push(t); + if (!this.running) return; + } + this.stats.scriptTxs += withScripts.length; + const picked = withScripts.slice(0, this.cap); + this.stats.skipped += withScripts.length - picked.length; + for (const t of picked) this.#queue.push({ ...t, block: b }); + this.queued = this.#queue.length; + this.#drain(); + } + + async #drain() { + if (this.busy) return; + this.busy = true; + try { + while (this.running && this.#queue.length) { + const job = this.#queue.shift()!; + this.queued = this.#queue.length; + await this.#replay(job); + } + } finally { + this.busy = false; + } + } + + async #replay(job: { hash: string; block: LiveBlock; scripts: StoreScriptRun[] }) { + const started = performance.now(); + try { + this.#limits ??= await api<{ max_tx_ex_mem: number; max_tx_ex_steps: number }>('/epochs/latest/parameters').then((p) => ({ + mem: BigInt(p?.max_tx_ex_mem ?? 14_000_000), + steps: BigInt(p?.max_tx_ex_steps ?? 10_000_000_000) + })); + const page = await loadPageTx(job.hash, job.block.epoch, job.scripts); + const plans = await prepareRuns(page); + const results: Record = {}; + const engineFailure = await runAll(plans, (k, r) => (results[k] = r)); + if (engineFailure) { + this.failure = engineFailure; + this.stop(); + return; + } + const runs = plans.map((p) => summarize(results[p.key], this.#limits!)); + for (const r of runs) { + this.stats.runs++; + if (r.verdict) this.stats.reproduced++; + if (r.outcome === 'failed' || r.outcome === 'out of budget') this.stats.failed++; + if (r.outcome === 'couldn’t run') this.stats.couldnt++; + const top = Math.max(r.cpuPct, r.memPct); + if (top >= NEAR_LIMIT) this.stats.near++; + this.stats.peak = Math.max(this.stats.peak, top); + } + const entry: MonitoredTx = { hash: job.hash, block: job.block.number, time: job.block.time, runs, ms: Math.round(performance.now() - started), source: plans[0]?.source }; + this.entries = [entry, ...this.entries].slice(0, 100); + } catch (e) { + const entry: MonitoredTx = { + hash: job.hash, + block: job.block.number, + time: job.block.time, + ms: Math.round(performance.now() - started), + runs: job.scripts.filter(hasRedeemer).map((s) => { + const r = typeof s.redeemer === 'string' ? JSON.parse(s.redeemer) : s.redeemer; + return { key: '', purpose: purposeOf(r?.tag) ?? String(r?.tag), index: Number(r?.index ?? 0), scriptHash: s.script_hash, language: null, outcome: 'couldn’t run' as const, verdict: null, cpu: null, mem: null, cpuPct: 0, memPct: 0, error: (e as Error)?.message ?? String(e) }; + }) + }; + this.stats.couldnt += entry.runs.length; + this.entries = [entry, ...this.entries].slice(0, 100); + } + } +} diff --git a/applications/viewer/src/lib/julc/replay/build.test.ts b/applications/viewer/src/lib/julc/replay/build.test.ts new file mode 100644 index 00000000..bf4a4453 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/build.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { bech32Encode } from '$lib/bech32'; +import { buildRuns, type ReplayInput, type StoreCert } from './build'; +import { slotConfigFor, slotToPosixMs } from './slots'; +import withdrawZero from './fixtures/mainnet-withdrawZero.json'; +import v3 from './fixtures/mainnet-v3.json'; +import mint from './fixtures/mainnet-mint.json'; +import proposal from './fixtures/mainnet-proposal.json'; +import { decodePlutusData, type PlutusData } from '$lib/cbor'; + +/* + * Fixtures are Mainnet transactions captured from Yaci Store with the same requests as load.ts (script bodies + * trimmed). They were re-run in the browser with julc: withdrawZero's two spend redeemers reproduce their on-chain + * budgets exactly; its reward redeemer fails because the context lacks the other redeemers (julc#244). + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function inputOf(f: any): ReplayInput { + const certificates: StoreCert[] = [ + ...f.stakes.map((s: { cert_index: number; address: string; registration: boolean }) => ({ cert_index: s.cert_index, type: s.registration ? 'registration' : 'deregistration', address: s.address })), + ...f.delegations.map((d: { cert_index: number; address: string; pool_id: string }) => ({ cert_index: d.cert_index, type: 'delegation', address: d.address, pool_id: d.pool_id })) + ]; + return { hash: f.hash, tx: f.tx, scripts: f.scripts, withdrawals: f.withdrawals, mints: f.mints, votes: f.votes, certificates, datums: {}, params: f.params, slots: slotConfigFor(f.genesis.network_magic, f.genesis) }; +} + +const stakeAddr = (header: number, fill: number) => bech32Encode('stake', new Uint8Array([header, ...Array(28).fill(fill)])); + +describe('slots', () => { + it('uses Shelley constants for public networks and genesis for devnets', () => { + const mainnet = slotConfigFor(764824073, { system_start: 1506203091, slot_length: 1 })!; + expect(slotToPosixMs(4492800, mainnet)).toBe(1596059091000n); + expect(slotToPosixMs(200642780, mainnet)).toBe(1596059091000n + (200642780n - 4492800n) * 1000n); + const devnet = slotConfigFor(42, { network_magic: 42, system_start: 1792193424, slot_length: 0.2 })!; + expect(slotToPosixMs(0, devnet)).toBe(1792193424000n); + expect(slotToPosixMs(10, devnet)).toBe(1792193426000n); + expect(slotConfigFor(42, null)).toBeNull(); + }); +}); + +describe('buildRuns', () => { + it('rebuilds a withdraw-zero transaction (Plutus V2) in ledger order', () => { + const runs = buildRuns(inputOf(withdrawZero)); + expect(runs.map((r) => r.key).sort()).toEqual(['reward#0', 'spend#0', 'spend#1']); + for (const r of runs) expect(r.skip).toBeUndefined(); + + const spend = runs.find((r) => r.key === 'spend#0')!; + const tx = spend.request!.transaction; + expect(spend.language).toBe('V2'); + expect(spend.declared).toEqual({ mem: 77308n, steps: 23525984n }); + expect(tx.purpose).toEqual({ type: 'spend', index: 0 }); + expect(tx.txId).toBe(withdrawZero.hash); + const refs = tx.inputs.map((i) => `${i.txId}#${i.index}`); + expect(refs).toEqual([...refs].sort()); + expect(tx.inputs[0].datum.kind).toBe('inline'); + expect(tx.validRange).toEqual({ + from: 1596059091000n + (BigInt(withdrawZero.tx.validity_interval_start) - 4492800n) * 1000n, + fromInclusive: true, + to: 1596059091000n + (BigInt(withdrawZero.tx.ttl) - 4492800n) * 1000n, + toInclusive: false + }); + expect(tx.signatories).toEqual(withdrawZero.tx.required_signers); + expect(tx.withdrawals).toEqual([{ credential: withdrawZero.withdrawals[0].address, amount: '0' }]); + + expect(spend.request!.target).toEqual({ language: 'PlutusV2', protocol: 11 }); + expect(spend.request!.costModel!.parameters.length).toBe(withdrawZero.params.cost_models_raw.PlutusV2.length); + expect(spend.notes.join(' ')).toMatch(/julc#244/); + + const reward = runs.find((r) => r.key === 'reward#0')!; + expect(reward.request!.transaction.purpose).toEqual({ type: 'reward', target: 'script:1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a' }); + }); + + it('rebuilds Plutus V3 runs', () => { + const runs = buildRuns(inputOf(v3)); + expect(runs.length).toBe(2); + for (const r of runs) { + expect(r.language).toBe('V3'); + expect(r.request?.target.language).toBe('PlutusV3'); + } + }); + + it('targets the minting policy and sorts the mint', () => { + const runs = buildRuns(inputOf(mint)); + const m = runs.find((r) => r.purpose === 'mint')!; + expect(m.request!.transaction.purpose).toEqual({ type: 'mint', target: m.scriptHash }); + expect(m.request!.transaction.mint).toEqual([{ policyId: mint.mints[0].unit.slice(0, 56), tokenName: mint.mints[0].unit.slice(56), quantity: String(mint.mints[0].quantity) }]); + }); + + it('orders withdrawals as the ledger does: script credentials first', () => { + const key = stakeAddr(0xe1, 0x00); + const script = stakeAddr(0xf1, 0xff); + const input = inputOf(withdrawZero); + input.withdrawals = [ + { address: key, amount: 1 }, + { address: script, amount: 2 } + ]; + const tx = buildRuns(input)[0].request!.transaction; + expect(tx.withdrawals.map((w) => w.credential)).toEqual([script, key]); + }); + + it('explains runs it cannot rebuild', () => { + const input = inputOf(withdrawZero); + const byron = { ...input, tx: { ...input.tx, outputs: [{ ...input.tx.outputs[0], address: 'DdzFFzCqrht4PWfBGtmrQz4x1GkZHYLVGbK7aaBkjWxujxzz3L5GxCgPiTsks5RvMN1Bo8Va6V9C6Rm2vkGQhJmq8dRLPMtJBXCjCC8' }] } }; + expect(buildRuns(byron)[0].skip).toMatch(/Byron/); + expect(buildRuns({ ...input, slots: null })[0].skip).toMatch(/Slot times/); + const propose = { ...input, scripts: [{ ...input.scripts[0], redeemer: { tag: 'propose', index: 0, data: 'd87980', ex_units: { mem: 1, steps: 1 } } }] }; + expect(buildRuns(propose)[0].skip).toMatch(/proposal #0/); + const cert = { ...input, scripts: [{ ...input.scripts[0], redeemer: { tag: 'cert', index: 0, data: 'd87980', ex_units: { mem: 1, steps: 1 } } }] }; + expect(buildRuns(cert)[0].skip).toMatch(/certificate/); + }); + + it('normalises purpose names and infers V3 for proposals', () => { + const input = inputOf(withdrawZero); + input.scripts = [{ script_hash: 'ab'.repeat(28), type: null, script_content: null, redeemer: { tag: 'proposing', index: 0, data: 'd87980', ex_units: { mem: 1, steps: 1 } } }]; + const [run] = buildRuns(input); + expect(run.key).toBe('propose#0'); + expect(run.language).toBe('V3'); + expect(run.skip).toMatch(/proposal #0/); + }); + + it('treats a 0 TTL and validity start (how Yaci Store reports none) as no bound', () => { + const input = inputOf(withdrawZero); + input.tx = { ...input.tx, ttl: 0, validity_interval_start: 0 }; + input.slots = null; + const [run] = buildRuns(input); + expect(run.skip).toBeUndefined(); + expect(run.request!.transaction.validRange).toEqual({ from: null, fromInclusive: true, to: null, toInclusive: false }); + }); + + it('maps votes in ledger voter order and targets the voting script', () => { + const input = inputOf(v3); + input.votes = [ + { voter_type: 'DREP_KEY_HASH', voter_hash: 'aa'.repeat(28), gov_action_tx_hash: '11'.repeat(32), gov_action_index: 0, vote: 'NO' }, + { voter_type: 'DREP_SCRIPT_HASH', voter_hash: input.scripts[0].script_hash, gov_action_tx_hash: '11'.repeat(32), gov_action_index: 0, vote: 'YES' } + ]; + input.scripts = [{ ...input.scripts[0], redeemer: { tag: 'vote', index: 0, data: 'd87980', ex_units: { mem: 1, steps: 1 } } }]; + const [run] = buildRuns(input); + const tx = run.request!.transaction; + expect(tx.votes.map((v) => v.voter)).toEqual([`script:${input.scripts[0].script_hash}`, `key:${'aa'.repeat(28)}`]); + expect(tx.purpose).toEqual({ type: 'vote', target: `script:${input.scripts[0].script_hash}`, voterType: 'drep' }); + }); +}); + +describe('buildRuns: proposals', () => { + // gov_action14dr5yg75pchr2sz42djtuflpvx5qnsek29qg7s7cft8lzrqt5vrqqtqntpk: guardrail run reproduced exactly in the browser. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const f = proposal as any; + const input = (proposals = f.proposals): ReplayInput => ({ + ...inputOf(f), + // The store has no script hash or body for proposing runs; load.ts takes the hash from the proposal and fetches the body. + scripts: f.scripts.map((s: object) => ({ ...s, script_hash: f.proposals[0].details.policyHash, type: 'plutusV3', script_content: JSON.stringify({ content: '4e4d01000033222220051200120011' }) })), + proposals + }); + + it('rebuilds a proposing run with the real governance action for the context fix', () => { + const [run] = buildRuns(input()); + expect(run.key).toBe('propose#0'); + expect(run.language).toBe('V3'); + expect(run.scriptHash).toBe('fa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64'); + expect(run.request!.transaction.purpose).toEqual({ type: 'propose', index: 0 }); + expect(run.request!.transaction.proposals[0]).toMatchObject({ deposit: '100000000000', actionType: 'parameterChange', guardrail: run.scriptHash }); + const procedure = decodePlutusData(run.proposals[0]) as { fields: PlutusData[] }; + expect(procedure.fields[0]).toEqual({ int: '100000000000' }); + const action = procedure.fields[2] as { constructor: number; fields: PlutusData[] }; + expect(action.constructor).toBe(0); + expect((action.fields[1] as { map: { k: { int: string } }[] }).map.map((e) => e.k.int)).toEqual(['16', '20', '21']); + }); + + it('skips a proposing run when the store has no proposals', () => { + expect(buildRuns(input([]))[0].skip).toMatch(/proposal #0/); + }); +}); diff --git a/applications/viewer/src/lib/julc/replay/build.ts b/applications/viewer/src/lib/julc/replay/build.ts new file mode 100644 index 00000000..93cb0f16 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/build.ts @@ -0,0 +1,417 @@ +import { bech32Decode } from '$lib/bech32'; +import { encodePlutusData } from '$lib/cbor'; +import { encodeAction, encodeProcedure, mockActionType } from '../governance/action'; +import type { DataInput, MockCertificate, MockDatum, MockTransaction, MockTxIn, MockTxOut, MockValue, MockVote, PlutusVersion, Target, TransactionRequest } from '../types'; +import { slotToPosixMs, type SlotConfig } from './slots'; + +/* + * Rebuild a historical transaction from Yaci Store data as a julc MockTransaction, one per redeemer, so each script + * can be re-run in the browser with the cost model of the epoch it ran in. Pure: all store data is passed in. + * + * Ledger order matters (purpose indexes and map order in the ScriptContext): inputs and reference inputs by + * (tx id, index); mint and values by policy, then asset name; withdrawals and votes by credential, with script + * credentials before key hashes, as in cardano-ledger. + * + * Known gaps (julc#244) are reported as notes instead of failing silently. + */ + +export interface StoreUtxo { + tx_hash: string; + output_index: number; + address: string; + amount: { unit: string; quantity: string | number }[]; + data_hash?: string | null; + inline_datum?: string | null; + reference_script_hash?: string | null; +} + +export interface StoreScriptRun { + script_hash: string; + type?: string | null; + script_content?: string | null; + redeemer?: { tag?: string; index?: number; data?: string; ex_units?: { mem: number; steps: number } } | string | null; + datum?: string | null; + datum_hash?: string | null; +} + +export interface StoreVote { + voter_type: string; + voter_hash: string; + gov_action_tx_hash: string; + gov_action_index: number; + vote: string; +} + +export interface StoreCert { + cert_index: number; + /** 'other': a certificate kind julc can't put into a context yet (julc#244), kept so indexes stay right. */ + type: 'registration' | 'deregistration' | 'delegation' | 'other'; + address: string; + pool_id?: string | null; +} + +/** A proposal of the transaction (/governance/proposals/{tx}). */ +export interface StoreProposal { + index: number; + type: string; + deposit: number | string; + return_address: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + details?: any; +} + +export interface ReplayInput { + hash: string; + tx: { + fees: number | string; + ttl?: number | null; + validity_interval_start?: number | null; + required_signers?: string[] | null; + inputs: StoreUtxo[]; + outputs: StoreUtxo[]; + reference_inputs?: StoreUtxo[] | null; + }; + scripts: StoreScriptRun[]; + withdrawals: { address: string; amount: number | string }[]; + mints: { unit: string; quantity: number | string }[]; + votes: StoreVote[]; + certificates: StoreCert[]; + /** The transaction's proposals, needed by proposing (guardrail) scripts. */ + proposals?: StoreProposal[]; + /** CBOR of datums known by hash (for inputs that carry a datum hash). */ + datums: Record; + /** The epoch the parameters are from; null when the transaction's own epoch wasn't in the store. */ + paramsEpoch?: number | null; + params: { + cost_models_raw?: Record | null; + protocol_major_ver?: number | null; + max_tx_ex_mem?: number | string | null; + max_tx_ex_steps?: number | string | null; + [param: string]: unknown; + } | null; + slots: SlotConfig | null; + /** Where the transaction came from: its stored CBOR (exact) or Yaci Store's JSON (rebuilt). */ + source?: 'cbor' | 'store'; + /** The datums the transaction witnesses, when known exactly (from its CBOR); otherwise they are inferred. */ + witnessDatums?: { hash: string; datum: string }[]; + /** The ProposalProcedures as Plutus data CBOR, when known exactly (from its CBOR); otherwise encoded from JSON. */ + procedures?: string[]; +} + +export type Purpose = 'spend' | 'mint' | 'reward' | 'cert' | 'vote' | 'propose'; + +export interface RunPlan { + key: string; + purpose: Purpose; + index: number; + scriptHash: string; + language: PlutusVersion | null; + declared: { mem: bigint; steps: bigint } | null; + redeemer: string | null; + /** CBOR of the spent output's datum (spend runs): the first argument of a V1/V2 spending script. */ + datum: string | null; + /** + * Inputs whose datum is given by hash. julc ignores the datum of an input that also has a hash (it only adds + * datums given without one, under a hash it computes itself), so a spending script's datum would be missing. + * julc gets such datums inline (V2/V3) or without their hash (V1, which has no inline datums), and + * fix-context.ts puts the on-chain hash back. + */ + hashedDatums: { input: number; hash: string; datum: string }[]; + /** + * Datums the transaction likely witnesses (inputs' and outputs' datums known by hash), added to txInfoData by + * fix-context.ts under their on-chain hash (V2/V3). An extra entry is harmless; a missing one breaks lookups. + */ + witnessDatums: { hash: string; datum: string }[]; + /** CBOR of the transaction's ProposalProcedures, put into the context by fix-context.ts (julc builds them roughly). */ + proposals: string[]; + /** null when this run can't be rebuilt; `skip` says why. */ + request: TransactionRequest | null; + skip?: string; + /** What may differ from the ledger's context, shown with the result. */ + notes: string[]; + /** Rebuilt from the transaction's stored CBOR (exact) or from Yaci Store's JSON (the store has no CBOR). */ + source?: 'cbor' | 'store'; +} + +const hexCmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); +const byRef = (a: StoreUtxo, b: StoreUtxo) => hexCmp(a.tx_hash, b.tx_hash) || a.output_index - b.output_index; + +/** Purpose names vary by store and era: spend/spending, cert/certifying, reward/rewarding, vote/voting, … */ +export function purposeOf(tag: unknown): Purpose | null { + const t = String(tag ?? '').toLowerCase(); + if (t.startsWith('spend')) return 'spend'; + if (t.startsWith('mint')) return 'mint'; + if (t.startsWith('cert')) return 'cert'; + if (t.startsWith('reward') || t.startsWith('withdraw')) return 'reward'; + if (t.startsWith('vot')) return 'vote'; + if (t.startsWith('propos')) return 'propose'; + return null; +} + +export function plutusVersion(type: string | null | undefined): PlutusVersion | null { + const m = String(type ?? '').match(/plutus\D*v?_?([123])/i); + return m ? (`V${m[1]}` as PlutusVersion) : null; +} + +function redeemerOf(s: StoreScriptRun) { + if (!s.redeemer) return null; + if (typeof s.redeemer !== 'string') return s.redeemer; + try { + return JSON.parse(s.redeemer); + } catch { + return null; + } +} + +function scriptCbor(s: StoreScriptRun): string | null { + if (!s.script_content) return null; + try { + const c = JSON.parse(s.script_content); + return typeof c?.content === 'string' ? c.content : null; + } catch { + return /^[0-9a-f]+$/i.test(s.script_content) ? s.script_content : null; + } +} + +/** A store amount list as a julc value, in ledger order. */ +export function value(amount: StoreUtxo['amount']): MockValue { + const assets = amount + .filter((a) => a.unit !== 'lovelace') + .map((a) => ({ policyId: a.unit.slice(0, 56), tokenName: a.unit.slice(56), quantity: String(a.quantity) })) + .sort((a, b) => hexCmp(a.policyId, b.policyId) || hexCmp(a.tokenName, b.tokenName)); + return { lovelace: String(amount.find((a) => a.unit === 'lovelace')?.quantity ?? 0), assets }; +} + +const cbor = (value: string): DataInput => ({ format: 'cbor', value }); + +function datum(u: StoreUtxo, datums: Record, attach: boolean): MockDatum { + if (u.inline_datum) return { kind: 'inline', data: cbor(u.inline_datum) }; + if (u.data_hash) { + const known = attach ? datums[u.data_hash] : undefined; + return known ? { kind: 'hash', hash: u.data_hash, data: cbor(known) } : { kind: 'hash', hash: u.data_hash }; + } + return { kind: 'none' }; +} + +const isShelley = (address: string) => /^addr(_test)?1/.test(address); + + +/** Ledger order for credentials: script hashes before key hashes, then by hash. */ +function credentialKey(stakeAddress: string): string { + const d = bech32Decode(stakeAddress); + if (!d || d.bytes.length < 29) return '2' + stakeAddress; + const script = (d.bytes[0] & 0x10) !== 0; + const hash = [...d.bytes.slice(1)].map((b) => b.toString(16).padStart(2, '0')).join(''); + return (script ? '0' : '1') + hash; +} + +/** A reward account (hex, header byte first) as a julc credential. */ +function rewardCredential(hex: string): string { + const script = (parseInt(hex.slice(0, 2), 16) & 0x10) !== 0; + return (script ? 'script:' : 'key:') + hex.slice(2); +} + +const VOTERS: Record = { + CONSTITUTIONAL_COMMITTEE_HOT_SCRIPT_HASH: { voterType: 'committee', prefix: 'script:', order: 0 }, + CONSTITUTIONAL_COMMITTEE_HOT_KEY_HASH: { voterType: 'committee', prefix: 'key:', order: 1 }, + DREP_SCRIPT_HASH: { voterType: 'drep', prefix: 'script:', order: 2 }, + DREP_KEY_HASH: { voterType: 'drep', prefix: 'key:', order: 3 }, + STAKING_POOL_KEY_HASH: { voterType: 'pool', prefix: '', order: 4 } +}; + +function votes(list: StoreVote[]): MockVote[] { + return list + .filter((v) => VOTERS[v.voter_type]) + .sort((a, b) => VOTERS[a.voter_type].order - VOTERS[b.voter_type].order || hexCmp(a.voter_hash, b.voter_hash) || hexCmp(a.gov_action_tx_hash, b.gov_action_tx_hash) || a.gov_action_index - b.gov_action_index) + .map((v) => ({ + voterType: VOTERS[v.voter_type].voterType, + voter: VOTERS[v.voter_type].prefix + v.voter_hash, + actionTxId: v.gov_action_tx_hash, + actionIndex: BigInt(v.gov_action_index), + vote: v.vote.toLowerCase() as MockVote['vote'] + })); +} + +function certificates(list: StoreCert[]): { certs: MockCertificate[]; complete: boolean } { + const sorted = [...list].sort((a, b) => a.cert_index - b.cert_index); + const complete = sorted.every((c, i) => c.cert_index === i && c.type !== 'other'); + const certs = sorted.filter((c) => c.type !== 'other').map((c) => + c.type === 'delegation' + ? { type: 'delegStaking', credential: c.address, poolId: c.pool_id ?? null } + : { type: c.type === 'registration' ? 'regStaking' : 'unregStaking', credential: c.address } + ); + return { certs, complete }; +} + +export function buildRuns(input: ReplayInput): RunPlan[] { + // Yaci Store reports a missing TTL or validity start as 0. A real TTL of slot 0 is impossible, and a start of + // slot 0 is the same as none for any script, so 0 means "no bound". + const tx = { ...input.tx, ttl: input.tx.ttl || null, validity_interval_start: input.tx.validity_interval_start || null }; + const inputs = [...tx.inputs].sort(byRef); + const refs = [...(tx.reference_inputs ?? [])].sort(byRef); + const outputs = [...tx.outputs].sort((a, b) => a.output_index - b.output_index); + const runs = input.scripts.map((s) => ({ s, r: redeemerOf(s) })).filter((x) => x.r?.tag); + const proposals = [...(input.proposals ?? [])].sort((a, b) => a.index - b.index); + + // Datums the scripts endpoint resolved for spent inputs are as good as /scripts/datum lookups. + const datums = { ...input.datums }; + for (const { s } of runs) if (s.datum && s.datum_hash) datums[s.datum_hash] ??= s.datum; + + const pv = input.params?.protocol_major_ver ?? null; + const protocol = pv == null ? 11 : Math.min(11, Math.max(10, pv)); + const { certs, complete: certsComplete } = certificates(input.certificates); + const byron = [...inputs, ...refs, ...outputs].some((u) => !isShelley(u.address)); + + const common: Omit = { + inputs: inputs.map((u) => ({ txId: u.tx_hash, index: BigInt(u.output_index), address: { payment: u.address }, value: value(u.amount), datum: datum(u, datums, true), referenceScript: u.reference_script_hash || null })), + referenceInputs: refs.map((u) => ({ txId: u.tx_hash, index: BigInt(u.output_index), address: { payment: u.address }, value: value(u.amount), datum: datum(u, datums, false), referenceScript: u.reference_script_hash || null })), + // Output datums given by hash are left unresolved: the ledger only includes them when the tx witnesses them. + outputs: outputs.map((u) => ({ address: { payment: u.address }, value: value(u.amount), datum: datum(u, datums, false), referenceScript: u.reference_script_hash || null })), + fee: String(tx.fees), + mint: input.mints + .map((m) => ({ policyId: m.unit.slice(0, 56), tokenName: m.unit.slice(56), quantity: String(m.quantity) })) + .sort((a, b) => hexCmp(a.policyId, b.policyId) || hexCmp(a.tokenName, b.tokenName)), + certificates: certs, + withdrawals: [...input.withdrawals] + .sort((a, b) => hexCmp(credentialKey(a.address), credentialKey(b.address))) + .map((w) => ({ credential: w.address, amount: String(w.amount) })), + validRange: { + from: tx.validity_interval_start != null && input.slots ? slotToPosixMs(tx.validity_interval_start, input.slots) : null, + fromInclusive: true, + to: tx.ttl != null && input.slots ? slotToPosixMs(tx.ttl, input.slots) : null, + toInclusive: false + }, + signatories: [...(tx.required_signers ?? [])].sort(hexCmp), + datums: [], + txId: input.hash, + votes: votes(input.votes), + proposals: proposals.map((p) => { + const c = credentialKey(p.return_address); + return { + deposit: String(p.deposit), + returnCredential: (c.startsWith('0') ? 'script:' : 'key:') + c.slice(1), + actionType: mockActionType(p.type), + guardrail: p.details?.policyHash ?? null, + withdrawals: p.type === 'TREASURY_WITHDRAWALS_ACTION' ? Object.entries(p.details?.withdrawals ?? {}).map(([address, amount]) => ({ credential: rewardCredential(address), amount: String(amount) })) : null + }; + }) + }; + + // The real governance actions, which replace julc's in the context. + let procedures: string[] = []; + let proceduresError: string | null = null; + try { + procedures = input.procedures ?? proposals.map((p) => encodePlutusData(encodeProcedure(p.deposit, p.return_address, encodeAction(p.type, p.details, input.params)))); + } catch (e) { + proceduresError = (e as Error).message; + } + + // Datums known by hash go to julc inline (V2/V3) or without their hash (V1), and are restored afterwards. + const hashedDatums: RunPlan['hashedDatums'] = []; + const inlinedInputs = common.inputs.map((i, n) => { + if (i.datum.kind !== 'hash' || !('data' in i.datum) || !i.datum.data) return i; + hashedDatums.push({ input: n, hash: i.datum.hash, datum: i.datum.data.value }); + return { ...i, datum: { kind: 'inline' as const, data: i.datum.data } }; + }); + + const v1HashedDatums: RunPlan['hashedDatums'] = []; + const v1Inputs = common.inputs.map((i, n) => { + if (i.datum.kind !== 'hash' || !('data' in i.datum) || !i.datum.data) return i; + v1HashedDatums.push({ input: n, hash: i.datum.hash, datum: i.datum.data.value }); + return { ...i, datum: { kind: 'hash' as const, hash: '', data: i.datum.data } }; + }); + + const witnessDatums = + input.witnessDatums ?? + [...new Set([...inputs, ...outputs].map((u) => u.data_hash).filter((h): h is string => !!h && !!datums[h]))].map((hash) => ({ + hash, + datum: datums[hash] + })); + const source = input.source ?? 'store'; + + return runs.map(({ s, r }) => { + const tag = purposeOf(r.tag) ?? (String(r.tag) as Purpose); + const index = Number(r.index ?? 0); + // Voting and proposing scripts only exist in Plutus V3, so a missing type can be inferred. + const language = plutusVersion(s.type) ?? (tag === 'vote' || tag === 'propose' ? 'V3' : null); + const plan: RunPlan = { + key: `${tag}#${index}`, + purpose: tag, + index, + scriptHash: s.script_hash, + language, + declared: r.ex_units ? { mem: BigInt(r.ex_units.mem), steps: BigInt(r.ex_units.steps) } : null, + redeemer: r.data ?? null, + datum: null, + hashedDatums: [], + witnessDatums: [], + proposals: [], + request: null, + notes: [], + source + }; + const code = scriptCbor(s); + const fail = (why: string) => ((plan.skip = why), plan); + if (tag === 'propose' && !proposals[index]) return fail(`The store did not return proposal #${index} of this transaction.`); + if (tag === 'propose' && proceduresError) return fail(`The proposal couldn’t be encoded: ${proceduresError}`); + if (!language) return fail('Not a Plutus script.'); + if (!code) return fail('The store did not return the script code.'); + if (!r.data) return fail('The store did not return the redeemer data.'); + if (byron) return fail('The transaction has Byron-era (bootstrap) addresses, which julc can’t represent yet.'); + if ((tx.validity_interval_start != null || tx.ttl != null) && !input.slots) return fail('Slot times for this network are unknown, so the validity range can’t be rebuilt.'); + + let purpose: MockTransaction['purpose']; + switch (tag) { + case 'spend': + if (index >= inputs.length) return fail(`The transaction has no input #${index}.`); + purpose = { type: 'spend', index }; + plan.datum = inputs[index].inline_datum ?? (inputs[index].data_hash ? (datums[inputs[index].data_hash!] ?? null) : null); + break; + case 'mint': + purpose = { type: 'mint', target: s.script_hash }; + break; + case 'reward': + purpose = { type: 'reward', target: `script:${s.script_hash}` }; + break; + case 'cert': + if (!certsComplete || index >= certs.length) return fail('This certificate kind can’t be rebuilt from the store yet (julc#244).'); + purpose = { type: 'certify', index }; + break; + case 'vote': { + const voter = common.votes.find((v) => v.voter === `script:${s.script_hash}`); + if (!voter) return fail('The vote cast by this script was not found in the store.'); + purpose = { type: 'vote', target: voter.voter, voterType: voter.voterType }; + break; + } + case 'propose': + purpose = { type: 'propose', index }; + break; + default: + return fail(`Unknown purpose “${tag}”.`); + } + + if (runs.length > 1) plan.notes.push('Only this script’s redeemer is in the context (julc#244). A script that reads the other redeemers, such as a withdraw-zero validator, can fail here although it passed on chain.'); + if (input.certificates.length && !certsComplete) plan.notes.push('Some certificates in this transaction couldn’t be rebuilt, so the context lists only part of them.'); + if (input.paramsEpoch === null) plan.notes.push('The store has no parameters for this transaction’s epoch, so the latest epoch’s cost model was used.'); + if (language === 'V1') plan.notes.push('Plutus V1 contexts from julc 0.1.0-pre18 can differ from the ledger’s (V1/V2 translation fixes are on julc main), so some V1 runs fail here although they passed on chain.'); + if (pv != null && pv !== protocol) plan.notes.push(`Evaluated with protocol ${protocol} rules; the transaction ran under protocol ${pv}.`); + if (pv === 10 && language === 'V3') plan.notes.push('Under protocol 10 the ledger costed some builtins differently from julc, so CPU can differ slightly from the declared budget (memory is unaffected).'); + + const target: Target = { language: `Plutus${language}`, protocol }; + const costs = input.params?.cost_models_raw?.[`Plutus${language}`]; + if (!costs?.length) plan.notes.push('The store has no cost model for this epoch, so julc’s built-in one was used.'); + plan.hashedDatums = language === 'V1' ? v1HashedDatums : hashedDatums; + plan.witnessDatums = witnessDatums; + plan.proposals = language === 'V3' ? procedures : []; + plan.request = { + script: { script: code, language }, + transaction: { ...common, inputs: language === 'V1' ? v1Inputs : inlinedInputs, purpose, redeemer: cbor(r.data) }, + target, + costModel: costs?.length ? { target, parameters: costs.map((c) => BigInt(c)) } : null, + // The tx limits of that epoch (julc's defaults otherwise), so a script that ran near them still fits. + ...(input.params?.max_tx_ex_steps ? { maxCpu: BigInt(input.params.max_tx_ex_steps) } : {}), + ...(input.params?.max_tx_ex_mem ? { maxMem: BigInt(input.params.max_tx_ex_mem) } : {}) + }; + return plan; + }); +} diff --git a/applications/viewer/src/lib/julc/replay/fix-context.test.ts b/applications/viewer/src/lib/julc/replay/fix-context.test.ts new file mode 100644 index 00000000..ac6dfc18 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fix-context.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { decodePlutusData, encodePlutusData, type PlutusData } from '$lib/cbor'; +import { fixContext, ownRedeemer } from './fix-context'; + +const b = (hex: string): PlutusData => ({ bytes: hex }); +const i = (n: number): PlutusData => ({ int: String(n) }); +const c = (constructor: number, ...fields: PlutusData[]): PlutusData => ({ constructor, fields }); +const map = (...entries: [PlutusData, PlutusData][]): PlutusData => ({ map: entries.map(([k, v]) => ({ k, v })) }); +const list = (...items: PlutusData[]): PlutusData => ({ list: items }); + +const P1 = 'aa'.repeat(28); +const P2 = '11'.repeat(28); +// Policy P1 before P2: out of ledger order. Token names out of order too. +const unsorted = () => map([b(''), map([b(''), i(5)])], [b(P1), map([b('02'), i(1)], [b('01'), i(2)])], [b(P2), map([b('00'), i(3)])]); +const sorted = map([b(''), map([b(''), i(5)])], [b(P2), map([b('00'), i(3)])], [b(P1), map([b('01'), i(2)], [b('02'), i(1)])]); + +const ownEntry = { k: c(1, c(0, b('ab'.repeat(32)), i(0))), v: c(0) }; + +/** A V3-shaped context: TxInfo with 16 fields, one input with a value and an inline datum. */ +function v3Context(): string { + const txOut = (value: PlutusData) => c(0, c(0, c(1, b('cc'.repeat(28))), c(1)), value, c(2, i(7)), c(1)); + const info = [ + list(c(0, c(0, b('ab'.repeat(32)), i(0)), txOut(unsorted()))), // inputs + list(), // reference inputs + list(txOut(unsorted())), // outputs + i(200000), // fee (integer in V3) + unsorted(), // mint + list(), + map(), + c(0), + list(), + map([ownEntry.k, ownEntry.v]), // redeemers + map(), // data + b('ef'.repeat(32)), + map(), + list(), + c(1), + c(1) + ]; + return encodePlutusData(c(0, c(0, ...info), c(0), c(1))); +} + +const infoOf = (cbor: string) => (decodePlutusData(cbor) as { fields: { fields: PlutusData[] }[] }).fields[0].fields; + +describe('fixContext', () => { + it('puts values in ledger order without touching datums', () => { + const fixed = fixContext(v3Context(), 'V3', null)!; + expect(fixed.sortedValues).toBe(true); + const info = infoOf(fixed.cbor); + const inputOut = (info[0] as { list: { fields: { fields: PlutusData[] }[] }[] }).list[0].fields[1].fields; + expect(inputOut[1]).toEqual(sorted); + expect(inputOut[2]).toEqual(c(2, i(7))); + expect((info[2] as { list: { fields: PlutusData[] }[] }).list[0].fields[1]).toEqual(sorted); + expect(info[4]).toEqual(sorted); + expect(info[3]).toEqual(i(200000)); + }); + + it('returns null when nothing needs changing', () => { + const once = fixContext(v3Context(), 'V3', null)!.cbor; + expect(fixContext(once, 'V3', null)).toBeNull(); + }); + + it('reads the own redeemer and completes the redeemer map', () => { + const ctx = v3Context(); + expect(ownRedeemer(ctx)).toEqual(ownEntry); + const other = { k: c(2, c(1, b('dd'.repeat(28)))), v: i(1) }; + const fixed = fixContext(ctx, 'V3', [ownEntry, other])!; + expect(fixed.completedRedeemers).toBe(true); + expect(infoOf(fixed.cbor)[9]).toEqual({ map: [ownEntry, other] }); + }); + + it('restores datums given by hash and adds witnessed datums in hash order', () => { + const hashA = '99'.repeat(32); + const hashB = '01'.repeat(32); + const fixed = fixContext(v3Context(), 'V3', null, [{ input: 0, hash: hashA, datum: '07' }], [{ hash: hashB, datum: 'd87980' }, { hash: hashA, datum: '07' }])!; + expect(fixed.restoredDatums).toBe(true); + const info = infoOf(fixed.cbor); + const inputOut = (info[0] as { list: { fields: { fields: PlutusData[] }[] }[] }).list[0].fields[1].fields; + expect(inputOut[2]).toEqual(c(1, b(hashA))); + expect(info[10]).toEqual(map([b(hashB), c(0)], [b(hashA), i(7)])); + }); +}); + +describe('fixContext: proposals', () => { + // julc's placeholder: a parameter change without changes. + const placeholder = () => c(0, i(100), c(0, b('aa'.repeat(28))), c(0, c(1), map(), c(0, b('fa'.repeat(28))))); + const real = c(0, i(100), c(0, b('aa'.repeat(28))), c(0, c(1), map([i(11), list(i(1), i(10))]), c(0, b('fa'.repeat(28))))); + function proposingContext(): string { + const info: PlutusData[] = Array.from({ length: 16 }, () => list()); + info[3] = i(0); + info[9] = map([c(5, i(0), placeholder()), c(0)]); + info[13] = list(placeholder()); + return encodePlutusData(c(0, c(0, ...info), c(0), c(5, i(0), placeholder()))); + } + + it('puts the real procedure into the proposal list, the redeemer purpose and the ScriptInfo', () => { + const fixed = fixContext(proposingContext(), 'V3', null, [], [], [encodePlutusData(real)])!; + expect(fixed.restoredProposals).toBe(true); + const ctx = decodePlutusData(fixed.cbor) as { fields: PlutusData[] }; + const info = (ctx.fields[0] as { fields: PlutusData[] }).fields; + expect(info[13]).toEqual(list(real)); + expect((info[9] as { map: { k: PlutusData }[] }).map[0].k).toEqual(c(5, i(0), real)); + expect(ctx.fields[2]).toEqual(c(5, i(0), real)); + }); + + it('leaves V2 contexts and missing proposals alone', () => { + expect(fixContext(proposingContext(), 'V3', null, [], [], [])).toBeNull(); + expect(fixContext(proposingContext(), 'V3', null, [], [], [encodePlutusData(real), encodePlutusData(real)])?.cbor).toBe(encodePlutusData((() => { + const d = decodePlutusData(proposingContext()) as { fields: PlutusData[] }; + const info = (d.fields[0] as { fields: PlutusData[] }).fields; + (info[9] as { map: { k: PlutusData }[] }).map[0].k = c(5, i(0), real); + d.fields[2] = c(5, i(0), real); + return d as PlutusData; + })())); + }); +}); diff --git a/applications/viewer/src/lib/julc/replay/fix-context.ts b/applications/viewer/src/lib/julc/replay/fix-context.ts new file mode 100644 index 00000000..5d47d678 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fix-context.ts @@ -0,0 +1,197 @@ +import { decodePlutusData, encodePlutusData, type PlutusData } from '$lib/cbor'; +import type { PlutusVersion } from '../types'; +import type { Purpose } from './build'; + +/* + * Corrections to the ScriptContext julc builds from a MockTransaction, until julc does them itself (julc#244): + * + * - Redeemers: julc puts only the evaluated script's own redeemer into txInfoRedeemers, but scripts such as + * withdraw-zero validators look up the others. Every run's context does contain that run's own entry + * (purpose → redeemer), so the viewer collects them all, orders them as the ledger does and puts the complete + * map into each context. + * - Values: julc doesn't keep multi-asset values in ledger order (policy, then token name, ada first), so a script + * comparing an output with the value it expects can fail. Values are re-sorted where the ledger puts them: the + * inputs', reference inputs' and outputs' values, the mint, and the fee in V1/V2. User data (datums) is untouched. + * - Datums by hash: julc drops the datum of an input that also has a hash, so a spending script's datum would be + * missing. build.ts hands such datums to julc inline (V2/V3) or without their hash (V1; julc then hashes them + * itself), and here the inputs get their on-chain hash back. Datums of inputs and outputs known by hash go into + * txInfoData under their on-chain hashes, as the ledger has every datum the transaction witnesses. + * - V1 shape: julc 0.1.0-pre18 encodes V1's withdrawals and datums as maps, where Plutus V1 has lists of pairs + * (fixed on julc main, #233). They are converted, so V1 scripts can read them. + * - Proposals (V3): julc builds a parameter change without its changes, and only roughly the other actions, so a + * guardrail script would check an empty proposal. The procedures encoded by governance/action.ts replace julc's + * in txInfoProposalProcedures, in Proposing purposes of the redeemer map and in the ScriptInfo. + * + * TxInfo field positions: V1 [inputs, outputs, fee, mint, …] + * V2 [inputs, refInputs, outputs, fee, mint, certs, wdrl, range, signers, redeemers, …] + * V3 same as V2, but the fee is an integer. + */ + +type Constr = { constructor: number; fields: PlutusData[] }; +type Entry = { k: PlutusData; v: PlutusData }; + +const REDEEMERS = 9; +const PROPOSALS = 13; +const PROPOSING = 5; +const DATA: Record = { V1: 8, V2: 10, V3: 10 }; +const LAYOUT: Record = { + V1: { lists: [0, 1], values: [2, 3], redeemers: null }, + V2: { lists: [0, 1, 2], values: [3, 4], redeemers: REDEEMERS }, + V3: { lists: [0, 1, 2], values: [4], redeemers: REDEEMERS } +}; + +/** Ledger order of redeemers: by tag (spend, mint, cert, reward, vote, propose), then index. */ +export const TAG_ORDER: Record = { spend: 0, mint: 1, cert: 2, reward: 3, vote: 4, propose: 5 }; + +const isConstr = (d: PlutusData | undefined): d is Constr => !!d && typeof (d as Constr).constructor === 'number' && Array.isArray((d as Constr).fields); +const isMap = (d: PlutusData | undefined): d is { map: Entry[] } => !!d && Array.isArray((d as { map?: unknown }).map); +const bytesOf = (d: PlutusData) => ('bytes' in d ? d.bytes : ''); +const byKey = (a: Entry, b: Entry) => (bytesOf(a.k) < bytesOf(b.k) ? -1 : bytesOf(a.k) > bytesOf(b.k) ? 1 : 0); + +function txInfo(context: PlutusData): Constr | null { + return isConstr(context) && isConstr(context.fields[0]) ? context.fields[0] : null; +} + +/** Sort a Value (Map policy (Map name amount)) in place; returns whether anything moved. */ +function sortValue(value: PlutusData | undefined): boolean { + if (!isMap(value)) return false; + let moved = false; + const sortMap = (m: { map: Entry[] }) => { + const before = m.map.map((e) => bytesOf(e.k)).join(); + m.map.sort(byKey); + if (m.map.map((e) => bytesOf(e.k)).join() !== before) moved = true; + }; + sortMap(value); + for (const e of value.map) if (isMap(e.v)) sortMap(e.v); + return moved; +} + +/** The TxOut inside a list element: TxInInfo [outRef, TxOut] or a bare TxOut [address, value, …]. */ +function txOutOf(item: PlutusData): Constr | null { + if (!isConstr(item)) return null; + if (item.fields.length === 2 && isConstr(item.fields[1])) return item.fields[1]; + return item; +} + +/** The single redeemer entry julc put into a context, or null if it isn't there. */ +export function ownRedeemer(contextCbor: string): Entry | null { + try { + const map = txInfo(decodePlutusData(contextCbor))?.fields[REDEEMERS]; + return isMap(map) && map.map.length === 1 ? map.map[0] : null; + } catch { + return null; + } +} + +export interface FixedContext { + cbor: string; + sortedValues: boolean; + completedRedeemers: boolean; + restoredDatums: boolean; + v1Shape?: boolean; + restoredProposals?: boolean; +} + +/** Apply the corrections; null when the context isn't in the expected shape or nothing needed changing. */ +export function fixContext( + contextCbor: string, + language: PlutusVersion, + redeemers: Entry[] | null, + hashedDatums: { input: number; hash: string; datum: string }[] = [], + witnessDatums: { hash: string; datum: string }[] = [], + /** CBOR of the transaction's ProposalProcedures, in order (V3). */ + proposals: string[] = [] +): FixedContext | null { + try { + const context = decodePlutusData(contextCbor); + const info = txInfo(context); + if (!info) return null; + const layout = LAYOUT[language]; + let sortedValues = false; + for (const i of layout.lists) { + const list = info.fields[i]; + if (!list || !('list' in list)) continue; + for (const item of list.list) if (sortValue(txOutOf(item)?.fields[1])) sortedValues = true; + } + for (const i of layout.values) if (sortValue(info.fields[i])) sortedValues = true; + let completedRedeemers = false; + if (redeemers && layout.redeemers != null && isMap(info.fields[layout.redeemers]) && redeemers.length > (info.fields[layout.redeemers] as { map: Entry[] }).map.length) { + info.fields[layout.redeemers] = { map: redeemers }; + completedRedeemers = true; + } + let v1Shape = false; + if (language === 'V1') { + for (const i of [5, 8]) { + const f = info.fields[i]; + if (isMap(f)) { + info.fields[i] = { list: f.map.map((e) => ({ constructor: 0, fields: [e.k, e.v] })) }; + v1Shape = true; + } + } + } + let restoredDatums = false; + const inputs = info.fields[0]; + const data = info.fields[DATA[language]]; + if (language === 'V1' && data && 'list' in data) { + // V1: TxOut datum is Maybe DatumHash, txInfoData a list of (hash, datum) pairs. + if (inputs && 'list' in inputs) { + for (const h of hashedDatums) { + const out = txOutOf(inputs.list[h.input]); + const just = out?.fields[2]; + if (!out || !isConstr(just) || just.constructor !== 0) continue; + const julcHash = bytesOf(just.fields[0]); // the hash julc computed + out.fields[2] = { constructor: 0, fields: [{ bytes: h.hash }] }; // Just (on-chain hash) + for (const pair of data.list) if (isConstr(pair) && bytesOf(pair.fields[0]) === julcHash) pair.fields[0] = { bytes: h.hash }; + restoredDatums = true; + } + } + for (const w of witnessDatums) { + if (data.list.some((pair) => isConstr(pair) && bytesOf(pair.fields[0]) === w.hash)) continue; + data.list.push({ constructor: 0, fields: [{ bytes: w.hash }, decodePlutusData(w.datum)] }); + restoredDatums = true; + } + if (restoredDatums) data.list.sort((a, b) => (isConstr(a) && isConstr(b) ? byKey({ k: a.fields[0], v: a }, { k: b.fields[0], v: b }) : 0)); + } + if (language !== 'V1' && isMap(data)) { + if (inputs && 'list' in inputs) { + for (const h of hashedDatums) { + const out = txOutOf(inputs.list[h.input]); + if (!out || out.fields.length < 3) continue; + out.fields[2] = { constructor: 1, fields: [{ bytes: h.hash }] }; // OutputDatumHash + restoredDatums = true; + } + } + for (const w of witnessDatums) { + if (data.map.some((e) => bytesOf(e.k) === w.hash)) continue; + data.map.push({ k: { bytes: w.hash }, v: decodePlutusData(w.datum) }); + restoredDatums = true; + } + if (restoredDatums) data.map.sort(byKey); + } + let restoredProposals = false; + if (language === 'V3' && proposals.length) { + const procedures = proposals.map(decodePlutusData); + const list = info.fields[PROPOSALS]; + if (list && 'list' in list && list.list.length === procedures.length) { + info.fields[PROPOSALS] = { list: procedures }; + restoredProposals = true; + } + // Proposing [index, procedure], as a redeemer key and as the ScriptInfo. + const restore = (purpose: PlutusData | undefined) => { + if (!isConstr(purpose) || purpose.constructor !== PROPOSING || purpose.fields.length !== 2) return; + const i = Number((purpose.fields[0] as { int?: string }).int ?? -1); + if (procedures[i]) { + purpose.fields[1] = procedures[i]; + restoredProposals = true; + } + }; + const redeemerMap = info.fields[REDEEMERS]; + if (isMap(redeemerMap)) for (const e of redeemerMap.map) restore(e.k); + if (isConstr(context)) restore(context.fields[2]); + } + if (!sortedValues && !completedRedeemers && !restoredDatums && !v1Shape && !restoredProposals) return null; + return { cbor: encodePlutusData(context), sortedValues, completedRedeemers, restoredDatums, v1Shape, restoredProposals }; + } catch { + return null; + } +} diff --git a/applications/viewer/src/lib/julc/replay/fixture.ts b/applications/viewer/src/lib/julc/replay/fixture.ts new file mode 100644 index 00000000..6d6a8246 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fixture.ts @@ -0,0 +1,34 @@ +import type { ForkEdits } from './fork'; +import type { RunResult } from './run'; + +/* + * A script run as a test fixture: everything needed to run it again elsewhere (any Plutus VM, a unit test, a + * benchmark), in one JSON file. Data is CBOR hex; integers that can exceed 2^53 are strings. + */ +export function fixtureOf(txHash: string, result: RunResult, fork: { edits: ForkEdits; epoch: number | null } | null = null) { + const { plan, evaluation, args } = result; + const req = plan.request!; + return { + format: 'yaci-viewer/script-run@1', + source: { tx: txHash, purpose: plan.purpose, index: plan.index, ...(fork ? { fork } : {}) }, + script: { hash: plan.scriptHash, language: plan.language, cbor: req.script.script, ...(req.script.params?.length ? { params: req.script.params.map((p) => p.value) } : {}) }, + target: req.target, + costModel: req.costModel?.parameters.map(String) ?? null, + limits: { cpu: req.maxCpu?.toString() ?? null, mem: req.maxMem?.toString() ?? null }, + /** What the script is applied to, in order: V3 the ScriptContext; V1/V2 (datum,) redeemer, ScriptContext. */ + args: (args ?? []).map((a) => a.value), + scriptContext: evaluation?.scriptContextCbor ?? null, + expected: evaluation ? { status: evaluation.status, accepted: evaluation.accepted, cpu: evaluation.cpu.toString(), mem: evaluation.mem.toString(), traces: evaluation.traces ?? [] } : null, + declared: plan.declared ? { cpu: plan.declared.steps.toString(), mem: plan.declared.mem.toString() } : null, + notes: plan.notes, + /** cbor: replayed from the transaction's stored CBOR; store: rebuilt from Yaci Store's JSON. */ + transactionSource: plan.source ?? 'store' + }; +} + +export function downloadJson(name: string, value: unknown) { + const url = URL.createObjectURL(new Blob([JSON.stringify(value, (_, v) => (typeof v === 'bigint' ? v.toString() : v), 2)], { type: 'application/json' })); + const a = Object.assign(document.createElement('a'), { href: url, download: name }); + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} diff --git a/applications/viewer/src/lib/julc/replay/fixtures/mainnet-mint.json b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-mint.json new file mode 100644 index 00000000..c0ddca07 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-mint.json @@ -0,0 +1 @@ +{"hash":"436d47e611aadb692a0d47e3467aa517143316251d59b24826c2d1b641049fd1","epoch":662,"tx":{"fees":382925,"ttl":0,"validity_interval_start":0,"required_signers":["d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604"],"inputs":[{"tx_hash":"5cbbc0f0ddd999614243ee4ebd28b005a239c6cfe10e80bb1ce1a060aff96924","output_index":3,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"2473940"}],"data_hash":null,"inline_datum":"d8799fd8799f581cd4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604ff581c233d38f89af8b4d439cedddf071b1b2458b68a1ec01e11cc49091e4ed8799fd8799fd8799f581cf8a2125d27c30abc547b6dc064dec0615a2ccdd88cde4227e4617d98ffd8799f581ccf9eaf5aa407e4b84e1dfe67fe5eae41c1c5a17de12335354f391120ff1a00138800d8799fd8799fd8799f581cd4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604ffd8799fd8799fd8799f581ccf9eaf5aa407e4b84e1dfe67fe5eae41c1c5a17de12335354f391120ffffffffd87980ffd87a9f9f581cf66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b6988044695553441a0f57abd8ff9f581c1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e344555534443781a0f332ff7ffff43d87980ff1a00061a801a001e8480ffff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604"}]},{"bytes":"233d38f89af8b4d439cedddf071b1b2458b68a1ec01e11cc49091e4e"},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"f8a2125d27c30abc547b6dc064dec0615a2ccdd88cde4227e4617d98"}]},{"constructor":0,"fields":[{"bytes":"cf9eaf5aa407e4b84e1dfe67fe5eae41c1c5a17de12335354f391120"}]},{"int":1280000},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"cf9eaf5aa407e4b84e1dfe67fe5eae41c1c5a17de12335354f391120"}]}]}]}]},{"constructor":0,"fields":[]}]},{"constructor":1,"fields":[{"list":[{"bytes":"f66d78b4a3cb3d37afa0ec36461e51ecbde00f26c8f0a68f94b69880"},{"bytes":"69555344"},{"int":257403864}]},{"list":[{"bytes":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34"},{"bytes":"5553444378"},{"int":255012855}]}]},{"bytes":"d87980"}]},{"int":400000},{"int":2000000}]}]}},{"tx_hash":"5cbbc0f0ddd999614243ee4ebd28b005a239c6cfe10e80bb1ce1a060aff96924","output_index":4,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"84359797"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"5cbbc0f0ddd999614243ee4ebd28b005a239c6cfe10e80bb1ce1a060aff96924","output_index":5,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"4773658"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f75636865722033","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1psq3ymfvg6648u4gvcukzaxf3mtu2c53qmdhst","quantity":"1"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f7563686572203136","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1k937vwg9h9sc7u2djq6hjn6glkl4ysh92cs9wz","quantity":"1"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f7563686572203235","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1eayektglg44svmtv3c72nrs7642au2eu86nwnr","quantity":"1"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f7563686572203939","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1qkqpnqsq78cclf4gs3r5pam4w4gm0rkw4hu825","quantity":"1"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"5184927239"},{"unit":"0c3b71e6468510341c813626cfa4d008d0b0c50d6ffcea188878e8650014df1059542d534e454b5f504552505f4c50","policy_id":"0c3b71e6468510341c813626cfa4d008d0b0c50d6ffcea188878e865","asset_name":"asset1rtmw62r53qm86ns586sgsgedszu9muf7u7lcmr","quantity":"2048"},{"unit":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e345553444378","policy_id":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","asset_name":"USDCx","quantity":"3002753"},{"unit":"279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f534e454b","policy_id":"279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f","asset_name":"SNEK","quantity":"50467"},{"unit":"2da97f55d49be13dabc8450a2eabab0412f3075a03f7519d32d469250014df1050554c5345","policy_id":"2da97f55d49be13dabc8450a2eabab0412f3075a03f7519d32d46925","asset_name":"asset1c2e6g2f754j5y47s86r0x3hgez2v9ehd7j7fd7","quantity":"9156162367"},{"unit":"7246a32c1a6dad60da9139f06da8f1a99927f1e1d465f6e71b762e13504247","policy_id":"7246a32c1a6dad60da9139f06da8f1a99927f1e1d465f6e71b762e13","asset_name":"PBG","quantity":"2"},{"unit":"90dab650fe3c852487ea75418e060ebccb98cb84f23e1eedf8e6008e0014df1050756c736550542d4d696e737761704e49474854414441","policy_id":"90dab650fe3c852487ea75418e060ebccb98cb84f23e1eedf8e6008e","asset_name":"asset1sk79xh9f62q0dxk74h04aa958fg574khpw753c","quantity":"2528685536"},{"unit":"93fc7ce799be81dc8dc02189ea79f50a629972c195ad3a2c37a4743d0014df1050554c5345","policy_id":"93fc7ce799be81dc8dc02189ea79f50a629972c195ad3a2c37a4743d","asset_name":"asset18mpl9hmgylxh26whmmh3u36sce8hx8srdeurcg","quantity":"9999998"},{"unit":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af746170707930322d30353430","policy_id":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af","asset_name":"tappy02-0540","quantity":"1"},{"unit":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c363434636339386333666238636639303933616235336366","policy_id":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c","asset_name":"644cc98c3fb8cf9093ab53cf","quantity":"1"},{"unit":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c363435653234393032623630316531336137393834333234","policy_id":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c","asset_name":"645e24902b601e13a7984324","quantity":"1"},{"unit":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c363437343566326237323865353530633431333837613137","policy_id":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c","asset_name":"64745f2b728e550c41387a17","quantity":"1"},{"unit":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a68656c696f736c616e67","policy_id":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a","asset_name":"helioslang","quantity":"1"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ce74c52975908a612d5ce68327040d449aae99f8b463bb6de046a1b23c5713169","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"asset1jqqsmmv0dn8x3xwkt2pkdwztxhrzlfcmft3eqv","quantity":"36396844"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"5e777cbb99bc830ff9a41887d8a3b0ff07d8d86b82ad33805727ae7a227bf29d","output_index":1,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"2000000"},{"unit":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e345553444378","policy_id":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","asset_name":"USDCx","quantity":"256287523"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"843d8322bdd867e98df7c2edbf45f796bb62cb19dbdfba7015c6a720e84f82dc","output_index":3,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"69232252"}],"data_hash":null,"inline_datum":"00","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"int":0}},{"tx_hash":"849411c3f5b2da1bf903ee8113c0a934c57b82d6ed9243e4fdbc0d8d2c019a64","output_index":3,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"44048950"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"849411c3f5b2da1bf903ee8113c0a934c57b82d6ed9243e4fdbc0d8d2c019a64","output_index":4,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"44048949"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"849411c3f5b2da1bf903ee8113c0a934c57b82d6ed9243e4fdbc0d8d2c019a64","output_index":5,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"5000000"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"94c1c47568b470ef5123b638ce625f8ac640b22ba9ea5943762471560cad6d7c","output_index":3,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"70148102"}],"data_hash":null,"inline_datum":"00","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"int":0}},{"tx_hash":"963cd05b847bc5003a568cd6c8c7229c4c74a3157b0290bc48f49b306eb15765","output_index":1,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"2000000"},{"unit":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e345553444378","policy_id":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","asset_name":"USDCx","quantity":"2199496412"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null}],"outputs":[{"tx_hash":"436d47e611aadb692a0d47e3467aa517143316251d59b24826c2d1b641049fd1","output_index":0,"address":"addr1q82vx5jca42vx5n7qpu05vu8r6vxmvgc2nvuknmhnky7vpx0n6h44fq8ujuyu807vll9atjpc8z6zl0pyv6n2neezysq047pyc","stake_address":"stake1u88eat665sr7fwzwrhlx0lj74equr3dp0hsjxdf4fuu3zgqzy9x8p","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"327702723"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f75636865722033","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1psq3ymfvg6648u4gvcukzaxf3mtu2c53qmdhst","quantity":"1"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f7563686572203136","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1k937vwg9h9sc7u2djq6hjn6glkl4ysh92cs9wz","quantity":"1"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f7563686572203235","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1eayektglg44svmtv3c72nrs7642au2eu86nwnr","quantity":"1"},{"unit":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa000de14050424720766f7563686572203939","policy_id":"03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfa","asset_name":"asset1qkqpnqsq78cclf4gs3r5pam4w4gm0rkw4hu825","quantity":"1"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"5184927239"},{"unit":"0c3b71e6468510341c813626cfa4d008d0b0c50d6ffcea188878e8650014df1059542d534e454b5f504552505f4c50","policy_id":"0c3b71e6468510341c813626cfa4d008d0b0c50d6ffcea188878e865","asset_name":"asset1rtmw62r53qm86ns586sgsgedszu9muf7u7lcmr","quantity":"2048"},{"unit":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e345553444378","policy_id":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","asset_name":"USDCx","quantity":"3006688"},{"unit":"279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f534e454b","policy_id":"279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f","asset_name":"SNEK","quantity":"50467"},{"unit":"2da97f55d49be13dabc8450a2eabab0412f3075a03f7519d32d469250014df1050554c5345","policy_id":"2da97f55d49be13dabc8450a2eabab0412f3075a03f7519d32d46925","asset_name":"asset1c2e6g2f754j5y47s86r0x3hgez2v9ehd7j7fd7","quantity":"9156162367"},{"unit":"7246a32c1a6dad60da9139f06da8f1a99927f1e1d465f6e71b762e13504247","policy_id":"7246a32c1a6dad60da9139f06da8f1a99927f1e1d465f6e71b762e13","asset_name":"PBG","quantity":"2"},{"unit":"90dab650fe3c852487ea75418e060ebccb98cb84f23e1eedf8e6008e0014df1050756c736550542d4d696e737761704e49474854414441","policy_id":"90dab650fe3c852487ea75418e060ebccb98cb84f23e1eedf8e6008e","asset_name":"asset1sk79xh9f62q0dxk74h04aa958fg574khpw753c","quantity":"2528685536"},{"unit":"93fc7ce799be81dc8dc02189ea79f50a629972c195ad3a2c37a4743d0014df1050554c5345","policy_id":"93fc7ce799be81dc8dc02189ea79f50a629972c195ad3a2c37a4743d","asset_name":"asset18mpl9hmgylxh26whmmh3u36sce8hx8srdeurcg","quantity":"9999998"},{"unit":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af746170707930322d30353430","policy_id":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af","asset_name":"tappy02-0540","quantity":"1"},{"unit":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c363434636339386333666238636639303933616235336366","policy_id":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c","asset_name":"644cc98c3fb8cf9093ab53cf","quantity":"1"},{"unit":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c363435653234393032623630316531336137393834333234","policy_id":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c","asset_name":"645e24902b601e13a7984324","quantity":"1"},{"unit":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c363437343566326237323865353530633431333837613137","policy_id":"c0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05c","asset_name":"64745f2b728e550c41387a17","quantity":"1"},{"unit":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a68656c696f736c616e67","policy_id":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a","asset_name":"helioslang","quantity":"1"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ce74c52975908a612d5ce68327040d449aae99f8b463bb6de046a1b23c5713169","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"asset1jqqsmmv0dn8x3xwkt2pkdwztxhrzlfcmft3eqv","quantity":"36396844"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null}],"reference_inputs":[{"tx_hash":"76e8e5a5eb9ae1562b7c6afb042037f76c4b097c9857e95c9285a07c5e612ffa","output_index":0,"address":"addr1w8fv8anv93c4fk0shyf7u0xekz39hwmaz5me20tuxmq8apsgzpnyy","stake_address":null,"amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"2801500"},{"unit":"49b5b45b3b600416fee318dc34d9660ebb3a759fbd2db683fef641ff555344435850726f746f636f6c506172616d6574657273","policy_id":"49b5b45b3b600416fee318dc34d9660ebb3a759fbd2db683fef641ff","asset_name":"USDCXProtocolParameters","quantity":"1"}],"data_hash":null,"inline_datum":"d8799f581cd74de93a7e4940462c4509f59c712889422506f8b63dcfd0c266dc7bd8799fd8799f581c7a9aee035f6eff7dd56b4078b497a63853438dffe51611a36d6eee61ffd8799fd8799fd8799f581cc8ab0b5104240cfa85aed8265cef4982cd9dcbc5b0c444ddc689dadaffffffff581c1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e349f5821022d4b0769063a340abaae3647366096e8b2a8b249f8bb2686d8418860592b2bb8ff9f582102135645a11944958ef8b314b315bc5c7789830c5bc2397c3b96a60ddb90215bf05821032e2f0a4027a75749b7fcf3274672ba3ca83895520cd39b25b2ea3698fe281871ff582102f31c6dd06280b5c32fda1e1333b729be99871b29d62c64becd6f188a6d52cb7f9f581ca0208ec5dafd6479c0faf053a2a10b91d4c131f590ee2d26cee46c62581ca623af1e2fa73daef1dd29e1097ffaa27a9155fbf8b729ad55f2abf9ff01d87980581ca3d5052864638828bc3fbc0d5b9a4223e233620d7911a5376ed4b65801ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"bytes":"d74de93a7e4940462c4509f59c712889422506f8b63dcfd0c266dc7b"},{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"7a9aee035f6eff7dd56b4078b497a63853438dffe51611a36d6eee61"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"c8ab0b5104240cfa85aed8265cef4982cd9dcbc5b0c444ddc689dada"}]}]}]}]},{"bytes":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34"},{"list":[{"bytes":"022d4b0769063a340abaae3647366096e8b2a8b249f8bb2686d8418860592b2bb8"}]},{"list":[{"bytes":"02135645a11944958ef8b314b315bc5c7789830c5bc2397c3b96a60ddb90215bf0"},{"bytes":"032e2f0a4027a75749b7fcf3274672ba3ca83895520cd39b25b2ea3698fe281871"}]},{"bytes":"02f31c6dd06280b5c32fda1e1333b729be99871b29d62c64becd6f188a6d52cb7f"},{"list":[{"bytes":"a0208ec5dafd6479c0faf053a2a10b91d4c131f590ee2d26cee46c62"},{"bytes":"a623af1e2fa73daef1dd29e1097ffaa27a9155fbf8b729ad55f2abf9"}]},{"int":1},{"constructor":0,"fields":[]},{"bytes":"a3d5052864638828bc3fbc0d5b9a4223e233620d7911a5376ed4b658"},{"int":1}]}},{"tx_hash":"86c9f9a54f11627a3b4c0b9577d0a5074eb366b08555192c1d6213239e2854e1","output_index":0,"address":"addr1wxduzjkjzn7lu807ms9gqkeh5xzgc6mwf7yewryt0vkwgzqzlkc89","stake_address":null,"amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"2926490"},{"unit":"9bc14ad214fdfe1dfedc0a805b37a1848c6b6e4f89970c8b7b2ce4081f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","policy_id":"9bc14ad214fdfe1dfedc0a805b37a1848c6b6e4f89970c8b7b2ce408","asset_name":"asset1wzt576hzgs6t488p6scr4aq2uzvshkpmdafql0","quantity":"1"}],"data_hash":null,"inline_datum":null,"script_ref":"82035901955901920101003323232323232322232325333007323330083300622533300900114a02a66601466ebcc02cc034004cdd2a400466ae80c034c038dd5191919299980699b87480100084c04000458c040008c030004dd518069806180618071baa300d300c300e37546601044a6660160022c264a66601a646601844a66601e002294054ccc040cdd7980898098008068a5113002301200100137566020601e60226ea8c040c03cc044dd5000880089801980780118078009bac300d0044bd700a5113002300c0013756601660146014601460146014004002940c8c8c94ccc028cdc3a40000042944528180680118048009baa300a30093009002149858c020c028dd5180480098049baa001232300223300323300423300523300623300723300823300923300a23300b23300c23300d23300e23300f233010233011233012233012012001001001001001001001001001001001001001001001001001230022330020020015573eae6955ceaba25742aae793011e581c49b5b45b3b600416fee318dc34d9660ebb3a759fbd2db683fef641ff0001","reference_script_hash":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","inline_datum_json":null},{"tx_hash":"d722c14b023979e92aae51978d9ead239ef3f94dc7131939d6a78a10c06eefc7","output_index":0,"address":"addr1wxduzjkjzn7lu807ms9gqkeh5xzgc6mwf7yewryt0vkwgzqzlkc89","stake_address":null,"amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"26450470"},{"unit":"9bc14ad214fdfe1dfedc0a805b37a1848c6b6e4f89970c8b7b2ce408d74de93a7e4940462c4509f59c712889422506f8b63dcfd0c266dc7b","policy_id":"9bc14ad214fdfe1dfedc0a805b37a1848c6b6e4f89970c8b7b2ce408","asset_name":"asset1x5hudqrkqmp9es0wvuw2s004hgeh2u9m9c4ym5","quantity":"1"}],"data_hash":null,"inline_datum":null,"script_ref":"82035916e75916e40101003323232323232323232323232322232323232323232323232323232323232533301b3370e90020010a99980d99980d980a180e802a504a22646464a66603c66e1d20000021323232323232323232323232323232323232323232323232533303633032303800e3038001153330363302e32323253330393370e9000001099ba5480092f5c02a66607266e1d20020021613374a90001981b1ba937286eccc0ec0052f5c0607800460700026ea8c0e0008c0e002454ccc0d8cc0b8c8c8c94ccc0e4cdc3a4000004266e952000330363374a90001981b181d800a5eb812f5c0266e9520024bd70181e001181c0009baa303830390093038303900113253330373302f3039303a00300113253330383033303b00113232533303a3035303d001132533303b3375e6078006002264a66607866ebcc0f40053010645555344437800132323232533304032323253330433303f3045002304500113303b304530460023045304600114a0608c6ea8008c114dd5182181818210018a9998201981c182118218008050a99982019191919299982219b87480000084c8c8c94ccc11ccdc3a40000042944528182500118230009baa004153330443370e90010010991919299982399b87480080084cdd7982480218248008a50304a002304600137540082646464a66608e66e1d200400213375e609200860920022940c128008c118004dd5002182380118218009baa30430023374a900025eb804c94ccc104c0f0c1100044c8c94ccc10cc0f8c11800454ccc10ccdd79822001004899299982219baf30450014c010645555344437800132323232533304833040304a304b00101213253330493044304c00113232533304b3046304e001132533304c3375e609a00600226464a66609c66ebcc13c0040084c94ccc13ccdd79829801000899299982818009829182a1baa305200b132325333052304d30553055305500113253330533375e00260aa0042646464646464a6660b2660a6660a6660a6660a6660a6660a6660a6660a6660a6660a666e1cdd6982d81aa400466e2120000023330593371e6eb8c16c0c12201200000000000000000000000000000000000000000000000000000000000000000004a0944ccc164cdc79bae305b02f489200000000000000000000000000000000000000000000000000000000000000000004a0944cdc39bad305b033482a27004cdc79bae305b032378e66e28dd700f9bae4c0106455553444378003371000200466e24004dd6982d81719b89375a60b660b808400266e24cdc08010009bad305d01e3370e0060042930b1bad305c015375a60b2064a6660ac66ebc01000454ccc158ccc158c13cc160c16409d282511533305690029250a510a99982b19baf006374e660a6008660a660b004e660a6980103d87a80004bd70099299982b0008b0a99982b9829182d000899299982c19baf305900101e132533305800116153330593054305c001132533305a3375e60b6002980106455553444378001375a60bc0022c60b60022c6eacc17000458c16400458dd5982c0230b0b0b099299982b99b90375c00a002264a6660b066e40008dd70008c80248c94ccc168cdd78051ba733057008300133057305c305d02b4bd700a99982d19baf002374e6002660ae006660ae98103d87a80004bd7009919299982e182b982f800899191919299983019baf003026153330603375e00202a264a6660c00022c2a6660c260b860c8002264a6660c466ebcc18c0053001064555534443780013253330620011615333063305e306600113253330643375e60ca0020302a6660c866ebcc1a00040584dd698340018b0b18328008b1bab306600416306300116375660c80082c2a6660c066ebc00409854ccc180cdd780180a89929998300008b0a999830982e1832000899299983119baf30630014c01064555534443780013253330620011615333063305e306600113253330643375e60ca0020302a6660c866ebcc1a00040584dd698340018b0b18328008b1bab306600616306300116375660c80042c2c60c000260c000660bc00260bc0042c60bc0026eacc1701285858c158012c16305902816375c00260ae052a6660a866e20005200018010991919299982b99827982c982d000810899299982c1829982d80089919299982d182a982e8008a99982d19baf305b00200f132533305b3375e60b800201e2a6660b666ebcc17c00403454ccc16cc030c174c17cdd5182e803899299982e182b982f982f982f9bac0011800800858c8c8c94ccc178cdc3a4008004260c00022c60c200460ba0026ea8c174014585858c1700045858dd5982e800982d0008b182d1bab305900216305900130580013058375460ac6660a4444a6660ae66e212006002133003337020049003182d182d182d000899982a911299982d19b88480000084cc00ccdc08012400460ba00220020040020020206eb4c154c1580c858c15009058dd6000991919299982999b87480100084c15400458c158008c148004dd518290048b182599ba548008cc1300112f5c02c6ea1200216304f00337529110016304d034163756609c00260960022c60966eacc12800858c128004c124004c124dd51823800999821111299982399b88480180084cc00ccdc08012400c609460946094002266608a444a66609466e212000002133003337020049001182680088008010009bad304602300816304500116163756608c00260860022c60866eacc108008585858c108004c104004c104dd5181f80099981d111299981f99b88480180084cc00ccdc08012400c608460846084002266607a444a66608466e212000002133003337020049001182280088008010009bad303e01c00b16303d00216303c028163756607a00260740022c60746eacc0e401058cdd2a400497ae016161630393754606e006606e002606c002606c6ea8c0d0004ccc0bc8894ccc0d0cdc42400c00426600666e0400920063037303730370011333032222533303733710900000109980199b8100248008c0e80044004008004dd698198091bac30330223332223253330343370e00890000a99981a199bb4375c606c006002666e31200048200040085261615333033003161332253330363370e00490000a99981b199bb4375c6070002006666e31200048200040105261615333035001161332253330383370e00490000a99981c199bb4375c607400200a666e312000482000401852616153330370011613322533303a3370e00490000a99981d199bb4375c607800200e666e312000482000402052616153330390011613322533303c3370e00490000a99981e199bb4375c607c002012666e3120004820004028526161533303b00116133303a222533303f3370e00490000a99981f999bb4375c6082002018666e3120004820004034526161533303e00116133003337020049001182100099b8100248008c0fc004cdc080124004607a00266e040092002303b001337020049001181c80099b8100448008c0dc00cde38081bad30320123758606403a6eb8c0c8c0cc038c0c8004ccc0b08894ccc0c00046400aeb2c132325333033533303332533303300114a0264a66606800229404c8c94ccc0d80045280991981919819198191981919baf30390013752911003033303b0023375e6072006607404266ebcc0f0004dd4240046066607600860720026eacc0e8004c0dc004c0dc004dd5981a800899817981a981b9baa30350023374a900119818181a80e25eb805280c80249a6600b00132323253330363370e90020010981c0008b181c801181a8009baa30353036001181b00184cc014010c0d800cc0d4004c0d4dd51819981a181a9baa30330018011bac3030021303030303758605e6060002605e002605c002605a0026058002605600260540026052002605000264a66604a66e1cdc680119b80001483800c54ccc094cdc7999b8c480012008002489045a2e0acd001323233024375066f2928999b8c48021200800433024375066f2928999b8c48041204000433024375066f2928999b8c481412008004330243752666e31205848100010cc0914ccc09ccdc380124004266e952000330243752666e3120a001480e00112f5c02a66604e66e1c009200413374a9001198121ba9333718905000a407000897ae016330243752666e3120d80148100010cc090dd4999b8c4826009204000433024375066f2928999b8c48360092040004330243752666e3120980348100010cc090dd3a99981399b87371a002905f00899191919198141ba933371890002408000a660506ea0cde525133371890202400400a66050a66605666e1c009200013374a900125eb8054ccc0accdc380124004266e952000330283752666e312044481000152f5c02c66050a66605666e1c005200013374a900125eb8054ccc0accdc3800a4004266e952000330283374a9000198141ba9333718904300a407000a97ae04bd700a99981599b87001480104cdd2a40006605066e952002330283752666e31208601480e00152f5c097ae0164bd7019bca4a200466f2928801199b8c482100520020023337189021240040022c97ae033371890700180100199bca4a2666e31209801480200085858cde5251333718906c01a40100026eb8c094004dd6181200298120009811800981100098108008a99980f19b87480080084c8cc074894ccc08000454ccc084cdc399b80375a60460066eb4c08cc090c090c090c090c090c090c090c090c090c090c090dd61811981200199b8148000c94ccc0840045854ccc088c074c0940044c94ccc08ccdd79812000981280889929998118008b0a999812180f9813800899299981299baf30260014c106455553444378001375a60520022c604c0022c6eacc09c00458c09000458dd598118088a4c2c2a6660426603a6046604a6ea8c08cc094dd51811981218129baa30230013374a90011980f181180525eb80584c008c090004dd6181080918109919299981019b87371a00466e00005209001153330203371e666e3120004802000922104070afbc20013301d375066f2928999b8c4802120400023301d375066f2928999b8c4812120400023301d374e6464a66604466e1cdc680119b80001482a01454ccc088cdc7999b8c48001200800248904ca85def70013301f375066f2928999b8c4802120080023301f375066f2928999b8c4804120080023301f375066f2928999b8c4806120080023301f3752666e31202048100008cc07cdd4999b8c4818120400023301f3752666e3120a00148100008cc07cdd4999b8c483800520400023301f3752666e3120a00248100008cc07cdd4999b8c483800920400023301f3752666e3120a00348100008cc07cdd4999b8c483800d20400023301f375066f2928999b8c482801120400023301f3752666e3120e00448100008cc07cdd39919299981219b87371a00466e0000520e001153330243371e666e312000480200092201046b20f62a0013233022375066f2928999b8c48021200800333022375066f2928999b8c480412008003330223752666e3120184810000ccc0894ccc094cdc3800a4004266e952000330223752666e312060480e000d2f5c02a66604a66e1c005200413374a9001198111ba933371890302407000697ae016330223752666e312098014810000ccc088dd4999b8c483800400800d2f5c066f2928999b8c481612008002161633794944ccdc6241b0029004000999b8c482a0140040092f5c02c2c66f2928999b8c4828015200800133371890480080080125eb805858cde5251333718904400a40100026eb8c0800044c94ccc0780045854ccc07cc068c0880044c8c94ccc084cdd7981100080109929998108008b0a999811180e981280089919299981219baf302500100213253330253375e60520040022646464646464a6660566002605a605e6ea8c0b400c4c94ccc0b0c09cc0bc0044c8c94ccc0b8c0a4c0c400454ccc0b8cdd79817801007899299981799baf303000100d13253330303375e606800400226464a666064660546068606a0020042646464a66606a60606070002264a66606c66ebc004c0e004454ccc0d8cdd7981c001181c181c80809999981a11111299981d0008b0991919299981f180a182018211baa3040003132533303f303a3042001132325333041303c3044001153330413375e6084004044264a66608466ebcc10c00408054ccc108cdd798230008098a9998211981d182218228028090991919299982298201824000899299982319b9000e001153330463371e01e0022a66608c66608c607e6090004941288a99982319b88480080344cccc040dd7182400180099b8100d48008c1240304c94ccc11ccdd798248020008a99982399b90002375c0022930b0b18240100b0b0b1bae304700316304700130460013758646464a66608a66e1d20040021304700116304800230440013754608800a2c2c2c60860022c2c6eacc110004c10400458c104dd598200010b1820000981f800981f9baa303d001375c60700066eb8004dd6809181c8078b0b181b8018b181b800981b0009bac32323253330353370e90020010981b8008b181c001181a0009baa30340011630340083374a900125eb8058dd4240042c60600022c2c6eacc0c4004c0b800458c0b8dd598168010b181319ba548008cc09c02d2f5c0605800260586ea8c0a8004dd6181480c181480099981191129998138008c8015d65826464a666054a66605464a66605400229404c94ccc0ac0045280991929998168008a50132330293302933029330293375e606000201a6054606400466ebcc0c000c040cdd798198009ba848008c0a8c0c8010c0c0004dd59818800981700098170009bab302c001133026302c302e3754605800466e9520023302700b4bd700a5019004934cc016002646464a66605a66e1d20040021302f001163030002302c00137546058605a002302d0030998028021816801981600098161baa302a302b302c375460540030023758604e0302c604c00e2c604a0046ea5220100163756604a0022c604400460420102c6eacc080038c084008c074004dd5180e8078b0a99980d99b874801800854ccc06cc8c8c94ccc078cdc380124004266e24dd698100049991199980f1111299981180188008a9998118010800899191919299981419b8f0020011333008302b007302b0063370000a90010a99981419b900020011333008302b0070060051333008007302b006005375c0046eb8008c09c00cc09800c008005200037586040604260426042604201c6eb0c08002854ccc078cdc38012400c266ebc005300107d87a9fd87a80ff0014a0603e60406042004603a0026ea8c074c0780045261616301e002301a001375460346036018603400260320026030002602e602e602e002602c002602a0026028602a6ea8c8c8c94ccc050cdc3a40080042602c0022c602e00460260026ea8c04cc050c050c054dd51809980a180a9baa3300f225333012001161325333014323301322533301600114a02a66602e66ebcc060c06400403852889801180d0008009bab3016301730183754602c602e60306ea800440044c00cc05c008c054004dd61809801980998098009809000980880098089baa300f002300f001300f375400244646464a66601a66e1d2000002132323253330103370e9000001099baf3012004301200114a06026004601e0026ea80104c8c8c94ccc040cdc3a40000042940528980980118078009baa0043010002300c0013754004466e1cc024dd5000a4004446660100040029415d191191919299980499b87480000084c8c8c94ccc030cdc3a4000004266ebcc038010c038004528180780118058009baa0041323232533300c3370e90000010a5013375e601c008601c002601e00460160026ea8010c030008c020004dd50012ba0232300223300323300423300523300623300723300823300923300a23300b23300c23300d23300e23300f233010233011233012233012012001001001001001001001001001001001001001001001001001230022330020020015573eae6955ceaba15744aae793011e581c49b5b45b3b600416fee318dc34d9660ebb3a759fbd2db683fef641ff0001","reference_script_hash":"d74de93a7e4940462c4509f59c712889422506f8b63dcfd0c266dc7b","inline_datum_json":null}],"invalid":false},"scripts":[{"tx_hash":"436d47e611aadb692a0d47e3467aa517143316251d59b24826c2d1b641049fd1","script_hash":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34","script_content":"{\"type\":\"PlutusScriptV3\",\"content\":\"590195590192010100332323…trimmed\"}","type":"PLUTUS_V3","redeemer":{"tag":"mint","index":0,"data":"d87980","ex_units":{"mem":36633,"steps":14843322}},"datum":null,"datum_hash":null},{"tx_hash":"436d47e611aadb692a0d47e3467aa517143316251d59b24826c2d1b641049fd1","script_hash":"d74de93a7e4940462c4509f59c712889422506f8b63dcfd0c266dc7b","script_content":"{\"type\":\"PlutusScriptV3\",\"content\":\"5916e75916e4010100332323…trimmed\"}","type":"PLUTUS_V3","redeemer":{"tag":"reward","index":0,"data":"d87a9f5f5840070afbc2000000000000000000000000000000000000000000000000000000000190cf67000000000000000000000000000000000000000000000000000000005840001e8480000001c4ca85def700000001000000000000000000000000000000000000000077777777dcc4d5a8b6e418fd04d8997ef11000ee00000000000000005840000000002222222d7164433c4c09b0b0d809a9b52c04c205000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000584000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000866e992217e0bfb8371f9ad32a53dcc47d1aa04e0000000000000000584000000000b1f653c30f59172dad00879ff90f0d1e7b2d3949000000000000000000000000866e992217e0bfb8371f9ad32a53dcc47d1aa04e00000000000000005840000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009241b620fec7887bd49527fb5840ed32400a46d8b1160b168de929810f1d3f19a3fe95adea0c000000706b20f62a00000001000027149ea9794d33dbcef3f77718e903816e877ab2577f4d7ee6535840638f1f60fc671dd600000001d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e6040000000000000000000000000000000000000000000000004c000000000000000000000000ffff","ex_units":{"mem":241069,"steps":91006141}},"datum":null,"datum_hash":null}],"withdrawals":[{"block_number":14078655,"block_time":1792211479,"address":"stake178t5m6f60ey5q33vg5ylt8r39zy5yfgxlzmrmn7scfndc7cwh3zx9","tx_hash":"436d47e611aadb692a0d47e3467aa517143316251d59b24826c2d1b641049fd1","tx_index":2,"amount":0,"epoch":662,"slot":200645188}],"mints":[{"unit":"1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e345553444378","quantity":-2455780000}],"votes":[],"stakes":[],"delegations":[],"params":{"cost_models_raw":{"PlutusV1":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,228465,122,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,228465,122,0,1,1,90434,519,0,1,74433,32,85848,228465,122,0,1,1,85848,228465,122,0,1,1,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,53384111,14333,10,955506,213312,0,2,43053543,10,43574283,26308,10,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,1293828,28716,63,0,1,1006041,43623,251,0,1,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21],"PlutusV2":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,228465,122,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,228465,122,0,1,1,90434,519,0,1,74433,32,85848,228465,122,0,1,1,85848,228465,122,0,1,1,955506,213312,0,2,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,43053543,10,53384111,14333,10,43574283,26308,10,1293828,28716,63,0,1,1006041,43623,251,0,1,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21],"PlutusV3":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,123203,7305,-900,1716,960,57,85848,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,123203,7305,-900,1716,960,57,85848,0,1,90434,519,0,1,74433,32,85848,123203,7305,-900,1716,960,57,85848,0,1,1,85848,123203,7305,-900,1716,960,57,85848,0,1,955506,213312,0,2,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,43053543,10,53384111,14333,10,43574283,26308,10,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,1293828,28716,63,0,1,1006041,43623,251,0,1,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21]},"protocol_major_ver":11},"genesis":{"active_slots_coefficient":0.05,"update_quorum":5,"max_lovelace_supply":"45000000000000000","network_magic":764824073,"epoch_length":432000,"system_start":1506203091,"slots_per_kes_period":129600,"slot_length":1,"max_kes_evolutions":62,"security_param":2160}} diff --git a/applications/viewer/src/lib/julc/replay/fixtures/mainnet-proposal.json b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-proposal.json new file mode 100644 index 00000000..73caac0b --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-proposal.json @@ -0,0 +1 @@ +{"hash": "ab474223d40e2e3540555364be27e161a809c33651408f43d84acff10c0ba306", "epoch": 646, "tx": {"fees": 343087, "ttl": 0, "validity_interval_start": 0, "required_signers": null, "inputs": [{"tx_hash": "36d07f29c67822b9a75cef6d899ec66843d87922077bdac8a3ee508b7f8b5615", "output_index": 0, "address": "addr1qyrk4kfujrna4lp2635zztajufcp2kw36vhjt67ur7kd4knpr9phs055mc302vmh3pq4y85h7a6c3ls9g3l5vjlpjtysqfydlq", "stake_address": "stake1u9s3jsmc862dugh4xdmcss2jr6tlwavglcz5gl6xf0se9jg9t5kr3", "amount": [{"unit": "lovelace", "policy_id": null, "asset_name": "lovelace", "quantity": "99999817955"}], "data_hash": null, "inline_datum": null, "script_ref": null, "reference_script_hash": null, "inline_datum_json": null}, {"tx_hash": "b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf", "output_index": 0, "address": "addr1qyrk4kfujrna4lp2635zztajufcp2kw36vhjt67ur7kd4knpr9phs055mc302vmh3pq4y85h7a6c3ls9g3l5vjlpjtysqfydlq", "stake_address": "stake1u9s3jsmc862dugh4xdmcss2jr6tlwavglcz5gl6xf0se9jg9t5kr3", "amount": [{"unit": "lovelace", "policy_id": null, "asset_name": "lovelace", "quantity": "3440118"}], "data_hash": null, "inline_datum": null, "script_ref": null, "reference_script_hash": null, "inline_datum_json": null}], "outputs": [{"tx_hash": "ab474223d40e2e3540555364be27e161a809c33651408f43d84acff10c0ba306", "output_index": 0, "address": "addr1qyrk4kfujrna4lp2635zztajufcp2kw36vhjt67ur7kd4knpr9phs055mc302vmh3pq4y85h7a6c3ls9g3l5vjlpjtysqfydlq", "stake_address": "stake1u9s3jsmc862dugh4xdmcss2jr6tlwavglcz5gl6xf0se9jg9t5kr3", "amount": [{"unit": "lovelace", "policy_id": null, "asset_name": "lovelace", "quantity": "2914986"}], "data_hash": null, "inline_datum": null, "script_ref": null, "reference_script_hash": null, "inline_datum_json": null}], "reference_inputs": [], "invalid": false}, "scripts": [{"tx_hash": "ab474223d40e2e3540555364be27e161a809c33651408f43d84acff10c0ba306", "script_hash": null, "script_content": null, "type": null, "redeemer": {"tag": "proposing", "index": 0, "data": "a0", "ex_units": {"mem": 689355, "steps": 142209724}}, "datum": null, "datum_hash": null}], "proposals": [{"block_number": 13745171, "block_time": 1785432061, "tx_hash": "ab474223d40e2e3540555364be27e161a809c33651408f43d84acff10c0ba306", "index": 0, "tx_index": 5, "slot": 193865770, "deposit": 100000000000, "return_address": "stake1uyvjdz9rxsfsmv44rtk75k2rqyqskrga96dgdfrqjvjjpwsefcjnp", "type": "PARAMETER_CHANGE_ACTION", "details": {"type": "PARAMETER_CHANGE_ACTION", "policyHash": "fa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64", "govActionId": {"transactionId": "c75bb221606687aa858ec89c7a15c88e9c17054f2e045ae31ecc8a9687cd206e", "gov_action_index": 0}, "protocolParamUpdate": {"maxTxExMem": 17500000, "minPoolCost": 75000000, "maxTxExSteps": 10000000000, "maxBlockExMem": 77500000, "maxBlockExSteps": 20000000000}}, "anchor_url": "ipfs://bafkreifc5ct5fpeu4ygx6k4cqzbyzny7xfmuljywgn7jge3zkf2tidacym", "anchor_hash": "f200d39e39a3db74bdaa4f04865b4da38b5403322984c6fdcaf09bd44cacf868", "epoch": 646, "gov_action_id": "gov_action14dr5yg75pchr2sz42djtuflpvx5qnsek29qg7s7cft8lzrqt5vrqqtqntpk"}], "withdrawals": [], "mints": [], "votes": [], "stakes": [], "delegations": [], "params": {"cost_models_raw": {"PlutusV3": [100788, 420, 1, 1, 1000, 173, 0, 1, 1000, 59957, 4, 1, 11183, 32, 201305, 8356, 4, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 16000, 100, 100, 100, 16000, 100, 94375, 32, 132994, 32, 61462, 4, 72010, 178, 0, 1, 22151, 32, 91189, 769, 4, 2, 85848, 123203, 7305, -900, 1716, 960, 57, 85848, 0, 1, 1, 1000, 42921, 4, 2, 30623, 28755, 75, 1, 898148, 27279, 1, 51775, 558, 1, 39184, 1000, 60594, 1, 141895, 32, 83150, 32, 15299, 32, 76049, 1, 13169, 4, 22100, 10, 28999, 74, 1, 28999, 74, 1, 43285, 552, 1, 44749, 541, 1, 33852, 32, 68246, 32, 72362, 32, 7243, 32, 7391, 32, 11546, 32, 85848, 123203, 7305, -900, 1716, 960, 57, 85848, 0, 1, 90434, 519, 0, 1, 74433, 32, 85848, 123203, 7305, -900, 1716, 960, 57, 85848, 0, 1, 1, 85848, 123203, 7305, -900, 1716, 960, 57, 85848, 0, 1, 955506, 213312, 0, 2, 270652, 22588, 4, 1457325, 64566, 4, 20467, 1, 4, 0, 141992, 32, 100788, 420, 1, 1, 81663, 32, 59498, 32, 20142, 32, 24588, 32, 20744, 32, 25933, 32, 24623, 32, 43053543, 10, 53384111, 14333, 10, 43574283, 26308, 10, 16000, 100, 16000, 100, 962335, 18, 2780678, 6, 442008, 1, 52538055, 3756, 18, 267929, 18, 76433006, 8868, 18, 52948122, 18, 1995836, 36, 3227919, 12, 901022, 1, 166917843, 4307, 36, 284546, 36, 158221314, 26549, 36, 74698472, 36, 333849714, 1, 254006273, 72, 2174038, 72, 2261318, 64571, 4, 207616, 8310, 4, 1293828, 28716, 63, 0, 1, 1006041, 43623, 251, 0, 1, 100181, 726, 719, 0, 1, 100181, 726, 719, 0, 1, 100181, 726, 719, 0, 1, 107878, 680, 0, 1, 95336, 1, 281145, 18848, 0, 1, 180194, 159, 1, 1, 158519, 8942, 0, 1, 159378, 8813, 0, 1, 107490, 3298, 1, 106057, 655, 1, 1964219, 24520, 3, 607153, 231697, 53144, 0, 1, 116711, 1957, 4, 231883, 10, 1000, 24838, 7, 1, 232010, 32, 321837444, 25087669, 18, 617887431, 67302824, 36, 356924, 18413, 45, 21, 219951, 9444, 1, 1000, 172116, 183150, 6, 24, 21, 213283, 618401, 1998, 28258, 1, 1000, 38159, 2, 22, 1000, 95933, 1, 1, 11, 1000, 277577, 12, 21]}, "protocol_major_ver": 11, "max_tx_ex_mem": "16500000", "max_tx_ex_steps": "10000000000", "gov_action_deposit": 100000000000}, "genesis": {"network_magic": 764824073, "system_start": 1506203091, "slot_length": 1, "epoch_length": 432000}} \ No newline at end of file diff --git a/applications/viewer/src/lib/julc/replay/fixtures/mainnet-tx-cbor.json b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-tx-cbor.json new file mode 100644 index 00000000..68015ecd --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-tx-cbor.json @@ -0,0 +1,61 @@ +{ + "source": "Mainnet transaction CBOR from Koios (api.koios.rest), for decoder tests", + "txs": { + "a5554f4aa71cac02c10f6f523779ad210c5eb384b681789b15eb60014c48ad2e": { + "note": "V1 spend (2022, Alonzo-era encoding)", + "cbor": "84a90082825820eb2449ec467d731005a32b14dcfeaacd839a3ecd0056133afd1d18eb447ed0ea038258206619822c03dc8d1c61a9c2dc9e56d18cc7a8c1d5a7f01fd9e099440562e4812f01018383581d71a813c75283bac225b19f1d0c0a914d89cac6db9a9ce967d105d353ec821a0019c812a1581c29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6a1434d494e1b00000001c216201b58204492a520fdf4d6881e22aba00aec95e6113c4c91a1df7c7ce17385f44fd4b8fb82583901bfd926718c8e1c4cb06704b4c9c5273d45d7b16198b9b1abe4a60b46045cd40a656566d683da4214ec684523b60d1d91e4b0f8fbd7860506821a0014851ea1581c29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6a1434d494e1aa58ebc7582583901bfd926718c8e1c4cb06704b4c9c5273d45d7b16198b9b1abe4a60b46045cd40a656566d683da4214ec684523b60d1d91e4b0f8fbd78605061a05d6660b021a00063739031a03d35044075820e2546581747a6bd6bf587cd50711db70b5b64037f3cabff59cc0247dfefa4ec1081a03d326140b5820a20c313d4d84bc5abaa4009280338996a44d2c1fff908cf14ea3096cae4e9b790d8182582073f6d46e761be7c8cce1be44b2aefba531c651a62d8a313914db7ccd6fb4e43a000e81581cbfd926718c8e1c4cb06704b4c9c5273d45d7b16198b9b1abe4a60b46a400818258205d10d9c15b3aca05eed496153a7688ba448821b2523700e00651b3439f2651465840b868edb1f5d9a8fc8b274a2ea549ed14ab2662b097130cd15e23b26f73be3ca867f2bff530a70f0deeaaf52218514be95b768dacdffaa2e1c3b47083fbe6070e0381590a77590a7401000032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323222232325335533553335734608c00204a2a666ae68c1140040b41084c8c8c8d54cd4c06800c840045888c8d4c0c0c08c00488cc068cccc074c10410805d4cd4c090c0f40188400458c0a001ccc068ccc03c060c08cc0fc018c0a801ccc0694cd4c06c488ccc090894cd4cc068c0d8008c0d8c0a401c4cc0100080044004004c0a00240b4880dcc0e0c04cc09400d4cd4c0700148400458c03c01cc068d5400c0f140084c8c8c8d54cd4c06800c840045888c8c8d4c0c4c09000888c8d54cd4c074488ccc098894cd4cc070c0e000940204cc0100080044004004c0a802c58134888c8c8c8c8cc08ccccc098c12812c0814cd4c0b4c11803c8400458c0c4040cc08cccc060084c0b0c05c03c00ccc08ccc088cdc119b81001353500f22222001223233053223350014800088d4008894ccd5cd19b8f00200913007001130060030073052223350014800088d4008894ccd5cd19b8f00200710011300600333702609001e00466e08004cdc09a801911a801112999a801108008b0b00119811982080418208021a80711020980a8069818006980c299a981009119981491299a9980f981d8012805899802001000880080098168070b028180a1813002098181811800a99a980e002908008b1807803980d1aa80181e2801081d0b1aab9d3754006264a666ae68c110d55ce8008991981c1aba1001357426ae88004d55cf0008209baa0012350012222200522235002223500222350052235002225333533300d00b0060021533500115335005133300c00b003007031133300c00b003007031133300c00b003007222350022235003225333533300800700400215335003100102402c0242223253335003215333500321533350052130044984c00d261533350042130044984c00d260110221533350042130034984c009261533350032130034984c00926010153335002202300f02215333500221533350042130034984c009261533350032130034984c009260100211533350032130024984c005261533350022130024984c0052600f253335002215333500421533350042133300800700200116161602215333500321533350032133300700600200116161602102223500122222222007233300275c66004eb9d69a8008129119118011bab00130352233335573e00240584646607466064600c6aae74004c014d55cf00098021aba200335742004064464a666ae68c0e8d55ce80089919191919191919191909199998008030028020018012999ab9a304335573a00a2646606e6eb8d5d08009bae357426ae88004d55cf0028201bad3574200a604e6ae84014dd69aba1005375a6ae84014dd51aba1357440026ae88004d5d10009aba200135573c00206e6ea800488d400888d400c88c8cd40148cd401094ccd5cd19b8f002001003028202f2335004202f25333573466e3c00800400c0a054cd400c854cd400884cd40088cd40088cd40088cd40088cc03400800480c88cd400880c88cc0340080048880c8888cd401080c88894ccd5cd19b8700600315333573466e1c0140084cc0c80100040b40b409854cd4004809807488ccd5cd19b8f0020010260271233333333001023225333573466e1c00800401c54ccd5cd19b8900200101a01922333573466e2000800409809c00888ccd5cd19b8900200102702622333573466e2000800409c098894ccd5cd19b8900200110011002225333573466e240080044008400488ccd5cd19b89002001024025225335002100101d233500102e0211102222233550033302622333500602b0020013500402a300200133022225335002011100101e23232333355333573466e1d2006001132122223004005357426aae7800854ccd5cd18198008990911118010029aba135573c0042a666ae68c0c80044c848888c004014d5d09aab9e002153335734606200226424444600600a6ae84d55cf0010171010901090109199aa98130139981291299a80110801880081091919299aa999ab9a3371e6a0040566a0020562666ae68cdc39a8010161a80081601181200e8980f00180e980380198031a800814998071192999ab9a303435573a002264660506ae84004d5d09aba200135573c0020626ea8004d4c018d40100a08888888888028d55ce8009baa3500102623253335734605e6aae740044c8c8cc0914ccd5cd18189aab9d0011375c6ae84d55cf0008171bad357426ae88008dd51aba100135573c0020586ea80048c94ccd5cd18171aab9d00113232323232323232323232323232323232323212333333333300101301100f00d00b009007005003002357426ae88008d5d08009aba2002357420026ae88008d5d08009aba2002357420026ae88008d5d08009aba2002357420026ae88008d5d08009aba2002357420026ae88008d5d08009aab9e00102b375400244604e44a66a00220064426600c004600800246603c6603c601e0020326603c201c03246a0024466a00402a4038460066a0020404660080046a0024444444444012464a666ae68c0a0d55ce800899191998131aba1002357420026ae84d5d10009aba200135573c00204a6ea80048cc009d71a800911111100f119118011bac001301f2233335573e002402c46604660086ae84008c00cd5d100100e1192999ab9a302535573a002264646466036a666ae68c0a0d55ce8010991980e198043ad35742002600e6ae84d5d10009aab9e0020255333573460506aae740044c8cc070cc021d69aba10013007357426ae88004d55cf0008129baa357426ae88008dd51aba100135573c0020446ea8d4004888880a08c94ccd5cd18128008020a999ab9a302400100c02135573a6ea800488c8c94ccd5cd18138008038a999ab9a3026001130093004357426aae7800854ccd5cd18128008030111aab9d0013754002201c2201222002444002424446006008460046a00201a464a666ae68c070d55ce80089919191980918031aba100353335734603e002264646014a666ae68c0880044c8c8c8c8c8488ccc00401801000cdd69aba1357440046eb4d5d08009aba2002375a6ae84004d55cf0010a999ab9a30210011300a3008357426aae78008078d55ce8009baa357426aae7800854ccd5cd181000080380e1aab9d00137546ae84d5d10009aab9e001019375400246464a666ae68c0740044c8488c00400cdd71aba135573c0042a666ae68c0700044c014dd71aba135573c0040326aae74004dd50008803909118010019110011198019999aa98050059980491199a8020070008011a8010069119b8000148008cc038010005200048010cc01c888c00cc008004800488ccd5cd19b87002001003004100810082350012200422200323500100e221233001003002300622112225335001135003008221333500500930040023335530070080050040011200122002220013002222533500110022213500222330073330080020060010033001222253350011002221350022253335734601e002266601000e00c006266601000e6601466601600e00400200c0064646002002446600660040040024424466002008006444246660020080060042a66ae71240103505431001622153350011002221622222004370e90001b8748008dc3a40081049fd8799f1b00000181433932d81b000001822af75ed8d8799fd8799f581cbfd926718c8e1c4cb06704b4c9c5273d45d7b16198b9b1abe4a60b46ffd8799fd8799fd8799f581c045cd40a656566d683da4214ec684523b60d1d91e4b0f8fbd7860506ffffffff1b0000000267a4dc90d8799f581c29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6434d494effffff0581840001d87980821a0010f0fe1a1b162fb6f5a11902a2a1636d73678178194d696e737761703a2057697468647261772056657374696e67" + }, + "90692ee7d8e876d239b2b1c27a058e30abdd420f0759ce0fadd2f7708acc9508": { + "note": "V1 mint (2022)", + "cbor": "84a900848258200b74d18fad061af7550d1fbcf35193103b2139735891ce06ce295880e13b68da018258206ab3fcabbe3888bb2c3510446dfd8150a4dab648b589be740b8a72f57d2acf7f0382582093f71e402bb1c984fd8825508683727f859f2453ccd7129e5f5f95b0240811b7008258209946514f500d0a22fec82be0db5bfae6ea213552b790c0545992b84c684ebf42000d8182582093f71e402bb1c984fd8825508683727f859f2453ccd7129e5f5f95b0240811b700018482581d612ebacbaf16275a0184357de07c81d4a895f29343c235974f3b4c1d7d1a0598c4a582581d612ebacbaf16275a0184357de07c81d4a895f29343c235974f3b4c1d7d821a00150bd0a1581c2f2e0404310c106e2a260e8eb5a7e43f00cff42c667489d30e179816a14d3136363035363639303633383701825839013923d1a54393f0ae847a71bc394f72f647f5cf96169d85d6f85cab4e3ce9a54bf679c1e0654af298d76e1277d6746c5d5b790401bbf193d4821a001f7239a1581ce4214b7cce62ac6fbba385d164df48e157eae5863521b4b67ca71d86a158206aa2153e1ae896a95539c9d62f76cedcdabdcdf144e564b8955f609d660cf6a21b000000022b0b90f783583911e1317b152faac13426e6a83e06ff88a4d62cce3c1634ab0a5ec1330952563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c2821b00001171a49315e1a3581c0be55d262b29f564998ff81efe21bdc0022621c12f15af08d0f2ddb1a158206aa2153e1ae896a95539c9d62f76cedcdabdcdf144e564b8955f609d660cf6a201581c13aa2accf2e1561723aa26871e071fdf32c867cff7e7d50ad470d62fa1474d494e5357415001581c29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6a1434d494e1b0000f82ed62c023f5820f34f6608e24639bd884146bfeb6801278c84c9fe41b886db11c3766461ae6209021a000d9674031a03d329fc0e81581c2ebacbaf16275a0184357de07c81d4a895f29343c235974f3b4c1d7d09a1581ce4214b7cce62ac6fbba385d164df48e157eae5863521b4b67ca71d86a158206aa2153e1ae896a95539c9d62f76cedcdabdcdf144e564b8955f609d660cf6a21b000000022b0b90f70b5820937c7ae7cf34d847646a394bffab16bfa03e8ad0dc729913c7827d7874d5e3c3075820a84b2d4a77a77d7bcca692f281677f9010cb08d32342e85f70e8d0b487859931a40081825820d2beea31ad3bbc59b0d50a84a9395e9f55dca859ac7a78da7b0c1ea946fdd36358404fac78d402dc59dd1ecd9c63881cd328bcef815b9f3d581db0c2d2e9c96c9e75abf35fcff1e2ecaab5ea68580d05f084e30932eb8e8bcb69e99b5cf25df2da0e038359014f59014c01000032323232323232322223232325333009300e30070021323233533300b3370e9000180480109118011bae30100031225001232533300d3300e22533301300114a02a66601e66ebcc04800400c5288980118070009bac3010300c300c300c300c300c300c300c007149858dd48008b18060009baa300c300b3754601860166ea80184ccccc0288894ccc04000440084c8c94ccc038cd4ccc038c04cc030008488c008dd718098018912800919b8f0014891ce1317b152faac13426e6a83e06ff88a4d62cce3c1634ab0a5ec133090014a0266008444a00226600a446004602600a601a00626600a008601a006601e0026ea8c03cc038dd5180798071baa300f300b300e3754601e00244a0026eb0c03000c92616300a001375400660106ea8c024c020dd5000aab9d5744ae688c8c0088cc0080080048c0088cc00800800555cf2ba15573e6e1d200201591e1b591e1801000032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232222323232533533355333573460cc0042646424446600200a0086eb4d5d09aba25002375a6ae85400454ccd5cd1832801099091118010021bad357426aae7800c54ccd5cd1832001099190911198018028021bad357426ae894008c0ccd5d0a80082e1119191a827911111a80391191111aa99a9824806108008b1119191a9a9a80103102d91191919191919191aa99a982a89119982d91299a99820a8071a80103409980200100088008008020b03c9111919191919191919191919191982a299a8050a99aa8100999ab9a3094013303a307908101330820106700b06706e0060063305433355307908101305c05a305953353502b2233500206e2071210011635014222222207a33054353501422208e0122350012322533355333500a2153335004215333500c2130054984c011261533350052130054984c0112603c04f15333500b2130044984c00d261533350042130044984c00d2603b153335003205003a04f153335003215333500b2130044984c00d261533350042130044984c00d2603b04e15333500a2130034984c009261533350032130034984c0092603a153350010700820108201070253335002215333500a21533350042133303a03b00200116161604e15333500921533350032133303903a00200116161604d04e33054330500153306f00b02733054330533306301602548008cc150cc14ccc18c02c0952002330543305333063001010002330543333084012222533500315335002135001222223305d3305c0053306c01402b3305d3305c0043306c01402a3305d3305c00300c3305d3305c0023306c0140193305c001304800d0910122153350041622153350071622153350081622133300c003001323232323232323533307d0050070272222225335330743305100248000cc1440052000161333335003235500b2222223501d222223501b22235051222223232323232323232323253353308b013308b013306e00848000cc1b802920003308b013306d00201e3308b0153353306e00a00315335330870100e07013308b013308a013309a010110703370066e0402800c060cc22804cc2680404411c0084cc22c04cc22804cc268040441c0060cc22c04cc22804cc2680404411c008cc22804cdc08050019984d008088070a99a998370041a802055008a99a99843808078380998458099845009984d0080883819b80337020106a008154020306611402661340202208e00426611602661140266134020220e003066116026611402661340202208e004661140266e04020d40102a804cc2680404403c4cc22c04cc22804cc2680404411c008cc22804cc268040441c0060cc22c04cc20c04048070cc2080404006c4c8c8c8ccccc2c80400c008cdc019b800180050013370002e002a66a0082605466e0800c00840594cd400c4cccc0a005406406005c520003370002e00866e0005cd40102a80458c27c04028d40082a404d40042a404d54cd54ccd5cd19b8900148000278044c94ccd5cd19b890014800027c044c94ccd5cd19b8900148000280044c28404ccc2ec0400c0080054ccd5cd19b8900400610041006350020ad0121001160b901350010b0015333573466e2400400c54ccd5cd19b880010031330a8010023370666e080080400444cc2a0040080104cc2a004cdc199b820040110100043370666e080040380414cd4cc1fc01c1a04cdc0998490081100399b80011010133092010220073370666e080040300354cd4cc1f40101984cdc0998480081000219b8000f00e133090010200042235500c2222223501e222223501c222350522222232323232323232323253353308b013306e00748000cc22c04cc1b4008074cc22c04cc22c04cc22804cc2680404011c008cc22804cc268040401c005ccc22c04cc20c0404406ccc2080403c0684c8c8c8c8ccccc2cc0400c008cdc019b800180060013370002e002a66a00a2605666e0800c00840594cd40104cccc0a405406406005c52000350020b301350010b60153353330690870100e01e1330ae013370002c00e02a26615c0202c66e0005401c58c27c04024cdc199b820010123370200400266604400600266e0ccdc0981219b803370400400466e08cdc119b82337049004241941e90680780200198600080199b824801120ca0f350050a70130be01001350030a60153353308001001069133702661260204600266e000440404cc24c0408c004d400428804d54cd4ccc1801f80140544ccc2d0040140340304ccc2d004010030034888d400c88ccc2e40401401000c88d54030888888d407888888d4070888d414888888c8c8c8c94cd4cc21804cc1a4009200033086013306800101833086015335330820101906b13308501330950100b0193370000202426610c026610a026612a020160320026610a026612a020160d60246610c02660fc01802c660fa01402a2a66a6660c8104020120322666661540266e00044008cdc08080008078070068999998550099b810110013370002000401e01c01a2c66603e6a00614c026a00614a02002a66a66100020020d2266e04cc24c0408c004cdc0008808099849808118009a800851009aa99a99983003f00280a899985a00802806806099985a008020060069111a8019119985c80802802001911aa8061111111a80f111111a80e1111a82911111191919191919299a998440099835802240006611002660d80020086611002a66a66108020360da266110026610e026612e0201a03666e00068050cc21c04cc25c04034014cdc08020008a99a99842008028368998440099843809984b8080680d80d19843809984b8080680299b8033702008002028266110026610e026612e0201a03603466110026610e026612e0201a00a66e04010004cc21c04cc25c040341b4050cc22004cc20004038060cc1fc03005c54cd4ccc1982100402c06c4ccccc2b004cdc000980099b8101201a01101000f1333330ac013370202603466e0004800404404003c594cd54ccd5cd19b880190011309f013370066e0ccdc119b82002019483403ccdc119b81001019483283d200209e012100116350040a601350030a60153353308001001069133702661260204600266e000440404cc24c0408c004d400428804d54cd4ccc1801f80140544ccc2d0040140340304ccc2d004010030034888d400c88ccc2e40401401000c88d54030888888d407888888d4070888d414888888c8c8c8c94cd4cc21804cc1a4011200033086013308601330680030193306800201833086015335330820100906b133086013308501330950100b009337000060246610a026612a020160100042a66a66104020100d626610c026610a026612a0201601066e00008048cc21404cc2540402c02400c4cc21804cc21404cc2540402c02400ccc21804cc21404cc2540402c020008cc21404cc2540402c1ac048cc21804cc1f8030058cc1f40280544c8c8c8ccccc2b40400800ccdc019b8101200700133700022002a66a0082604a66e0800800c40414cd400c4cccc08c03c04c048044520003370202400866e0404000858c26804010cdc199b8200200e00d3370666e08004038030cc244040840f8888c8cdc199b820010033370066e0801120d00f0013370400290650791112999ab9a3371200890000a4000264a666ae68cdc48008028a4000264a666ae68cdc4800a400029000080099b833370400466e04004014cdc019b8200148028014c014cdc10018011192999ab9a33710004900004d808a999ab9a30a10100214800054ccd5cd1851008010a40042a666ae68c28c04008520021330010023370066e0c009200448008c254048894ccd5cd19b880010021330030013370666e00cdc1802000800a4008200426660f2002006046464a666ae68c27c04d55ce80089919191919191919191919191919091999998008050048040028018011bad357426ae88008dd69aba100135744010a666ae68c2b4040084c8c8488888cc01001c018dd69aba135744a0046660f8eb9d71aba15001153335734615802004264642444446600200e00c6eb4d5d09aba25002375a6ae85400454ccd5cd18558080109909111118028031bad357426aae7801854ccd5cd18550080109919091111198010038031bad357426ae894008ccc1f1d73ae35742a0022a666ae68c2a4040084c8c8488888cc00c01c018dd69aba135744a0046660f8eb9d71aba150010a101135573c00a6aae74010cc1e1d71aba100530743574200a60e66ae84014dd51aba1001357440026ae88004d5d10009aab9e0010970137540026a002104026a00810c0260c82446660d444a66a660b066606c0d46a004104026a03c1040266606c0a06a6a0040fc0ee05e266008004002200200202a60c82446660d444a66a660b066606c0a06a0040ee6a0200ee66606c0a06a0040ee05e2660080040022002002026666660f0660c602c044660c602c04203e660c602c02003c660a8660a0044607a008660a8660a0042607c008660a8024660a8a66a60c82446660d444a66a660a66aa03a104026a6a0040ee1040226600800400220020020260d6442a66a0020fe440dea66a6660640a600490000998299981d183c840809984100833800a40042660a66607460f21020266104020ce0029000183800999b8100101d303d001333066059008010305333307f22322325333573466e1c010dc680488010a999ab9a308f0100415333573466e1d205a500313370290001980299b800044800800800400454ccd5cd19b885002481805854ccd5cd19b8950023370090302402426600866e0000d20023370066e08005201433702a00490300b099b8e0060014800120001533500400115335501a13335734611a026606860e60f6660f80c200a0c20d00022a66a0062a66aa0320022666ae68c23804cc0ccc1c81e8cc1ec18001018019c0044ccd5cd18460099819183883c9983d02f80182f833299a9983d91299a8008321109a80111299a9982b00101089834800898030019a9a99a983b03c00501083883610a99a8008b1109a80111299a8018a99a99827800a400420042c112022c666ae68cdc49982c800803240000c80ba6a0020d4a66a60b02446660bc44a66a66088a0226a0040d6266008004002200200200e2c0f8660ce02c6a00a0d460c2006a66a60a42446660b044a66a660826aa0160e06a6a6a0040d80ca0e026600800400220020020060b2442a66a0020da440ba60ba0026a0280d4660b40020246a6a0080c80be26a6a0020c20b4a66a609601c420022c2a66a660600040320ba266060002032603200e6068010464646a09c4444464646464646464660706606a00c0066607066068660a600c018660a6006018660706606e6608e6a6a66a60c60ca00401e0bc0b2660b601201090011981c1981f998188070009981c1981b9811800a40006607066068604201c60420026607066068604401c60440026606e604801c6048002660706606a60aa00a09666070a66a609024466609c44a66a60a26a6a0040c40b8266008004002200200200809e442a66a0020c6440a6a66a609024466609c44a66a60a26a0040b8266008004002200200260b000e09e442a66a0020c6440a666609a08000600860a40066a0020aca66a608824466609444a66a660666a6a6a00e0bc0ae0c46a6a0040ae0c4266008004002200200260a80062c0d06a0100ba6a6a0020b00a6a66a608400c420022c603000c606600e4464646a09e44444a66a6a00e440a8426464646464646464646464646607e660700286660aa09000800c6607e66076014660b40060246607e6607e6607c6609c6a0040c00120106607e6606e6a0040bc6a01a0d26606c6a0040be6a01a0ca6607e6607866aa60ce0d846a00244660ca00466aa60d40de46a00244660d0004666a0026e012000700466e0000520000013304000b3500922330723306400233072330640010090540540033303f3303e3304e3535335306a06c0010160650603306200f00e48008cc0fccc0f0c1700181494cd4c13c488ccc154894cd4c160d4d40081a418c4cc010008004400400400c1588854cd40041a888168c168014cd4c1a01a800c04cd40041754cd4c12c488ccc144894cd4cc0dcd4d4030194178d40081784cc010008004400400400c581bcc160004d403418cccd4080178d4d4080188178004cc11800c004cc164020d4004170cc140004020d4d40041681554cd4c11001c8400458124c06401cc0d0020448004584d55cf0011aab9d0013754004444a66a6600600400207809c46a0020b0246666666600204044a666ae68cdc38010008020a999ab9a3371200400203203044666ae68cdc400100081b01e802802001912999ab9a337120040022002200444a666ae68cdc4801000880108008881f11199ab9a3371000400207406644666ae68cdc480100081c81911199ab9a337120040020620706607c91100488100223333550023303f2233350050480010023500304222337000029001000a4000660784446006600400240026607666076e01200070246a0024444400a46a0020a246a0024407246a0024406c464a666ae68c140d55ce8008991919191981e2999ab9a305435573a00626464646464646464646464646464646464646464646424666666666600201a01801601401201000e00a00600460446ae84d5d10011980f1981dbae2001357420026ae88008cc071d71aba100135744016a666ae68c190d55ce804899191919827a999ab9a306735573a004264660a066038eb4d5d0800980d9aba1357440026aae7800817d4ccd5cd18339aab9d001132330503301c75a6ae84004c06cd5d09aba200135573c0020be6ea8d5d09aba200237546ae84004d55cf00482e1980c9981b019bad35742014660300326ae84028ccc059d70029aba100a33301575c0086ae84028cc054008d5d08051980a1192999ab9a306035573a0022646609260326ae84004c010d5d09aba200135573c0020b06ea8004d5d08051192999ab9a305f35573a002264646660b060606ae84008ccc059d70029aba10013303375c6ae84d5d10009aba200135573c0020ae6ea8004cc045d73ad37546ae84004d5d10009aba2001357440026ae88004d5d10009aba200135573c006098a666ae68c15c0044c848888c010014c02cd5d09aab9e00215333573460ac00226424444600400a60486ae84d55cf0010a999ab9a3055001132122223001005300c357426aae7800854ccd5cd182a0008990911118018029bae357426aae78008130d55ce8009baa357426ae88008dd51aba100135573c0020906ea80048c94ccd5cd182800081e8a999ab9a304f00102b04735573a6ea800488c8c94ccd5cd18290008058a999ab9a3051001130193004357426aae7800854ccd5cd18280008050241aab9d00137540024464460046eac004c10888cccd55cf8009014119198239981c98031aab9d001300535573c00260086ae8800cd5d0801020919118011bac00130402233335573e002404c46608860086ae84008c00cd5d100101f91919192999ab9a305300211222203515333573460a4004220922a666ae68c1440084c8c848888888cc004024020dd69aba135744a0046eb8d5d0a8008a999ab9a3050002132321222222233002009008375c6ae84d5d128011bae35742a0022a666ae68c13c0084c8c848888888cc018024020dd71aba135744a004603a6ae85400454ccd5cd1827001099091111111803804180e9aba135573c0062a666ae68c1340084c848888888c014020c074d5d09aab9e003045135573c0046aae74004dd50009192999ab9a304a35573a0022646606660086ae84004dd69aba1357440026aae78004108dd50009192999ab9a304935573a00226eb8d5d09aab9e0010413754002220582205444a66a00442a66a00442660240040020462a66a0024046068446a004446a006446666010008006004002446a004444446a00c44444a66a6601e01400a2a66a6601e0120082a666ae68cdc38040018a999ab9a3370e00e0042a66a00c42a66a004426a004446a004446a00a446a00444a66a666602e00c00a0040022a66a00e42a66a008426604800400206a2a66a006406a08c0680562a66a002405607805405405405444446466a00a466a0084a666ae68cdc78010008018121013919a802101392999ab9a3371e0040020060482a66a00642a66a0044266a004466a00446601200400244405444466a0084054444a666ae68cdc38030018a999ab9a3370e00a0042660220080020520520442a66a00240440664466a004466a00446601c0040024046466a004404646601c004002446a004446a00644a666ae68cdc780200109980780180081091199aa9815019180680191a80091199aa981681a980800311a80091199a800919805a40000020144660160029000000998030010009981280100a91199ab9a3370e00400202c03a44a66a00420020324466aa605205c46a002446604e004666a002466aa605a06446a0024466056004601800200244666010016004002466aa605a06446a0024466056004601600200266600600c004002444666aa605005c06466aa605205c46a002446604e0046010002666aa605005c446a00444a66a666aa6054064601a01646a002446601400400a00c200626606c00800602800266aa605205c46a002446604e0046606844a66a002260120064426a00444a66a6601800401022444660040140082600c00600800442444600200842444600600844666ae68cdc780100080800b9980e80080a11299a801012080091980e11199a8018128010009a80080f9192999ab9a303435573a00226464646466666042666016eb9d71aba100433300b75ceb8d5d08019bad357420046eb4d5d0800998051192999ab9a303a35573a0022646604660146ae84004cc035d71aba1357440026aae780040c8dd50009aba1357440026ae88004d5d10009aba200135573c0020586ea80048c94ccd5cd18199aab9d0011323301c3005357420026600c0086ae84d5d10009aab9e00102b375400246464a666ae68c0d00044c8c8c8c8c8488ccc00401801000cdd69aba1357440046eb4d5d08009aba2002375a6ae84004d55cf0010a999ab9a3033001130103004357426aae780080acd55ce8009baa0012323253335734606600226424460020066eb8d5d09aab9e00215333573460640022601e6eb8d5d09aab9e00202a35573a0026ea800488c8c94ccd5cd18190008980798021aba135573c0042a666ae68c0cc0040380a8d55ce8009baa001222325333573460626aae740044c8cc068c014d5d080098021aba1357440026aae780040a4dd5000911a8009119198131119a800a4000446a00444a666ae68cdc7801004898038008980300180298129119a800a4000446a00444a666ae68cdc7801003880089803001919a80081100211a800911a80111111111111999a805900b900b900b9199aa981101500b11a80091299a998090010020980c00180b805912999ab9a3371e6a0040346a0020342666ae68cdc39a80100b1a80080b001805003880b91180f11299a80088019109980300118020009299a800900b0019111a801111299a800909a8029111111111299aa99a999aa981001400a11a800912999ab9a3371e00401c2602c00602a0044260286a0020440244260240022c2c2006424460040066601444a66a0044200620020022018446602e44a66a00203c4426a00444a666ae68cdc78010038a99a8008111109a80111299a8018a999ab9a302d00113301400b0020262202813006003002235001222222222200a2350012201c23500122222222220092220032220012220023333300248811c0be55d262b29f564998ff81efe21bdc0022621c12f15af08d0f2ddb10048811ce4214b7cce62ac6fbba385d164df48e157eae5863521b4b67ca71d8600330014891c13aa2accf2e1561723aa26871e071fdf32c867cff7e7d50ad470d62f004881074d494e535741500048811c2f2e0404310c106e2a260e8eb5a7e43f00cff42c667489d30e179816004881054f574e455200221233001003002222221233333001006005004003002300b22112225335001135003006221333500500c300400233355300700f0050040012200130092211222533500110022213300500233355300700d005004001300822112253350010052213300f30040023355300600b00400111001220023005221225333573466e20005200013005490103505436001533500213005491035054370022153335734602c0062004266a600c01000266e0400d2002253357380022c240026004444a66a00220044426a004446600e66601000400c00200660024444a66a00220044426a00444a666ae68c0500044ccc02001c01800c4ccc02001ccc028ccc02c01c00800401800c8c8c00400488cc00cc00800800488488cc00401000c88848ccc00401000c00854cd5ce2490350543100162215335001100200715335738921001622222222007220053704904d0f910b111110021b8748000dc3a40046e1d2004370e90031b8748020dc3a40146e1d200c0159030b590308010000323232323232323232323232323232322232323232323253353300a53353301322533500100b2213232325335533553335734603c002260286eb8d5d09aab9e002153335734603e002026038402202626603600a00620066aae74004dd51a801911000980200098081bac35500222222222220091622153350011533533300d00c48811c0be55d262b29f564998ff81efe21bdc0022621c12f15af08d0f2ddb10033301075c0066a00444400442a66a0022c4426a00444a66a0062a666ae68c07c004400858074580614cd4ccc02c028cccd54ccd5cd19b874801800c4c848888c010014d5d09aab9e00415333573466e1d2004003132122223002005357426aae7801054ccd5cd180c0018990911118008029aba135573c0082a666ae68c05c00c4c848888c00c014d5d09aab9e0040152675c4c4c66601ceb8004d54008888888888801c854cd400458884d4008894cd400c400806c5844800458cc035d73ad13253335734602c6aae740044c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c848cccccccccc00404c04403c03402c02401c01400c008d5d09aba2002357420026ae88008d5d08009aba2002357420026ae88008d5d08009aba2002357420026ae88008d5d08009aba2002357420026ae88008d5d08009aba2002357420026aae78004050dd51a801910011aab9d00137546a00244002a666ae68c048d55ce800899198059aba1001357426ae88004d55cf0008081baa001100922333573466e3c00800402848800488c028894cd4004014884d4008894cd4cc02000801c4c0280044c01800c44880048488c00800c88c88c008dd58009803911999aab9f00120092323300c33007300635573a002600a6aae78004c010d5d10019aba100200722123300100300230032233335573e002400a46466010a666ae68c02cd55ce80089919191919091998008028018011aba1357440046ae84004d5d10011aba100135573c00201260086ae8800cdd51aba1002003120011220022323001001223300330020020012212233001004003153357389210350543100162216370e90001b874800810483d8799fd8799fd8799f581c3923d1a54393f0ae847a71bc394f72f647f5cf96169d85d6f85cab4effd8799fd8799fd8799f581c3ce9a54bf679c1e0654af298d76e1277d6746c5d5b790401bbf193d4ffffffffd8799fd8799f581c3923d1a54393f0ae847a71bc394f72f647f5cf96169d85d6f85cab4effd8799fd8799fd8799f581c3ce9a54bf679c1e0654af298d76e1277d6746c5d5b790401bbf193d4ffffffffd87a80d87b9f01ff1a001e84801a001e8480ffd8799fd8799f4040ffd8799f581c29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6434d494eff1b0000418b43759eff00d87a80ffd8799fd8799f4040ffd8799f581c29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c6434d494eff1b0000418d6e812ff600d87a80ff0583840001d8799fd8799fd8799f581c2ebacbaf16275a0184357de07c81d4a895f29343c235974f3b4c1d7dffd87a80ff00ff821a002ab1151a3e67120e840003d879808219c7841a01a0f8a5840100d87980821a000468ac1a070b470af5d90103a100a11902a2a1636d7367836f4c616d696e61722052656c617965727840363535633635666334313661366130313431666363363735383362363364656435393231353561626133306462383865333030633338636561393734323235626130" + }, + "ab474223d40e2e3540555364be27e161a809c33651408f43d84acff10c0ba306": { + "note": "Parameter change proposal (guardrail)", + "cbor": "84a800d901028282582036d07f29c67822b9a75cef6d899ec66843d87922077bdac8a3ee508b7f8b561500825820b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf000dd9010281825820b3d452bff7769d7f557ec6b8974760ee6c5e496c276652b654032966621e0ccf00018182583901076ad93c90e7dafc2ad468212fb2e2701559d1d32f25ebdc1facdada611943783e94de22f533778841521e97f77588fe05447f464be192c91a002c7aaa1082583901076ad93c90e7dafc2ad468212fb2e2701559d1d32f25ebdc1facdada611943783e94de22f533778841521e97f77588fe05447f464be192c91a002ca3af111a0007da47021a00053c2f0b58208c75478b90ba1457795d9102d92f4a19886f1a8c147fe96a02d1cb8f355d4ca914d9010281841b000000174876e800581de1192688a334130db2b51aedea594301010b0d1d2e9a86a460932520ba8400825820c75bb221606687aa858ec89c7a15c88e9c17054f2e045ae31ecc8a9687cd206e00a3101a047868c014821a010b07601b00000002540be40015821a049e8e601b00000004a817c800581cfa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64827842697066733a2f2f6261666b7265696663356374356670657534796778366b3463717a62797a6e793778666d756c6a7977676e376a6765337a6b66327469646163796d5820f200d39e39a3db74bdaa4f04865b4da38b5403322984c6fdcaf09bd44cacf868a300d9010281825820b34db9badbd148ffdcc73259bad2bc5981a382e657a2b27c2bc014fc163871195840e1041860d287e705958ea047b30428846f25e4c03e551cc66f692158e4489faf3f44b9ce42ed92f3f0c8ccc950c1c2d84c7e25f2df578363b494ad644a02ee0707d90102815908545908510101003232323232323232323232323232323232323232323232323232323232323232323232323232323232259323255333573466e1d20000011180098111bab357426ae88d55cf00104554ccd5cd19b87480100044600422c6aae74004dd51aba1357446ae88d55cf1baa3255333573466e1d200a35573a002226ae84d5d11aab9e00111637546ae84d5d11aba235573c6ea800642b26006003149a2c8a4c301f801c0052000c00e0070018016006901e4070c00e003000c00d20d00fc000c0003003800a4005801c00e003002c00d20c09a0c80e1801c006001801a4101b5881380018000600700148013003801c006005801a410100078001801c006001801a4101001f8001800060070014801b0038018096007001800600690404002600060001801c0052008c00e006025801c006001801a41209d8001800060070014802b003801c006005801a410112f501c3003800c00300348202b7881300030000c00e00290066007003800c00b003482032ad7b806038403060070014803b00380180960003003800a4021801c00e003002c00d20f40380e1801c006001801a41403f800100a0c00e0029009600f0030078040c00e002900a600f003800c00b003301a483403e01a600700180060066034904801e00060001801c0052016c01e00600f801c006001801980c2402900e30000c00e002901060070030128060c00e00290116007003800c00b003483c0ba03860070018006006906432e00040283003800a40498003003800a404d802c00e00f003800c00b003301a480cb0003003800c003003301a4802b00030001801c01e0070018016006603490605c0160006007001800600660349048276000600030000c00e0029014600b003801c00c04b003800c00300348203a2489b00030001801c00e006025801c006001801a4101b11dc2df80018000c0003003800a4055802c00e007003012c00e003000c00d2080b8b872c000c0006007003801809600700180060069040607e4155016000600030000c00e00290166007003012c00e003000c00d2080c001c000c0003003800a405d801c00e003002c00d20c80180e1801c006001801a412007800100a0c00e00290186007003013c0006007001480cb005801801e006003801800e00600500403003800a4069802c00c00f003001c00c007003803c00e003002c00c05300333023480692028c0004014c00c00b003003c00c00f003003c00e00f003800c00b00301480590052008003003800a406d801c00e003002c00d2000c00d2006c00060070018006006900a600060001801c0052038c00e007001801600690006006901260003003800c003003483281300020141801c005203ac00e006027801c006001801a403d800180006007001480f3003801804e00700180060069040404af3c4e302600060001801c005203ec00e006013801c006001801a4101416f0fd20b80018000600700148103003801c006005801a403501c3003800c0030034812b00030000c00e0029021600f003800c00a01ac00e003000c00ccc08d20d00f4800b00030000c0000000000803c00c016008401e006009801c006001801807e0060298000c000401e006007801c0060018018074020c000400e00f003800c00b003010c000802180020070018006006019801805e0003000400600580180760060138000800c00b00330134805200c400e00300080330004006005801a4001801a410112f58000801c00600901260008019806a40118002007001800600690404a75ee01e00060008018046000801801e000300c4832004c025201430094800a0030028052003002c00d2002c000300648010c0092002300748028c0312000300b48018c0292012300948008c0212066801a40018000c0192008300a2233335573e00250002801994004d55ce800cd55cf0008d5d08014c00cd5d10011263009222532900389800a4d2219002912c80344c01526910c80148964cc04cdd68010034564cc03801400626601800e0071801226601800e01518010096400a3000910c008600444002600244004a664600200244246466004460044460040064600444600200646a660080080066a00600224446600644b20051800484ccc02600244666ae68cdc3801000c00200500a91199ab9a33710004003000801488ccd5cd19b89002001800400a44666ae68cdc4801000c00a00122333573466e20008006005000912a999ab9a3371200400222002220052255333573466e2400800444008440040026eb400a42660080026eb000a4264666015001229002914801c8954ccd5cd19b8700400211333573466e1c00c006001002118011229002914801c88cc044cdc100200099b82002003245200522900391199ab9a3371066e08010004cdc1001001c002004403245200522900391199ab9a3371266e08010004cdc1001001c00a00048a400a45200722333573466e20cdc100200099b820020038014000912c99807001000c40062004912c99807001000c400a2002001199919ab9a357466ae880048cc028dd69aba1003375a6ae84008d5d1000934000dd60010a40064666ae68d5d1800c0020052225933006003357420031330050023574400318010600a444aa666ae68cdc3a400000222c22aa666ae68cdc4000a4000226600666e05200000233702900000088994004cdc2001800ccdc20010008cc010008004c01088954ccd5cd19b87480000044400844cc00c004cdc300100091119803112c800c60012219002911919806912c800c4c02401a442b26600a004019130040018c008002590028c804c8888888800d1900991111111002a244b267201722222222008001000c600518000001112a999ab9a3370e004002230001155333573466e240080044600823002229002914801c88ccd5cd19b893370400800266e0800800e00100208c8c0040048c0088cc00800800505a182050082a0821a000a84cb1a0879f2bcf5f6" + }, + "12c22eb0f1f9796e8221eef547bd7aa65c49ff81f8836ea036e17878dee8e5ed": { + "note": "V3 spend + V3 committee vote", + "cbor": "84a900d90102828258209c8b89a35c6c403c98558c5eefd33d4eb0f8ec3cefb3f7fc775d56b4dcd68b6a008258209c8b89a35c6c403c98558c5eefd33d4eb0f8ec3cefb3f7fc775d56b4dcd68b6a010dd90102818258209c8b89a35c6c403c98558c5eefd33d4eb0f8ec3cefb3f7fc775d56b4dcd68b6a010182a300581d71e87ac837794be9ecfbeca87e33671d5712cabaeed4970e074883df7901821a004c4b40a1581cbf3bbf5a8539663eddd53364a9fd90e468c0182fcf6f0642ac16d65fa1581eb547c30e21dc1f73216d426ef2fd2a2b3565c801bc4d5f82173f2a5e230101028201d81859019a9fd8799f581c47be87958f74acc8ef7259c58dcd5249e132672d98b113289172a46758202639e12406a2b4b4c5a46ec6bb16e26d938f86744dc9542d64bf8e1aeb38ff32ffd8799f581c4c81c04139ab537fdc540112ca8b4f2cf087b0a7eee18c7a257e6842582069139ce9b6f973cdc3a68c57144bf5456bbdb6a9c435953110170bc25c9b5acbffd8799f581c6fa5793abecc0b5645686b9c7e0d9cb235fe70447ce65be3f0732db9582092eac695089a8362c54d1e5136697a8b61d73ca8bf7e0c6b81a120122472a399ffd8799f581c547c8dca23e870884681ad98385a333322b17e940973f46b4511709358208af824a45128cebdde46e0ee5878bb846fada1f07ba05631fed19bb0263e2bdeffd8799f581cf671302c95343f413e03b6a54e5a7d47753527cbfa1087ae9237847858203dc23ae04cb0693bb501380bb63ca70c52eabc57f80a6259c90d10fe1005e8a0ffd8799f581c0e37debab3a29bd0c9bb38ca37231fcbf6e1b478eb3238cf6db9d4f75820ab5344a4bca78827f5bc01c4456a39b8e22f3974f273a5187d1bdcc7833994daffff82581d61a32e36eef2a43abb0d3bffc09da47e72ec3667c9a6139ec8b552866b1a00fe5dbf1082581d61a32e36eef2a43abb0d3bffc09da47e72ec3667c9a6139ec8b552866b1a00f88125111a001195ce021a000bb9340ed9010284581c0e37debab3a29bd0c9bb38ca37231fcbf6e1b478eb3238cf6db9d4f7581c47be87958f74acc8ef7259c58dcd5249e132672d98b113289172a467581c4c81c04139ab537fdc540112ca8b4f2cf087b0a7eee18c7a257e6842581cf671302c95343f413e03b6a54e5a7d47753527cbfa1087ae923784780b58202fd4380a161582038679d09ba2b5fe83c12fbba1f57b99a0b5abcb8e2e64c3a313a18201581c2ea7a78eb914d988b9d368ed88906f3bc9fc5421667dea6a366710eca2825820418df5986f50547ec4a709f1a5bce6b850753ad6614c873386b7c953fee84a9f00820182787968747470733a2f2f7261772e67697468756275736572636f6e74656e742e636f6d2f4561737465726e2d43617264616e6f2d436f756e63696c2f67612d726174696f6e616c65732d323032352f726566732f68656164732f6d61696e2f67613135362f67613135362d726174696f6e616c652e6a736f6e6c645820435760c023264747802ef939e6c80682ed5a432315b34a99bcf5784a8bf0c2b582582075e7882a8ef2bc39517bffbfb654e89f525def5a8364d81e11fc5facafc6dd9b00820182787968747470733a2f2f7261772e67697468756275736572636f6e74656e742e636f6d2f4561737465726e2d43617264616e6f2d436f756e63696c2f67612d726174696f6e616c65732d323032352f726566732f68656164732f6d61696e2f67613135372f67613135372d726174696f6e616c652e6a736f6e6c64582058e8e3e5d3995333948deda829571f331446a9064135c9e75b3aa5f5f8884e63a300d90102858258205978fbfed75818417ddc9fb35c7fbf3669fdb812358fb6461b04adca13d432f758403771dbabbd8e7d93e854e6cc8f25b4eb3870a1cfa0e7cec1bce155218ec4f02834b456e2aac2333d02de0bcc960a18657917f18b90a6f369a7ddc61c25213f0d825820fb10f6e831a1e7fa13c9daac40656f83e8700bb180371204a29d9d4ac3029d2158406a461d734047cde02b0b3ee5af133fba96cb45cbd96d1b36510c987ae4f0177eea6e125bb7f3cc551b0f23fb44b1214f30890dd6ca4279bb6e00d968e87b4507825820e261517e07bc28347b051c86d075d9a92398603e55fbf44f1358041473c5a5c35840d86e24993385bf4fe53ac962cb85b7394f210ff236690911842b1d512fd38134d27bc699089504019b85eb5b5d24460660a00302fa204f5515100a425a2765028258208583fedf805b703139f059e8acb951d1dfa11830acda274b61a1f778b31cc6545840efb8352f2bd2404f6fa4bc820578c5793e2b733be9a1a69548588a9e3924302e29213c7e356404ac61175bd8fd1fd1fbbf47b29d9c0a114ee001e5cb0a677e0a82582074ca0a8ebcdf27a86d2fcd28334a747c184b531477ded93d662c4ab63015592c584072622e26c0e143f675bd47e3c1b6c09b745184fc20e036916b9360c6680677f3136b79ea6d9e3248d31abf74f344312b2c885b3a120029a05c19e463812ef40707d9010282590bab590ba801010033232323232323232323222593293232325333573466e1d2000002132323232323232328009919192999ab9a3370e900000109919191919191919191919191919191919999999999991999199199111111111111111400404201f00e806c03201700a804c02200f006802c0120070028008c004d5d080a18009aba1013302623232325333573466e1d2000002180098101aba100198029aba13574400213030491035054310035573c0046aae74004dd5000998130009aba101123232325333573466e1d2000002132328009919192999ab9a3370e90000010c004c08cd5d0800ccc0848c8c8c94ccd5cd19b87480000086002604e6ae840042a666ae68cdc3a4004004265003375a6ae8400a6eb4d5d0800cdd69aba13574400235744002260709201035054310035573c0046aae74004dd50009aba135744002130344901035054310035573c0046aae74004dd51aba100398039aba10029919192999ab9a3370e90000010c00054ccd5cd19b8748008008600a6eb8d5d0800854ccd5cd19b874801000860066ae8400426068921035054310035573c0046aae74004dd51aba10019980f3ae357426ae880046ae88004d5d100089817a481035054310035573c0046aae74004dd50009bad3574201e60026ae84038c004c005d69981200b1aba100c33301501975a6ae8402cc8c8c94ccd5cd19b87480000086002646464a666ae68cdc3a400000430013302e75a6ae84006605a6ae84d5d100084c0c12401035054310035573c0046aae74004dd51aba10019919192999ab9a3370e90000010c004cc0b9d69aba100198169aba13574400213030491035054310035573c0046aae74004dd51aba1357440021302d491035054310035573c0046aae74004dd51aba100a3302475c6ae84024ccc0548c8c8c94ccd5cd19b874800000860066eb8d5d0800854ccd5cd19b87480080086012603c6ae840042a666ae68cdc3a40080043007301d3574200215333573466e1d200600218009bad35742003301a357426ae880042a666ae68cdc3a4010004300b301c3574200215333573466e1d200a00218029bad357420033018357426ae880042605c9201035054310035573c0046aae74004dd50008131aba1008330010263574200e6eb8d5d080319980a80c1980a81491919192999ab9a3370e90000010c00854ccd5cd19b874800800860082a666ae68cdc3a400800430001302e4901035054310035573c0046aae74004dd50009aba1005330240143574200860046ae8400cc008d5d09aba2003301475c602aeb4d5d10009aba2001357440026ae88004d5d10009aba2001357440026ae88004d5d10009aba2001357440026ae88004d5d10009aba20011301a491035054310035573c0046aae74004dd51aba10099aba10089919192999ab9a3370e90000010c00cdd71aba10010a999ab9a3370e90010010c024c028d5d0800ccc01c058d5d09aba20010a999ab9a3370e90020010c01cc024d5d0800854ccd5cd19b874801800860026eb4d5d0800cc018d5d09aba20010a999ab9a3370e90040010c02cc020d5d0800854ccd5cd19b8748028008600a6eb4d5d0800cc010d5d09aba20010980d2481035054310035573c0046aae74004dd51aba13574401023232325333573466e1d20000021328009bad35742005300a357420033232325333573466e1d20000021328049980600f1aba10029aba1001998063ae357426ae880046ae8800454ccd5cd19b874800800860026601603a6ae84006646464a666ae68cdc3a40000043001375a6ae840066eb4d5d09aba200109810a481035054310035573c0046aae74004dd51aba13574400215333573466e1d20040021805999804806bad357420033300b75c6ae84d5d1000854ccd5cd19b8748018008600e6601603a6ae840042a666ae68cdc3a40100042646500d3300d01f357420073301a00f3574200533300b00f75a6ae840072646464a666ae68cdc3a40000043001375a6ae840066eb4d5d09aba200109811a481035054310035573c0046aae74004dd51aba135744003223301a0020010d5d10009aba200115333573466e1d200a00218029980580e9aba10019919192999ab9a3370e90000010998073ae35742002260429201035054310035573c0046aae74004dd51aba13574400215333573466e1d200c00218010980f2481035054310035573c0046aae74004dd51aba1357440023574400226034921035054310035573c0046aae74004dd50009119118011bab00130182233335573e0025000232801c004c018d55ce800cc014d55cf000a60086ae8800c6ae8400a0004646464a666ae68cdc3a4000004300d3007357420033300575a6ae84d5d1000854ccd5cd19b87480080086026600e6ae840066600aeb4d5d09aba20010a99199ab9a3370e90020018c00cc020d5d08014c004d5d09aba20020a999ab9a3370e9003001899402cc024d5d0801cc008d5d0800cdd69aba135744002357440042a666ae68cdc3a40100063009300835742005375a6ae84d5d1001054ccd5cd19b874802800c602a60106ae840082a666ae68cdc3a40180063011300835742005375a6ae84d5d1001054ccd5cd19b874803800c600a6eb8d5d08014dd71aba13574400415333573466e1d201000318039bae35742005375a6ae84d5d1001054ccd5cd19b874804800c600260106ae8400a60106ae84d5d1001054ccd5cd19b874805000c601e60106ae84008260329201035054310023232325333573466e1d200000218009bae357420021532333573466e1d2002003180298009aba10020a999ab9a3370e90020018c00cdd71aba100298009aba1357440041301d491035054310023232325333573466e1d2000002180098079aba10010a999ab9a3370e90010010c00854ccd5cd19b8748010008600826040921035054310035573c0046aae74004dd50009aab9e00235573a0026ea8004d55cf0011aab9d001375400244646464a666ae68cdc3a4004004300215333573466e1d2000002180098029aba10010980c249035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004c014d5d0800854ccd5cd19b87480080086006600a6ae840042a666ae68cdc3a40080043005375c6ae840042602c921035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004dd71aba10010a999ab9a3370e90010010c00cdd71aba10010980aa49035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004dd71aba10019bad357426ae8800426028921035054310035573c0046aae74004dd50009aba20011300f491035054310035573c0046aae74004dd5001488a4006450029400a450029400a4500291802c802488888888888888880401806112c800c4c0220052219002914800c88894ccd5cd19b874800a403244646603244b20031480024432005225333573466e3c0080244c01c0044c01800c006301822590018a40012219002912999ab9a3371e00400e20022600c00604c0420002601400e229344c02d2401035054350018049112999ab9a3370e900000089805a481035054330015333573466e20005200013300333702900000119b81480000044ca00266e1000c00666e10008004660080040026010444a666ae68cdc3a4000002200426600600266e180080048c88c008dd60009804111999aab9f00128001400cc010d5d08014c00cd5d100120004b2003180046004200123232325333573466e1d200000218010a999ab9a3370e90010010c0004c01d241035054310035573c0046aae74004dd5000911919192999ab9a3370e90000010c00854ccd5cd19b87480080086002600a6ae840042a666ae68cdc3a40080043004130074901035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004dd71aba10019bad357426ae880042600a921035054310035573c0046aae74004dd5000919319ab9c00180011918008009180111980100100099aa99a8910010891400400a00211212900280092211cbf3bbf5a8539663eddd53364a9fd90e468c0182fcf6f0642ac16d65f0048811eb547c30e21dc1f73216d426ef2fd2a2b3565c801bc4d5f82173f2a5e230100015914f05914ed010100333323232323232323232323232323232323232323232323232323232323232323232222259323290029114800c8a005280148a0052801489640064643200f222222222222222230110101816912c800c60052219002912c80148a402e452a666ae68cdc78020010999ab9a3370e0060030008010c00a3201122900191112c980b808c8564cccccc0f4058894ccd5cd19b874803800860030080a99199ab9a3370e90080018ac9999aab9f00228011191940040086ae88010d5d0801c00242b2607c00321800c00c0048980124c89800a4c1300149894ccd5cd19b874804800c60030040a999ab9a3370e900a0018c00600015333573466e1d201600315933335573e0045002232328008010d5d10021aba100380048564c0fc0064300180380091801230020c008a004500228011400a42a666ae68cdc3a40046604400a03a2720312222222222222222253290028ac99800805008c564c11c01a320552222222222222222333573466e1d20013304400c03f800400918012300248564c12001e2666660680580320300224646464660ac44b20031801488564cc13002400a2600a027130040010064c15489640063002910c8044894ccd5cd19b8f002004159500889803c00244260130020980300184b200318014564ccd412011c00d400a30008c00813304829001914802c894ccd5cd19b8f00400215333573466e3c00c004600430001800002d180122b2608e00d1593300100a01189999981981580c00b808191299480144c006001221300380109640063002898018010c14489640063000910c80148c8c88c008070c15c896400626006025221593304d00800289805003c4c010004305622590018c00a442a666ae68cdc7802801098048030980200088c009180124642b266006018027159304900889800809460048c00860a444b200318014886400a4444a666ae68cdc3a40046607a006070320092290029400a4a666ae68cdc78060008c0006004098040029159323333303402c019018011259330020160018c00230021a822021c564cc11405002a2b200d18014886400a44b20071900291948014a40d64a666ae68cdc78010008a8018c00a50024a40d6500292999ab9a3371e0040022a0063002056400a260a9000910982b400928014a004910c008460048c0082305122590018c00a443200522900191112999ab9a3370e90011981f00181d0ac80146005215933333305b001225333573466e1d200000215933335573e00250022800c004d5d08014d5d1001100090c800c8964c17400a42b26666aae7c008a00450018009aba10039aba20032001219001912c981f80148564cccd55cf801140088c8ca002004357440086ae8400e0012159304100190c00600200f003800918012300246004460048c0088c0083002280114008a004500290c800c888cc15000404118012430020980500380008c00a30024600422601400e08c0092801064466666604e00245002280100114008a004604c446666aae7c004a00300012c98129aba100290ac98021aba200390c0060060050012300244ca00460066ae880090003232325333573466e1d2000002132323232323232328009919192999ab9a3370e900000109919191919191919191919191919191919999999999991999199199111111111111111400404201f00e806c03201700a804c02200f006802c0120070028008c004d5d080a18009aba1013302e23232325333573466e1d2000002180098101aba100198029aba13574400213031491035054310035573c0046aae74004dd5000998170009aba101123232325333573466e1d2000002132328009919192999ab9a3370e90000010c004c08cd5d0800ccc0848c8c8c94ccd5cd19b87480000086002604e6ae840042a666ae68cdc3a4004004265003375a6ae8400a6eb4d5d0800cdd69aba13574400235744002260729201035054310035573c0046aae74004dd50009aba135744002130354901035054310035573c0046aae74004dd51aba100398039aba10029919192999ab9a3370e90000010c00054ccd5cd19b8748008008600a6eb8d5d0800854ccd5cd19b874801000860066ae840042606a921035054310035573c0046aae74004dd51aba10019980f3ae357426ae880046ae88004d5d1000898182481035054310035573c0046aae74004dd50009bad3574201e60026ae84038c004c005d69981600b1aba100c33301501975a6ae8402cc8c8c94ccd5cd19b87480000086002646464a666ae68cdc3a400000430013302f75a6ae84006605c6ae84d5d100084c0c52401035054310035573c0046aae74004dd51aba10019919192999ab9a3370e90000010c004cc0bdd69aba100198171aba13574400213031491035054310035573c0046aae74004dd51aba1357440021302e491035054310035573c0046aae74004dd51aba100a3302c75c6ae84024ccc0548c8c8c94ccd5cd19b874800000860066eb8d5d0800854ccd5cd19b87480080086012603c6ae840042a666ae68cdc3a40080043007301d3574200215333573466e1d200600218009bad35742003301a357426ae880042a666ae68cdc3a4010004300b301c3574200215333573466e1d200a00218029bad357420033018357426ae880042605e9201035054310035573c0046aae74004dd50008139aba1008330010273574200e6eb8d5d080319980a80c1980a81511919192999ab9a3370e90000010c00854ccd5cd19b874800800860082a666ae68cdc3a400800430001302f4901035054310035573c0046aae74004dd50009aba10053302c0143574200860046ae8400cc008d5d09aba2003301475c602aeb4d5d10009aba2001357440026ae88004d5d10009aba2001357440026ae88004d5d10009aba2001357440026ae88004d5d10009aba20011301b491035054310035573c0046aae74004dd51aba10099aba10089919192999ab9a3370e90000010c00cdd71aba10010a999ab9a3370e90010010c024c028d5d0800ccc01c05cd5d09aba20010a999ab9a3370e90020010c01cc024d5d0800854ccd5cd19b874801800860026eb4d5d0800cc018d5d09aba20010a999ab9a3370e90040010c02cc020d5d0800854ccd5cd19b8748028008600a6eb4d5d0800cc010d5d09aba20010980da481035054310035573c0046aae74004dd51aba13574401023232325333573466e1d20000021328009bad35742005300a357420033232325333573466e1d20000021328049980600f9aba10029aba1001998063ae357426ae880046ae8800454ccd5cd19b874800800860026601603c6ae84006646464a666ae68cdc3a40000043001375a6ae840066eb4d5d09aba2001098112481035054310035573c0046aae74004dd51aba13574400215333573466e1d20040021805999804806bad357420033300b75c6ae84d5d1000854ccd5cd19b8748018008600e6601603c6ae840042a666ae68cdc3a40100042646500d3300d020357420073302200f3574200533300b00f75a6ae840072646464a666ae68cdc3a40000043001375a6ae840066eb4d5d09aba2001098122481035054310035573c0046aae74004dd51aba135744003223301b0020010d5d10009aba200115333573466e1d200a00218029980580f1aba10019919192999ab9a3370e90000010998073ae35742002260449201035054310035573c0046aae74004dd51aba13574400215333573466e1d200c00218010980fa481035054310035573c0046aae74004dd51aba1357440023574400226036921035054310035573c0046aae74004dd50009119118011bab00130302233335573e0025000232801c004c018d55ce800cc014d55cf000a60086ae8800c6ae8400a0004646464a666ae68cdc3a4000004300d3007357420033300575a6ae84d5d1000854ccd5cd19b87480080086026600e6ae840066600aeb4d5d09aba20010a99199ab9a3370e90020018c00cc020d5d08014c004d5d09aba20020a999ab9a3370e9003001899402cc024d5d0801cc008d5d0800cdd69aba135744002357440042a666ae68cdc3a40100063009300835742005375a6ae84d5d1001054ccd5cd19b874802800c602a60106ae840082a666ae68cdc3a40180063011300835742005375a6ae84d5d1001054ccd5cd19b874803800c600a6eb8d5d08014dd71aba13574400415333573466e1d201000318039bae35742005375a6ae84d5d1001054ccd5cd19b874804800c600260106ae8400a60106ae84d5d1001054ccd5cd19b874805000c601e60106ae84008260349201035054310023232325333573466e1d200000218009bae357420021532333573466e1d2002003180298009aba10020a999ab9a3370e90020018c00cdd71aba100298009aba1357440041301e491035054310023232325333573466e1d2000002180098079aba10010a999ab9a3370e90010010c00854ccd5cd19b8748010008600826042921035054310035573c0046aae74004dd50009aab9e00235573a0026ea8004d55cf0011aab9d001375400244646464a666ae68cdc3a4004004300215333573466e1d2000002180098029aba10010980ca49035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004c014d5d0800854ccd5cd19b87480080086006600a6ae840042a666ae68cdc3a40080043005375c6ae840042602e921035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004dd71aba10010a999ab9a3370e90010010c00cdd71aba10010980b249035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004dd71aba10019bad357426ae880042602a921035054310035573c0046aae74004dd50009aba200113010491035054310035573c0046aae74004dd5000c52689806a49035054350018101112999ab9a3370e900000089805a481035054330015333573466e20005200013300333702900000119b81480000044ca00266e1000c00666e1000800466008004002603e444a666ae68cdc3a4000002200426600600266e18008004888894ca4cc064a4006444520092233008900c9100100080038c00a44320052222590058ac805c8a401a45265200b2900492999ab9a3371e0040022a006300294009290049400a4a666ae68cdc78010008a8018c00815900390ac801486400a5200523300f002001911400922290049400a444a666ae68cdc38030018a999ab9a3370e00a0042666ae68cdc3802000c002004300218011180122b2003218014600044c94ca4cc08200000515001910c8014889640122a666ae68cdc7a4410000315333573466e3d2210000215333573466e20005200018010a8030c008600522180101590078c00a42b2602400321300f0018c009218010c8c074c064ccd40680a0020004cc074c0748cdc12400200201d18010910c0081148014a400a4660400040032801252005280148cc080008004229001911919811112c800c52000910c8014894ccd5cd19b8f00200913007001130060030014c0848964006290004886400a44a666ae68cdc78010038800898030018200123232325333573466e1d200000218010a999ab9a3370e90010010c0004c01d2401035054310035573c0046aae74004dd5000911919192999ab9a3370e90000010c00854ccd5cd19b87480080086002600a6ae840042a666ae68cdc3a40080043004130074901035054310035573c0046aae74004dd500091919192999ab9a3370e90000010c004dd71aba10019bad357426ae880042600a921035054310035573c0046aae74004dd5000919319ab9c001800119118011bac00130182233335573e0025000280198021aba100298019aba20024000c05889640063000910c00e400a45001002c800c8c02400644666a01202e0040032300a0011300400104c8888c8c8cc06c896400620072218018014c010004300200430053300f2900191911801003980e112c800c60012219002912c99806001003c6005130060030002301922590018c002443003900291400400a6603e44b20031802801a443200522593300f0020088c00c01e00289803001800726008002180a912c800c6001221801c80148a002005800800898020008602844b2003180048860072005228008016002002260080021809912c800c6001221801c80148a002005802800898020008602444b2003180048860072005228008014cc01ca4006466e0120000019119b80002001919b80480000040024c01000423223002001301222590018c002443003900291400400a600e0024c01000430102225900188014886400a446600e66601000400c002006180791112c800c400a4432005225333573466e1d200000113330080070060031333008007801c00401e005001401800611480148a400e44a666ae68cdc78020010998068018008c0083300d221222590028ac800c60012218012442b20071801488564cc01c01000a2666a60122400200e006003180101200122590028c00a4426464a666ae68cdc499b8033706002900219b8600148010c034cc01c8cc04c89640063002910a999ab9a3371e00a0043000130040010030010c0006004601600266010600e009000119118010009806112c800c6001221325930060038c00c00e00288008c0100044b200318004886004300922590018c0024430039002910012600800218041112c80146001223213001004300d22590018c00c0126600e0070030048029221593300a002006899804802803c4c01000411199ab9a3371e0040030008011803112c800c5200091099b8048008c010004600a44b200314800244266e01200230040011199999802000912999ab9a3370e90000010ac9999aab9f0012801140060026ae8400a6ae88008800486400644b2600e005215933335573e0045002232328008010d5d10021aba100380048564c02400643001800801c0048c009180123002230020c008a00450022801140088cccccc00c0048a0045002280114008a0020022590018c00a30001111111999999aba40062323300735573a0026aae78004dd5003918029bab00723004375800e460066eb401c8c008dd7003c0008c8c0040048c0088cc008008004cd54cd4488008448a00200500108909480140049111c3da414859ddd0955cf6694a20cbbc000d60c4cb49dcbf46ad543568a0048811e428865df10f3a798cc8080fbef76b8156f05c6be992463f98f9dbef22300003355335122002112280080140042242520050012451cbf3bbf5a8539663eddd53364a9fd90e468c0182fcf6f0642ac16d65f0048811eb547c30e21dc1f73216d426ef2fd2a2b3565c801bc4d5f82173f2a5e23010033335122200228008008a00600221229003801400491011c2ea7a78eb914d988b9d368ed88906f3bc9fc5421667dea6a366710ec000105a282000082d9050080821a00146e881a1179dc2e82040082a0821a000782c61a07b8b2bef5f6" + }, + "6eddf4f1dbad06f329aa69743559c4930b7b689f16c073061d228d9c6613d19c": { + "note": "Treasury withdrawal proposal (guardrail)", + "cbor": "84a700d90102828258200218c156c5fac1eb8710b9004db2c86a34338aea80e96ad85ffe6a374cc3a0c400825820ffbb56e18fbf14d1c3a87a96c8016c46a9c1515cb0eb9330cae82e676c691a2d00018182583901816a6e7eebdcad38253821d014a923fa5384fcee822340baf7d260676da1d1d0838b7108de20c02b1d2e7933b1b78cfc619e528c1cf6a5f11a0093d1e0021a0004c4a004d901028183078200581c6da1d1d0838b7108de20c02b1d2e7933b1b78cfc619e528c1cf6a5f11a001e84800b5820576557ec07219cdb7a1e861f30d782e87df13a99d4e08bf1e8e1489e85232be60dd90102818258200218c156c5fac1eb8710b9004db2c86a34338aea80e96ad85ffe6a374cc3a0c40014d9010281841b000000174876e800581de16da1d1d0838b7108de20c02b1d2e7933b1b78cfc619e528c1cf6a5f18302a1581de1b6914e1047d69e76c6fff866e71be15ce6d4f673c22782fc474c49111b00001402462f6000581cfa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a6482786068747470733a2f2f746f746f66696e616e63652e636f6d2f63617264616e6f2d74726561737572792d70726f706f73616c2d7374726174656769632d746f6b656e697a65642d636f707065722d726573657276652f646174612e6a736f6e6c645820b2bdccb6d772bae43613612a745ed19f80ba4692c0e1616a2e61606d379953c0a300d90102828258204ee8fc63a8fb7d9127efc0f38e7eca023102469be1b1e98dec78bf8d4fd0469358408c961509a586f72134295fbd02e42d258342120da57f52dff32ef52b11c5054dccfc6e1b2e90b36ac5f9c4c33b13d3980a28730f2a453d38931a5818a260ef048258201302d9e0a0170a2b282302ede18759b3c3a0125130c8292cd02e3300df58641a58407bb4d401d9a79615c7f01d11455e886789201ed77f39a79b9aa732c3ac792027f3f9686b2a7e910717c50aeaf502b015ca0c1f0c1505d2d4f6c65645b4c9570107d90102815908545908510101003232323232323232323232323232323232323232323232323232323232323232323232323232323232259323255333573466e1d20000011180098111bab357426ae88d55cf00104554ccd5cd19b87480100044600422c6aae74004dd51aba1357446ae88d55cf1baa3255333573466e1d200a35573a002226ae84d5d11aab9e00111637546ae84d5d11aba235573c6ea800642b26006003149a2c8a4c301f801c0052000c00e0070018016006901e4070c00e003000c00d20d00fc000c0003003800a4005801c00e003002c00d20c09a0c80e1801c006001801a4101b5881380018000600700148013003801c006005801a410100078001801c006001801a4101001f8001800060070014801b0038018096007001800600690404002600060001801c0052008c00e006025801c006001801a41209d8001800060070014802b003801c006005801a410112f501c3003800c00300348202b7881300030000c00e00290066007003800c00b003482032ad7b806038403060070014803b00380180960003003800a4021801c00e003002c00d20f40380e1801c006001801a41403f800100a0c00e0029009600f0030078040c00e002900a600f003800c00b003301a483403e01a600700180060066034904801e00060001801c0052016c01e00600f801c006001801980c2402900e30000c00e002901060070030128060c00e00290116007003800c00b003483c0ba03860070018006006906432e00040283003800a40498003003800a404d802c00e00f003800c00b003301a480cb0003003800c003003301a4802b00030001801c01e0070018016006603490605c0160006007001800600660349048276000600030000c00e0029014600b003801c00c04b003800c00300348203a2489b00030001801c00e006025801c006001801a4101b11dc2df80018000c0003003800a4055802c00e007003012c00e003000c00d2080b8b872c000c0006007003801809600700180060069040607e4155016000600030000c00e00290166007003012c00e003000c00d2080c001c000c0003003800a405d801c00e003002c00d20c80180e1801c006001801a412007800100a0c00e00290186007003013c0006007001480cb005801801e006003801800e00600500403003800a4069802c00c00f003001c00c007003803c00e003002c00c05300333023480692028c0004014c00c00b003003c00c00f003003c00e00f003800c00b00301480590052008003003800a406d801c00e003002c00d2000c00d2006c00060070018006006900a600060001801c0052038c00e007001801600690006006901260003003800c003003483281300020141801c005203ac00e006027801c006001801a403d800180006007001480f3003801804e00700180060069040404af3c4e302600060001801c005203ec00e006013801c006001801a4101416f0fd20b80018000600700148103003801c006005801a403501c3003800c0030034812b00030000c00e0029021600f003800c00a01ac00e003000c00ccc08d20d00f4800b00030000c0000000000803c00c016008401e006009801c006001801807e0060298000c000401e006007801c0060018018074020c000400e00f003800c00b003010c000802180020070018006006019801805e0003000400600580180760060138000800c00b00330134805200c400e00300080330004006005801a4001801a410112f58000801c00600901260008019806a40118002007001800600690404a75ee01e00060008018046000801801e000300c4832004c025201430094800a0030028052003002c00d2002c000300648010c0092002300748028c0312000300b48018c0292012300948008c0212066801a40018000c0192008300a2233335573e00250002801994004d55ce800cd55cf0008d5d08014c00cd5d10011263009222532900389800a4d2219002912c80344c01526910c80148964cc04cdd68010034564cc03801400626601800e0071801226601800e01518010096400a3000910c008600444002600244004a664600200244246466004460044460040064600444600200646a660080080066a00600224446600644b20051800484ccc02600244666ae68cdc3801000c00200500a91199ab9a33710004003000801488ccd5cd19b89002001800400a44666ae68cdc4801000c00a00122333573466e20008006005000912a999ab9a3371200400222002220052255333573466e2400800444008440040026eb400a42660080026eb000a4264666015001229002914801c8954ccd5cd19b8700400211333573466e1c00c006001002118011229002914801c88cc044cdc100200099b82002003245200522900391199ab9a3371066e08010004cdc1001001c002004403245200522900391199ab9a3371266e08010004cdc1001001c00a00048a400a45200722333573466e20cdc100200099b820020038014000912c99807001000c40062004912c99807001000c400a2002001199919ab9a357466ae880048cc028dd69aba1003375a6ae84008d5d1000934000dd60010a40064666ae68d5d1800c0020052225933006003357420031330050023574400318010600a444aa666ae68cdc3a400000222c22aa666ae68cdc4000a4000226600666e05200000233702900000088994004cdc2001800ccdc20010008cc010008004c01088954ccd5cd19b87480000044400844cc00c004cdc300100091119803112c800c60012219002911919806912c800c4c02401a442b26600a004019130040018c008002590028c804c8888888800d1900991111111002a244b267201722222222008001000c600518000001112a999ab9a3370e004002230001155333573466e240080044600823002229002914801c88ccd5cd19b893370400800266e0800800e00100208c8c0040048c0088cc00800800505a182050082a0821a000624241a05557d98f5f6" + }, + "a678733d3723ae7b471638129a27c1b37551d906d086bfc81bde308fec0f0475": { + "note": "V2 spend x2 + V2 withdraw-zero (batcher)", + "cbor": "84ad00d9010283825820b94d8b89068bf73175fcbb0579fb8e5ae2bef525a1b4bdf7965aa64599029ad701825820c87690a1017a295e89fa0a710354be419a0cb1164b537aca44265ff7e7f2778400825820d0b368413081d1a937f393483f00f4b15b0f683751f4df490901fe0f646fac5002018382583901ee0549556e74902f468738d5a436fd8688d749fad876cc99c22b012c0556b96c54b6718bfeaa22f980722928758521b01f8c964f48bc480b821a001e8480a1581c14628c260a749e78e69e58acca32d0baa09808d50388c84fa7e1ae47a1480014df10424f4c541b000000f20836efbea300583911ea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6b52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c201821b0000000792d1a727a2581c14628c260a749e78e69e58acca32d0baa09808d50388c84fa7e1ae47a1480014df10424f4c541b00003dcc0f55f98d581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca2434d5350015820e8286e20af16ff2dc1e41ff2d3e880033611026752702e7b8036a76fa9c824551b7ffffeed7b276c8e028201d8185884d8799fd8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affffd8799f4040ffd8799f581c14628c260a749e78e69e58acca32d0baa09808d50388c84fa7e1ae47480014df10424f4c54ff1b0000011284d8937b1b000000079000ed091b00003db57dfbb09518c818c8d8799f190682ffd87980ff825839015b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b533b9586f0fb9aafd578e0d0154e9478d23614e736eb39d1a30d8a991a0dc1fb10021a000a2ec7031a0bf5ee1105a1581df11eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a000758205160f88b929bf8a6c57c285b889488f9137c0ef3cfd0bcf408a10020e69146d5081a0bf5ed5d0b5820e57347a2cceb45efaa398cb2f101f5c393ec25d72d1fdce75dd264b63bbf18820dd9010281825820d0b368413081d1a937f393483f00f4b15b0f683751f4df490901fe0f646fac50020ed9010281581c5b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b10825839015b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b533b9586f0fb9aafd578e0d0154e9478d23614e736eb39d1a30d8a991a0d615a17111a004c4b4012d90102848258200dc17712e37a4e741767db2f90d4ffbf69faf88b9bed4c47864f7bd912924bea00825820cf4ecddde0d81f9ce8fcc881a85eb1f8ccdaf6807f03fea4cd02da896a621776008258202536194d2a976370a932174c10975493ab58fd7c16395d50e62b7c0e1949baea00825820d46bd227bd2cf93dedd22ae9b6d92d30140cf0d68b756f6608e38d680c61ad1700a200d9010281825820c5d63d7dc066df52592135b6d3cb4f3470d06f7bdd4b2d2e32eb59ca3782662f5840271c9ebedd435c6cc30abb78e81999f4a0617a358d49203a07ea2e81521d8ee40e1e4418188fa186e79da712086691a85c28edb66ecc05afcf80217b82e518090583840001d87980821962d91a007cc793840000d87980821a00012dfc1a0166fa60840300d8799f009f1a001e8480ff4100d87a809fd87a80ffff821a00143bbd1a197896b8f5a11902a2a1636d736781774d696e737761703a204f72646572204578656375746564" + }, + "04a7701c760202acf7e341a1ff2dd481458b84299f5f6db5c8106fb1df856595": { + "note": "V1 spend (recent)", + "cbor": "84ab00d901028382582074cc9b48c67a856b89cf95a5eea0d197d1cb5aa79012f54b914694ca1411a78f00825820874fa267df8995e545767b58e0a9dc63171cd7d2b69d8b5026263fe07d80837800825820da9d07cec0122fb06008102c6bc18ab062c837eae49418213d5ff6fe1c82c8dc02018383583911e1317b152faac13426e6a83e06ff88a4d62cce3c1634ab0a5ec1330952563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c2821b00000020964dbbd4a3581c0be55d262b29f564998ff81efe21bdc0022621c12f15af08d0f2ddb1a1582063f2cbfa5bf8b68828839a2575c8c70f14a32f50ebbfa7c654043269793be89601581c13aa2accf2e1561723aa26871e071fdf32c867cff7e7d50ad470d62fa1474d494e5357415001581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b1a028313c55820ed356898284f865193d08d5d3bebde5eab1b1ba43759a4bd76b8c2a73c08165482583901cfa9564f5a5ec20983c755f858955167e96346a4075953af3dd24b2a00b811995cc52412439f1b6db485ba3263e060740af3e224a39ded42821a001e8480a1581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b19eae082583901bcb15ea0ccde1a3e6bca4472724e4017e173f94e97a60d3e682a6e9d52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c2821a039a691ca1581c2f2e0404310c106e2a260e8eb5a7e43f00cff42c667489d30e179816a14d3137393938393630363433373201021a000bb3cf031a0bf613e00758205160f88b929bf8a6c57c285b889488f9137c0ef3cfd0bcf408a10020e69146d5081a0bf60ff80b58209939d3332e524e4dec239b4055ce202d422e8150ffa409503c03d340e8021f890dd9010281825820da9d07cec0122fb06008102c6bc18ab062c837eae49418213d5ff6fe1c82c8dc020ed9010281581cbcb15ea0ccde1a3e6bca4472724e4017e173f94e97a60d3e682a6e9d1082583901bcb15ea0ccde1a3e6bca4472724e4017e173f94e97a60d3e682a6e9d52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c2821a033b4d2ba1581c2f2e0404310c106e2a260e8eb5a7e43f00cff42c667489d30e179816a14d3137393938393630363433373201111a004c4b40a400d90102818258200303157375f23161027902abff500a6268c00a69f45f7bfe5adbc2f70f15df945840651110b8bf3e94d39f117a54552c66134f67d767f001b9f5bea73d63105131da39c98378137c81eb23d3f656e606646ae0cc88997b777b455cfb59a238f3880e03d901028259014f59014c01000032323232323232322223232325333009300e30070021323233533300b3370e9000180480109118011bae30100031225001232533300d3300e22533301300114a02a66601e66ebcc04800400c5288980118070009bac3010300c300c300c300c300c300c300c007149858dd48008b18060009baa300c300b3754601860166ea80184ccccc0288894ccc04000440084c8c94ccc038cd4ccc038c04cc030008488c008dd718098018912800919b8f0014891ce1317b152faac13426e6a83e06ff88a4d62cce3c1634ab0a5ec133090014a0266008444a00226600a446004602600a601a00626600a008601a006601e0026ea8c03cc038dd5180798071baa300f300b300e3754601e00244a0026eb0c03000c92616300a001375400660106ea8c024c020dd5000aab9d5744ae688c8c0088cc0080080048c0088cc00800800555cf2ba15573e6e1d200201591e1b591e1801000032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232222323232533533355333573460cc0042646424446600200a0086eb4d5d09aba25002375a6ae85400454ccd5cd1832801099091118010021bad357426aae7800c54ccd5cd1832001099190911198018028021bad357426ae894008c0ccd5d0a80082e1119191a827911111a80391191111aa99a9824806108008b1119191a9a9a80103102d91191919191919191aa99a982a89119982d91299a99820a8071a80103409980200100088008008020b03c9111919191919191919191919191982a299a8050a99aa8100999ab9a3094013303a307908101330820106700b06706e0060063305433355307908101305c05a305953353502b2233500206e2071210011635014222222207a33054353501422208e0122350012322533355333500a2153335004215333500c2130054984c011261533350052130054984c0112603c04f15333500b2130044984c00d261533350042130044984c00d2603b153335003205003a04f153335003215333500b2130044984c00d261533350042130044984c00d2603b04e15333500a2130034984c009261533350032130034984c0092603a153350010700820108201070253335002215333500a21533350042133303a03b00200116161604e15333500921533350032133303903a00200116161604d04e33054330500153306f00b02733054330533306301602548008cc150cc14ccc18c02c0952002330543305333063001010002330543333084012222533500315335002135001222223305d3305c0053306c01402b3305d3305c0043306c01402a3305d3305c00300c3305d3305c0023306c0140193305c001304800d0910122153350041622153350071622153350081622133300c003001323232323232323533307d0050070272222225335330743305100248000cc1440052000161333335003235500b2222223501d222223501b22235051222223232323232323232323253353308b013308b013306e00848000cc1b802920003308b013306d00201e3308b0153353306e00a00315335330870100e07013308b013308a013309a010110703370066e0402800c060cc22804cc2680404411c0084cc22c04cc22804cc268040441c0060cc22c04cc22804cc2680404411c008cc22804cdc08050019984d008088070a99a998370041a802055008a99a99843808078380998458099845009984d0080883819b80337020106a008154020306611402661340202208e00426611602661140266134020220e003066116026611402661340202208e004661140266e04020d40102a804cc2680404403c4cc22c04cc22804cc2680404411c008cc22804cc268040441c0060cc22c04cc20c04048070cc2080404006c4c8c8c8ccccc2c80400c008cdc019b800180050013370002e002a66a0082605466e0800c00840594cd400c4cccc0a005406406005c520003370002e00866e0005cd40102a80458c27c04028d40082a404d40042a404d54cd54ccd5cd19b8900148000278044c94ccd5cd19b890014800027c044c94ccd5cd19b8900148000280044c28404ccc2ec0400c0080054ccd5cd19b8900400610041006350020ad0121001160b901350010b0015333573466e2400400c54ccd5cd19b880010031330a8010023370666e080080400444cc2a0040080104cc2a004cdc199b820040110100043370666e080040380414cd4cc1fc01c1a04cdc0998490081100399b80011010133092010220073370666e080040300354cd4cc1f40101984cdc0998480081000219b8000f00e133090010200042235500c2222223501e222223501c222350522222232323232323232323253353308b013306e00748000cc22c04cc1b4008074cc22c04cc22c04cc22804cc2680404011c008cc22804cc268040401c005ccc22c04cc20c0404406ccc2080403c0684c8c8c8c8ccccc2cc0400c008cdc019b800180060013370002e002a66a00a2605666e0800c00840594cd40104cccc0a405406406005c52000350020b301350010b60153353330690870100e01e1330ae013370002c00e02a26615c0202c66e0005401c58c27c04024cdc199b820010123370200400266604400600266e0ccdc0981219b803370400400466e08cdc119b82337049004241941e90680780200198600080199b824801120ca0f350050a70130be01001350030a60153353308001001069133702661260204600266e000440404cc24c0408c004d400428804d54cd4ccc1801f80140544ccc2d0040140340304ccc2d004010030034888d400c88ccc2e40401401000c88d54030888888d407888888d4070888d414888888c8c8c8c94cd4cc21804cc1a4009200033086013306800101833086015335330820101906b13308501330950100b0193370000202426610c026610a026612a020160320026610a026612a020160d60246610c02660fc01802c660fa01402a2a66a6660c8104020120322666661540266e00044008cdc08080008078070068999998550099b810110013370002000401e01c01a2c66603e6a00614c026a00614a02002a66a66100020020d2266e04cc24c0408c004cdc0008808099849808118009a800851009aa99a99983003f00280a899985a00802806806099985a008020060069111a8019119985c80802802001911aa8061111111a80f111111a80e1111a82911111191919191919299a998440099835802240006611002660d80020086611002a66a66108020360da266110026610e026612e0201a03666e00068050cc21c04cc25c04034014cdc08020008a99a99842008028368998440099843809984b8080680d80d19843809984b8080680299b8033702008002028266110026610e026612e0201a03603466110026610e026612e0201a00a66e04010004cc21c04cc25c040341b4050cc22004cc20004038060cc1fc03005c54cd4ccc1982100402c06c4ccccc2b004cdc000980099b8101201a01101000f1333330ac013370202603466e0004800404404003c594cd54ccd5cd19b880190011309f013370066e0ccdc119b82002019483403ccdc119b81001019483283d200209e012100116350040a601350030a60153353308001001069133702661260204600266e000440404cc24c0408c004d400428804d54cd4ccc1801f80140544ccc2d0040140340304ccc2d004010030034888d400c88ccc2e40401401000c88d54030888888d407888888d4070888d414888888c8c8c8c94cd4cc21804cc1a4011200033086013308601330680030193306800201833086015335330820100906b133086013308501330950100b009337000060246610a026612a020160100042a66a66104020100d626610c026610a026612a0201601066e00008048cc21404cc2540402c02400c4cc21804cc21404cc2540402c02400ccc21804cc21404cc2540402c020008cc21404cc2540402c1ac048cc21804cc1f8030058cc1f40280544c8c8c8ccccc2b40400800ccdc019b8101200700133700022002a66a0082604a66e0800800c40414cd400c4cccc08c03c04c048044520003370202400866e0404000858c26804010cdc199b8200200e00d3370666e08004038030cc244040840f8888c8cdc199b820010033370066e0801120d00f0013370400290650791112999ab9a3371200890000a4000264a666ae68cdc48008028a4000264a666ae68cdc4800a400029000080099b833370400466e04004014cdc019b8200148028014c014cdc10018011192999ab9a33710004900004d808a999ab9a30a10100214800054ccd5cd1851008010a40042a666ae68c28c04008520021330010023370066e0c009200448008c254048894ccd5cd19b880010021330030013370666e00cdc1802000800a4008200426660f2002006046464a666ae68c27c04d55ce80089919191919191919191919191919091999998008050048040028018011bad357426ae88008dd69aba100135744010a666ae68c2b4040084c8c8488888cc01001c018dd69aba135744a0046660f8eb9d71aba15001153335734615802004264642444446600200e00c6eb4d5d09aba25002375a6ae85400454ccd5cd18558080109909111118028031bad357426aae7801854ccd5cd18550080109919091111198010038031bad357426ae894008ccc1f1d73ae35742a0022a666ae68c2a4040084c8c8488888cc00c01c018dd69aba135744a0046660f8eb9d71aba150010a101135573c00a6aae74010cc1e1d71aba100530743574200a60e66ae84014dd51aba1001357440026ae88004d5d10009aab9e0010970137540026a002104026a00810c0260c82446660d444a66a660b066606c0d46a004104026a03c1040266606c0a06a6a0040fc0ee05e266008004002200200202a60c82446660d444a66a660b066606c0a06a0040ee6a0200ee66606c0a06a0040ee05e2660080040022002002026666660f0660c602c044660c602c04203e660c602c02003c660a8660a0044607a008660a8660a0042607c008660a8024660a8a66a60c82446660d444a66a660a66aa03a104026a6a0040ee1040226600800400220020020260d6442a66a0020fe440dea66a6660640a600490000998299981d183c840809984100833800a40042660a66607460f21020266104020ce0029000183800999b8100101d303d001333066059008010305333307f22322325333573466e1c010dc680488010a999ab9a308f0100415333573466e1d205a500313370290001980299b800044800800800400454ccd5cd19b885002481805854ccd5cd19b8950023370090302402426600866e0000d20023370066e08005201433702a00490300b099b8e0060014800120001533500400115335501a13335734611a026606860e60f6660f80c200a0c20d00022a66a0062a66aa0320022666ae68c23804cc0ccc1c81e8cc1ec18001018019c0044ccd5cd18460099819183883c9983d02f80182f833299a9983d91299a8008321109a80111299a9982b00101089834800898030019a9a99a983b03c00501083883610a99a8008b1109a80111299a8018a99a99827800a400420042c112022c666ae68cdc49982c800803240000c80ba6a0020d4a66a60b02446660bc44a66a66088a0226a0040d6266008004002200200200e2c0f8660ce02c6a00a0d460c2006a66a60a42446660b044a66a660826aa0160e06a6a6a0040d80ca0e026600800400220020020060b2442a66a0020da440ba60ba0026a0280d4660b40020246a6a0080c80be26a6a0020c20b4a66a609601c420022c2a66a660600040320ba266060002032603200e6068010464646a09c4444464646464646464660706606a00c0066607066068660a600c018660a6006018660706606e6608e6a6a66a60c60ca00401e0bc0b2660b601201090011981c1981f998188070009981c1981b9811800a40006607066068604201c60420026607066068604401c60440026606e604801c6048002660706606a60aa00a09666070a66a609024466609c44a66a60a26a6a0040c40b8266008004002200200200809e442a66a0020c6440a6a66a609024466609c44a66a60a26a0040b8266008004002200200260b000e09e442a66a0020c6440a666609a08000600860a40066a0020aca66a608824466609444a66a660666a6a6a00e0bc0ae0c46a6a0040ae0c4266008004002200200260a80062c0d06a0100ba6a6a0020b00a6a66a608400c420022c603000c606600e4464646a09e44444a66a6a00e440a8426464646464646464646464646607e660700286660aa09000800c6607e66076014660b40060246607e6607e6607c6609c6a0040c00120106607e6606e6a0040bc6a01a0d26606c6a0040be6a01a0ca6607e6607866aa60ce0d846a00244660ca00466aa60d40de46a00244660d0004666a0026e012000700466e0000520000013304000b3500922330723306400233072330640010090540540033303f3303e3304e3535335306a06c0010160650603306200f00e48008cc0fccc0f0c1700181494cd4c13c488ccc154894cd4c160d4d40081a418c4cc010008004400400400c1588854cd40041a888168c168014cd4c1a01a800c04cd40041754cd4c12c488ccc144894cd4cc0dcd4d4030194178d40081784cc010008004400400400c581bcc160004d403418cccd4080178d4d4080188178004cc11800c004cc164020d4004170cc140004020d4d40041681554cd4c11001c8400458124c06401cc0d0020448004584d55cf0011aab9d0013754004444a66a6600600400207809c46a0020b0246666666600204044a666ae68cdc38010008020a999ab9a3371200400203203044666ae68cdc400100081b01e802802001912999ab9a337120040022002200444a666ae68cdc4801000880108008881f11199ab9a3371000400207406644666ae68cdc480100081c81911199ab9a337120040020620706607c91100488100223333550023303f2233350050480010023500304222337000029001000a4000660784446006600400240026607666076e01200070246a0024444400a46a0020a246a0024407246a0024406c464a666ae68c140d55ce8008991919191981e2999ab9a305435573a00626464646464646464646464646464646464646464646424666666666600201a01801601401201000e00a00600460446ae84d5d10011980f1981dbae2001357420026ae88008cc071d71aba100135744016a666ae68c190d55ce804899191919827a999ab9a306735573a004264660a066038eb4d5d0800980d9aba1357440026aae7800817d4ccd5cd18339aab9d001132330503301c75a6ae84004c06cd5d09aba200135573c0020be6ea8d5d09aba200237546ae84004d55cf00482e1980c9981b019bad35742014660300326ae84028ccc059d70029aba100a33301575c0086ae84028cc054008d5d08051980a1192999ab9a306035573a0022646609260326ae84004c010d5d09aba200135573c0020b06ea8004d5d08051192999ab9a305f35573a002264646660b060606ae84008ccc059d70029aba10013303375c6ae84d5d10009aba200135573c0020ae6ea8004cc045d73ad37546ae84004d5d10009aba2001357440026ae88004d5d10009aba200135573c006098a666ae68c15c0044c848888c010014c02cd5d09aab9e00215333573460ac00226424444600400a60486ae84d55cf0010a999ab9a3055001132122223001005300c357426aae7800854ccd5cd182a0008990911118018029bae357426aae78008130d55ce8009baa357426ae88008dd51aba100135573c0020906ea80048c94ccd5cd182800081e8a999ab9a304f00102b04735573a6ea800488c8c94ccd5cd18290008058a999ab9a3051001130193004357426aae7800854ccd5cd18280008050241aab9d00137540024464460046eac004c10888cccd55cf8009014119198239981c98031aab9d001300535573c00260086ae8800cd5d0801020919118011bac00130402233335573e002404c46608860086ae84008c00cd5d100101f91919192999ab9a305300211222203515333573460a4004220922a666ae68c1440084c8c848888888cc004024020dd69aba135744a0046eb8d5d0a8008a999ab9a3050002132321222222233002009008375c6ae84d5d128011bae35742a0022a666ae68c13c0084c8c848888888cc018024020dd71aba135744a004603a6ae85400454ccd5cd1827001099091111111803804180e9aba135573c0062a666ae68c1340084c848888888c014020c074d5d09aab9e003045135573c0046aae74004dd50009192999ab9a304a35573a0022646606660086ae84004dd69aba1357440026aae78004108dd50009192999ab9a304935573a00226eb8d5d09aab9e0010413754002220582205444a66a00442a66a00442660240040020462a66a0024046068446a004446a006446666010008006004002446a004444446a00c44444a66a6601e01400a2a66a6601e0120082a666ae68cdc38040018a999ab9a3370e00e0042a66a00c42a66a004426a004446a004446a00a446a00444a66a666602e00c00a0040022a66a00e42a66a008426604800400206a2a66a006406a08c0680562a66a002405607805405405405444446466a00a466a0084a666ae68cdc78010008018121013919a802101392999ab9a3371e0040020060482a66a00642a66a0044266a004466a00446601200400244405444466a0084054444a666ae68cdc38030018a999ab9a3370e00a0042660220080020520520442a66a00240440664466a004466a00446601c0040024046466a004404646601c004002446a004446a00644a666ae68cdc780200109980780180081091199aa9815019180680191a80091199aa981681a980800311a80091199a800919805a40000020144660160029000000998030010009981280100a91199ab9a3370e00400202c03a44a66a00420020324466aa605205c46a002446604e004666a002466aa605a06446a0024466056004601800200244666010016004002466aa605a06446a0024466056004601600200266600600c004002444666aa605005c06466aa605205c46a002446604e0046010002666aa605005c446a00444a66a666aa6054064601a01646a002446601400400a00c200626606c00800602800266aa605205c46a002446604e0046606844a66a002260120064426a00444a66a6601800401022444660040140082600c00600800442444600200842444600600844666ae68cdc780100080800b9980e80080a11299a801012080091980e11199a8018128010009a80080f9192999ab9a303435573a00226464646466666042666016eb9d71aba100433300b75ceb8d5d08019bad357420046eb4d5d0800998051192999ab9a303a35573a0022646604660146ae84004cc035d71aba1357440026aae780040c8dd50009aba1357440026ae88004d5d10009aba200135573c0020586ea80048c94ccd5cd18199aab9d0011323301c3005357420026600c0086ae84d5d10009aab9e00102b375400246464a666ae68c0d00044c8c8c8c8c8488ccc00401801000cdd69aba1357440046eb4d5d08009aba2002375a6ae84004d55cf0010a999ab9a3033001130103004357426aae780080acd55ce8009baa0012323253335734606600226424460020066eb8d5d09aab9e00215333573460640022601e6eb8d5d09aab9e00202a35573a0026ea800488c8c94ccd5cd18190008980798021aba135573c0042a666ae68c0cc0040380a8d55ce8009baa001222325333573460626aae740044c8cc068c014d5d080098021aba1357440026aae780040a4dd5000911a8009119198131119a800a4000446a00444a666ae68cdc7801004898038008980300180298129119a800a4000446a00444a666ae68cdc7801003880089803001919a80081100211a800911a80111111111111999a805900b900b900b9199aa981101500b11a80091299a998090010020980c00180b805912999ab9a3371e6a0040346a0020342666ae68cdc39a80100b1a80080b001805003880b91180f11299a80088019109980300118020009299a800900b0019111a801111299a800909a8029111111111299aa99a999aa981001400a11a800912999ab9a3371e00401c2602c00602a0044260286a0020440244260240022c2c2006424460040066601444a66a0044200620020022018446602e44a66a00203c4426a00444a666ae68cdc78010038a99a8008111109a80111299a8018a999ab9a302d00113301400b0020262202813006003002235001222222222200a2350012201c23500122222222220092220032220012220023333300248811c0be55d262b29f564998ff81efe21bdc0022621c12f15af08d0f2ddb10048811ce4214b7cce62ac6fbba385d164df48e157eae5863521b4b67ca71d8600330014891c13aa2accf2e1561723aa26871e071fdf32c867cff7e7d50ad470d62f004881074d494e535741500048811c2f2e0404310c106e2a260e8eb5a7e43f00cff42c667489d30e179816004881054f574e455200221233001003002222221233333001006005004003002300b22112225335001135003006221333500500c300400233355300700f0050040012200130092211222533500110022213300500233355300700d005004001300822112253350010052213300f30040023355300600b00400111001220023005221225333573466e20005200013005490103505436001533500213005491035054370022153335734602c0062004266a600c01000266e0400d2002253357380022c240026004444a66a00220044426a004446600e66601000400c00200660024444a66a00220044426a00444a666ae68c0500044ccc02001c01800c4ccc02001ccc028ccc02c01c00800401800c8c8c00400488cc00cc00800800488488cc00401000c88848ccc00401000c00854cd5ce2490350543100162215335001100200715335738921001622222222007220053704904d0f910b111110021b8748000dc3a40046e1d2004370e90031b8748020dc3a40146e1d200c0104d901029fd8799fd8799fd8799f581ccfa9564f5a5ec20983c755f858955167e96346a4075953af3dd24b2affd8799fd8799fd8799f581c00b811995cc52412439f1b6db485ba3263e060740af3e224a39ded42ffffffffd8799fd8799f581ccfa9564f5a5ec20983c755f858955167e96346a4075953af3dd24b2affd8799fd8799fd8799f581c00b811995cc52412439f1b6db485ba3263e060740af3e224a39ded42ffffffffd87a80d8799fd8799f581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f44534e454bff19eaa4ff1a001e84801a001e8480ffd8799fd8799f4040ffd8799f581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3f44534e454bff1a2ec63a971a8b919d18d8799fd8799fd8799fd8799f581caafb1196434cb837fd6f21323ca37b302dff6387e8a84b3fa28faf56ffd8799fd8799fd8799f581c52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c2ffffffffd87a80ffffffff0582840000d879808219a44d1a00c2940e840001d8799fd8799fd8799f581cbcb15ea0ccde1a3e6bca4472724e4017e173f94e97a60d3e682a6e9dffd8799fd8799fd8799f581c52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c2ffffffff02ff821a0027239d1a2097baa2f5a11902a2a1636d736781774d696e737761703a204f72646572204578656375746564" + }, + "395de97f23592e2572f6100e2f2ca73ae16899fdf32ab2c3d7747a585be6fc84": { + "note": "V2 mint", + "cbor": "84ab00d901028c8258200187c27c56ca4b0fc919847b98f0cf44b1ab0fd17f8098f45ed55ae6e2c8852c0082582006b3f6efe3f999a9acc16ebc9ad0a2842a668c99ee46fd9d7a5708d3d38d96b101825820208c408210efc2a60e334ebc6a8d8967616e184556accbc32babcc25b932a07700825820357fa9da0a4ff16c9873017a0c5022e6ffefae68837955fe07b86c874186f22a0082582042266e62ad184b5e73db6b7984129fba522775cba6941359acb56fb98ecebb90008258207085754a43f982eced81a618730397dfd7808c044f60ebbddc47b64add6371da0082582088620e57ea3420141cbc97b2eb95d0cd792952165ad6f262c56bb0ec9d5657470082582097ee30842b4290a3dd029b7ae4543e6286369c7ec688edbe4685795128bd2c0b00825820a9fa71325c2c957590a8849eee9c1439e4531b517448e7710f540fe3afdeaa2000825820ce1ff6743992b17ceb88a4f00d09823a026cd1535e815c03df96bffd1e540a1a00825820daa93255e662fc1eff97e1c07c4b6a6cc5eeab1ee8b37b7c287f6305e651d95801825820fa32cf89414d64895392fe69bfe1d2ec82f04f04ad3b37f41a143f1e4a08bbc3000185a300583911ea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6b52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c201821b00000002683b6bfea2581c4849e0d9e18061861a88acea1df431b3e39cdf65dfeee22ba024315ba14c0014df104941474f4e4155541b0000bbfa2de96c5c581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca2434d53500158209d48780bf2ea2c2e21a51d1274f7d55fce6e011fb9f9e7987a1f2da123633adf1b7ffffeabb98cecb2028201d8185884d8799fd8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affffd8799f4040ffd8799f581c4849e0d9e18061861a88acea1df431b3e39cdf65dfeee22ba024315b4c0014df104941474f4e415554ff1b00000154467313571b0000000267f6c1de1b0000bbfa2de96c5c18c818c8d87a80d87980ffa3005839117bc5fbd41a95f561be84369631e0e35895efb0b73e0a7480bb9ed73052563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c201821a00169f90a1581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca1434d534601028201d8185848d8799f58209d395c7a2793ad4e7d41a3d0dc9e3bdb446cc514fe48f10fb4c211f7dbb3a2ae58209d48780bf2ea2c2e21a51d1274f7d55fce6e011fb9f9e7987a1f2da123633adfffa3005839117bc5fbd41a95f561be84369631e0e35895efb0b73e0a7480bb9ed73052563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c201821a00169f90a1581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca1434d534601028201d8185848d8799f58209d48780bf2ea2c2e21a51d1274f7d55fce6e011fb9f9e7987a1f2da123633adf58209d4dfc2751fe2f0eb0260f0ac0019235ac0751b043d9a7d0d97737333632cb5fffa200581d618a0cfe6369fd7d7295e684ab2d6e8f62398a3bb53fffbe788ec6703301821a001f3d12a5581c4014ddf1ec8376be044bf1e2cd8d886da61c6d993045aae83eb1631fa1480014df10464f4e451b0000238d556750a2581c4849e0d9e18061861a88acea1df431b3e39cdf65dfeee22ba024315ba14c0014df104941474f4e4155541b0000002c3ce1c324581c8dbfe71fb02edeed46cbc4548b821ca2e3e281cfc47f2baf121a885fa1480014df10484947481b0000a65ea0ca479e581c8ec717891d0e73ede10ff8a7fe10ef6f0afba4cc4cf4c85ce5ce8e06a1480014df10484956451b000007958fdd0c07581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca158209d48780bf2ea2c2e21a51d1274f7d55fce6e011fb9f9e7987a1f2da123633adf1b000001544673134da200581d618a0cfe6369fd7d7295e684ab2d6e8f62398a3bb53fffbe788ec67033011a0482c86f021a00078570031a0bf6130e075820e9eec164573b6ec63b92f5f6a3ad5bad8c56976e3ba3aa31f2eeab0b4532d8ac09a1581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca3434d534601434d53500158209d48780bf2ea2c2e21a51d1274f7d55fce6e011fb9f9e7987a1f2da123633adf1b7fffffffffffffff0b5820e75a8eb81b59bd967a26c66c2e046b77cbd701bb73febf4ec69dc95e1de45d900dd90102818258200c50ba7346db9d77cd766cfcfec934b1ec4ded2074b7a4192776dbcfa55652e10110a200581d618a0cfe6369fd7d7295e684ab2d6e8f62398a3bb53fffbe788ec67033011a060281cb111a000b482812d901028282582059c7fa5c30cbab4e6d38f65e15d1adef71495321365588506ad089d237b602e000825820dbc1498500a6e79baa0f34d10de55cdb4289ca6c722bd70e1e1b78a858f136b900a200d901028182582012bd6f0ab4eba51f138190cbeb21a44b9ae4190e97789a4e79abac97dff5ed88584056502cc5500cf5ef385265a1d56fe932e74686b18f0a574d2254adf1842813dbb82430acd0c4621897c50dd5942833471ce3fc4a8338859073d3812b02ab300f05a282000182d8799fd8799f4040ffd8799f581c4849e0d9e18061861a88acea1df431b3e39cdf65dfeee22ba024315b4c0014df104941474f4e415554ffff821a000f284e1a14e34e4f82010082d87a80821a000803911a098bdbdbf5d90103a100a11902a2a1636d736781781853444b204d696e737761703a2043726561746520506f6f6c" + }, + "19fdc58d77544633a6ec094d7a788f49b0e3a0f2e2b96d76ed3948f46d7f006b": { + "note": "V3 mint", + "cbor": "84a900d901028282582037b7d117f8a7e209fcb8f2970012d0a2e1c3ac94cb37525789113f060066ac740082582037b7d117f8a7e209fcb8f2970012d0a2e1c3ac94cb37525789113f060066ac74020182a300583911faecb80eee6cadf9dac5184263ed4d164b38fe71d4f6f55e8f6b0da024562b0f217780656562b4a7b0517d60c421fa805e35204127c4b4ee01821a001a1bdea2581c5d16cc1a177b5d9ba9cfa9793b07e60f1fb70fea1f8aef064415d114a1434941471b00000004a817c800581cfaecb80eee6cadf9dac5184263ed4d164b38fe71d4f6f55e8f6b0da0a14c6ad37161ea3045f1394976df01028201d8185848d8799f581c7a01ba06739ebf77bf54c3a65a829ba0d1b4f094df241daff3f941784c67b79e2a46d1355d5fb340fd4c6ad37161ea3045f1394976dfd87b801b000000dfb7f53ad4ff825839017a01ba06739ebf77bf54c3a65a829ba0d1b4f094df241daff3f94178687b60bf9d95e13bbbff08f3bae69d77f52771c19746436c5ae45813821a2e603987a6581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b1a00038761581c561b992c34312bff7d9267a48cc440ad927a9886e4507413c1ac8f65a15143617264616e6f506f69736f6e3038353901581c5b26e685cc5c9ad630bde3e3cd48c694436671f3d25df53777ca60efa1434e564c01581c5d16cc1a177b5d9ba9cfa9793b07e60f1fb70fea1f8aef064415d114a1434941471b000000050adf0cb5581ce5a42a1a1d3d1da71b0449663c32798725888d2eb0843c4dabeca05aa151576f726c644d6f62696c65546f6b656e581b0000000276e733b1581cff560c1dc3c7d9af1cba0a624a10c2e256b5c5b8334007cee818bd51a14442574d5401021a00054f91075820bdaa99eb158414dea0a91d6c727e2268574b23efe6e08ab3b841abe8059a030c09a1581cfaecb80eee6cadf9dac5184263ed4d164b38fe71d4f6f55e8f6b0da0a14c6ad37161ea3045f1394976df010b58208632dce51c4944c7549edac1e4898f93116d7c3f33a383d54f32f6fa674b71bb0dd901028182582067e7d731df9a581d9a11f75dd6c7959682e31defcf1e4110f7619902b3c3243e000ed9010282581cf161800395791ac0cbad18de1e0798011fee3fc1e1e8729320ba02af581c7a01ba06739ebf77bf54c3a65a829ba0d1b4f094df241daff3f9417812d9010283825820130369784436e1b705b89780024e4bee171ab488294bc8a56cb5c31acc4d318c01825820c741b75125f18d37c6db670580bfbeae98006a58dfed76419432383da4ad893a00825820e933a1cded17a97bc73c7147e01fb955f93467cdc15d5f80094942504bfa18c200a200d9010282825820bd6eada5706efd1f6bba9e00f482d62193d31e4ae6efc5d2fc2eaa234711a3c25840d8807156c84896a02116e391c0dc026f03514eacf8ed12d30b8f1424bf571f999a349e00ecbabf2162850d28aecb5582f454c1b98d35444826df33d5c11343008258206672caa602662d9e8864e9b6d63e8e7ee7ff8d0ba1b8753b6a280bd1411b1bdd58409ba37035dcc704040c5560ad1db0bfc7919be9e06e378b567e2585dfa1553907e5d01dd1eb88cf4c9aa051209a93a12f376fcc5873d3996994548bc10295da0905a1820100829fd8799f4c6ad37161ea3045f1394976df1b00000004a817c800d87a80ffff821a0006eafd1a085595e6f5d90103a0" + }, + "8ea558a1988e32d713469654b8e48c6d8e3f191ee864a5ffdc3ba9a4ecaf3387": { + "note": "V3 spend", + "cbor": "84a800d90102828258206de215e9bf72723e659a2ffb8bf2219da88c843fb2011af98d91623ba55eced7008258206de215e9bf72723e659a2ffb8bf2219da88c843fb2011af98d91623ba55eced7010182a300581d71c4f37d2bc1a955cfdc33597b3c089179ebeffdc624bbcf05b33950ef01821a0017156aa1581cc4f37d2bc1a955cfdc33597b3c089179ebeffdc624bbcf05b33950efa1563637366336663632363136633264363636313732366401028201d81858589f4631303139333645393138363349454142463231303037190b425835697066733a2f2f516d61675a3541393441596d37487747314773427348637059736d555557674843484862776a5143634c4a7234390043d87980ff82583901e9b56233ca537ab6b3096e886f4a54fbd2fb92b4b3bf52d8e1e71c2a83c16d3d0350fd7927937389b91419299f913ee8515168ab3fa6e5dd1b000000010081d1bc021a0002eebc0b582025f850955998248abf23abbb59eefe4707b39b7ac60423afc9733bc1df0ea45f0dd90102818258206de215e9bf72723e659a2ffb8bf2219da88c843fb2011af98d91623ba55eced7010ed9010281581ce9b56233ca537ab6b3096e886f4a54fbd2fb92b4b3bf52d8e1e71c2a1082583901e9b56233ca537ab6b3096e886f4a54fbd2fb92b4b3bf52d8e1e71c2a83c16d3d0350fd7927937389b91419299f913ee8515168ab3fa6e5dd1b0000000100387538111a004c4b40a300d9010281825820ec387675c7182d02db52b52abaedd7029afe8e2c15abf08c19777e3c052a96845840e82309638570363e6aecc679262d89722394127000499b3269480fdb3b1c9db0c3369ca640a3653285edde96f278e8b6ea3d59c9f6871e08143e6f11b09c86060581840000d8798082192d7f1a00437efb07d9010281588f588d010000322533357346646460044660040040024600446600400400244a666aae7c0045280a999ab9a3375e6ae84004010528898011aba20013237586ae84c8d5d11aba2357446ae88d5d11aba2357446ae88004c8d55cf1baa00100135742646aae78dd50008008a4c2c98011e581ce9b56233ca537ab6b3096e886f4a54fbd2fb92b4b3bf52d8e1e71c2a0001f5f6" + }, + "6cb0ce3449fd3a8258a9a0344745f720f60f8291d670d9e9c7fc7aff00bbef2e": { + "note": "V3 withdraw", + "cbor": "84a900d901028382582070d2d88a7933980cd640e7b922a36bc0ce1631d19de9ef86aa09006c2631795a00825820c73106815da2ceef7ff578a08d5de508df4ea27faab942268cc2185b10295ea302825820f9d686166ddc2be48de3f56f6ebe647e44898710eca582298c3b490a7df4342e030183a300583931d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bbfc3a49de39c60c33bdb77452ca00bdf64067604bed87534157c2b1ad01821b0000000c90146f28a2581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1faa1454e494748541b0000003358ce7cb4581cd8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bba15820e52e7facd0b8784e27b271082a030a60cdabef1619480a507896eeacb0c5c26401028201d8185885d8799f9f4040ff9f581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa454e49474854ff181e1a37f86de51b0000000129269add1a280f6ea0d8799f1b004b5d4bcf53aa881b002386f26fc10000ffd8799f1b005a93a7fa0d6b121b002386f26fc10000ff1a0005f9b41a00200fac1b0000002d0f3ee467190296ff82583901636d0d0118a8933ac167d4c448150bb325deaf7a4fdfb44adc7f2f5ae39b5f40aa85fbc121a625d777a776eca1cb4c923426949c997d8828821a001e8480a1581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1faa1454e494748541b000000024bc6a96d82583901636d0d0118a8933ac167d4c448150bb325deaf7a4fdfb44adc7f2f5ae39b5f40aa85fbc121a625d777a776eca1cb4c923426949c997d88281b000000029c59160b021a0006fffd031a0bf6141505a1581df1d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb00081a0bf612ad0b5820040040ba8eddc1fca7edbaf8dda82324105f9068ec94896f92314698941760950dd9010281825820eb99abdc7212c0914aee5d165f8cfe5390833157c8134a6ff996d07fd8104b7d0012d90102828258202cafd7c92f7093e5229af274be83dea660b0590b4174bbed79ba662b44fbd1ee0082582064d111b957e7d7848ffdde5149aa77fa4090a7fa1ad0ac10806790061484850100a200d901028182582062e8297eae4d6a606981e93afec220dda426b38d38c3f504efe7ea44288a61945840564e89a667a019a526cbb3c7fb88c39f7769b27d6b308f0fd6c8af0096ce7d132f806e9fab08679b3041e835d08667f611e3448fcdf2b8429df7de41e14f2a0e05a282000082582400030000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff30001822821a00025d781a040d990082030082582400030000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff30001822821a0011edd81a16f2a240f5f6" + }, + "1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4": { + "note": "Withdraw-zero (mainnet-withdrawZero.json)", + "cbor": "84ad00d901028382582023f462fbf5767947c0cb7169dbd3962e6705ce5cdf760a8ac342eb7b935b1329018258203c584cc0a84e6e7f5fdb9d6373af158cb26eb2c88f11a6ae15a573761e28112b00825820553144e0d3dc0c6afa05a152386a00d14479de4bc298fd403ab83337364ff141020183825839011deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709b56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4ae1a2374b876a300583911ea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6b52563c5410bff6a0d43ccebb7c37e1f69f5eb260552521adff33b9c201821b0000006ca96445e3a2581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235a145484f534b591b000011da5fc11ccd581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca2434d5350015820a449f98fa06fff1d6c17b95c65ca609660049dc683cc3e94d3846b05419fc20d1b7ffffda1f261fcf1028201d8185881d8799fd8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affffd8799f4040ffd8799f581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c23545484f534b59ff1b0000025e0d9e03181b0000006ca6936e6d1b000011da077123a118321832d8799f190682ffd87980ff825839015b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b533b9586f0fb9aafd578e0d0154e9478d23614e736eb39d1a30d8a991a191d2dea021a000a282a031a0bf5919005a1581df11eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a000758205160f88b929bf8a6c57c285b889488f9137c0ef3cfd0bcf408a10020e69146d5081a0bf590dc0b5820de0fac1485500135165f672fb603038799e6507a6cf3948ff36e0a9927c9bec50dd9010281825820553144e0d3dc0c6afa05a152386a00d14479de4bc298fd403ab83337364ff141020ed9010281581c5b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b10825839015b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b533b9586f0fb9aafd578e0d0154e9478d23614e736eb39d1a30d8a991a18bc8654111a004c4b4012d90102848258200dc17712e37a4e741767db2f90d4ffbf69faf88b9bed4c47864f7bd912924bea00825820cf4ecddde0d81f9ce8fcc881a85eb1f8ccdaf6807f03fea4cd02da896a621776008258202536194d2a976370a932174c10975493ab58fd7c16395d50e62b7c0e1949baea00825820d46bd227bd2cf93dedd22ae9b6d92d30140cf0d68b756f6608e38d680c61ad1700a200d9010281825820c5d63d7dc066df52592135b6d3cb4f3470d06f7bdd4b2d2e32eb59ca3782662f5840b1097de271abdde6d570f0ef7e13693337426094dcc7a2ab578e8cb85b24759abe4078184a018d4ab30893dcb38b11c5f80875eb5b46bf9e6d14fe2edd75fd090583840001d87980821962d91a007cc793840000d87980821a00012dfc1a0166fa60840300d8799f009f1a001e8480ff4100d87a809fd87a80ffff821a001465aa1a19a22709f5a11902a2a1636d736781774d696e737761703a204f72646572204578656375746564" + }, + "436d47e611aadb692a0d47e3467aa517143316251d59b24826c2d1b641049fd1": { + "note": "Mint (mainnet-mint.json)", + "cbor": "84ab00d901028a8258205cbbc0f0ddd999614243ee4ebd28b005a239c6cfe10e80bb1ce1a060aff96924038258205cbbc0f0ddd999614243ee4ebd28b005a239c6cfe10e80bb1ce1a060aff96924048258205cbbc0f0ddd999614243ee4ebd28b005a239c6cfe10e80bb1ce1a060aff96924058258205e777cbb99bc830ff9a41887d8a3b0ff07d8d86b82ad33805727ae7a227bf29d01825820843d8322bdd867e98df7c2edbf45f796bb62cb19dbdfba7015c6a720e84f82dc03825820849411c3f5b2da1bf903ee8113c0a934c57b82d6ed9243e4fdbc0d8d2c019a6403825820849411c3f5b2da1bf903ee8113c0a934c57b82d6ed9243e4fdbc0d8d2c019a6404825820849411c3f5b2da1bf903ee8113c0a934c57b82d6ed9243e4fdbc0d8d2c019a640582582094c1c47568b470ef5123b638ce625f8ac640b22ba9ea5943762471560cad6d7c03825820963cd05b847bc5003a568cd6c8c7229c4c74a3157b0290bc48f49b306eb1576501018182583901d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604cf9eaf5aa407e4b84e1dfe67fe5eae41c1c5a17de12335354f391120821a138858c3ad581c03b6ddacd60cc1ebd9ed4041d0c298c4c6f48ab61e04fdad4d915cfaa451000de14050424720766f756368657220330152000de14050424720766f75636865722031360152000de14050424720766f75636865722032350152000de14050424720766f756368657220393901581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1faa1454e494748541b00000001350bb607581c0c3b71e6468510341c813626cfa4d008d0b0c50d6ffcea188878e865a1530014df1059542d534e454b5f504552505f4c50190800581c1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34a14555534443781a002de0e0581c279c909f348e533da5808898f87f9a14bb2c3dfbbacccd631d927a3fa144534e454b19c523581c2da97f55d49be13dabc8450a2eabab0412f3075a03f7519d32d46925a1490014df1050554c53451b0000000221bff33f581c7246a32c1a6dad60da9139f06da8f1a99927f1e1d465f6e71b762e13a14350424702581c90dab650fe3c852487ea75418e060ebccb98cb84f23e1eedf8e6008ea1581b0014df1050756c736550542d4d696e737761704e494748544144411a96b8ade0581c93fc7ce799be81dc8dc02189ea79f50a629972c195ad3a2c37a4743da1490014df1050554c53451a0098967e581cc02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29afa14c746170707930322d3035343001581cc0becc47655739fc5bc6d299b868831f6a19d2b46e0ed1c840c2f05ca3581836343463633938633366623863663930393361623533636601581836343565323439303262363031653133613739383433323401581836343734356632623732386535353063343133383761313701581cf0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9aa14a68656c696f736c616e6701581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca15820e74c52975908a612d5ce68327040d449aae99f8b463bb6de046a1b23c57131691a022b5f2c021a0005d7cd05a1581df1d74de93a7e4940462c4509f59c712889422506f8b63dcfd0c266dc7b0009a1581c1f3aec8bfe7ea4fe14c5f121e2a92e301afe414147860d557cac7e34a14555534443783a92603a9f0b5820960a03cc6739bff8a9152cb09bef5a1c571d635db5557f865f6437f2246f4b190dd90102818258200213c86d232819bf584259aa160a1cbdafa4931785730d03f326dbf1e8966c89040ed9010281581cd4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e6041082583901d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e604cf9eaf5aa407e4b84e1dfe67fe5eae41c1c5a17de12335354f3911201b0000000b6e38c01a111a0008c3b412d901028382582076e8e5a5eb9ae1562b7c6afb042037f76c4b097c9857e95c9285a07c5e612ffa0082582086c9f9a54f11627a3b4c0b9577d0a5074eb366b08555192c1d6213239e2854e100825820d722c14b023979e92aae51978d9ead239ef3f94dc7131939d6a78a10c06eefc700a20082825820376b7329c2d17f43fe59e0b7dbe414705c13d00c9c885283749f4bd4da540ed7584038aca437e25f3a07d20712a60aca7482d1c757acd9415d3d86e72309fb1b7b0b1d8b8171d717d18d9762ccf28feff9b5e07b7347070b0d9f85b2d3a5ff582f0f825820fe982e19a91e83c1878dbee35a7d7a5785bd95269db91917e6d69a0412d42a61584000997830c43d6f4ffdc55ac4d6de0b56f43c4600d0aae84794fe31a5100016991fcd8515a90d3cb4751a2a0b99e0ab73f9dd63d40f0cf137c4b7c3c1acd19e0d05a282010082d8798082198f191a00e27dba82030082d87a9f5f5840070afbc2000000000000000000000000000000000000000000000000000000000190cf67000000000000000000000000000000000000000000000000000000005840001e8480000001c4ca85def700000001000000000000000000000000000000000000000077777777dcc4d5a8b6e418fd04d8997ef11000ee00000000000000005840000000002222222d7164433c4c09b0b0d809a9b52c04c205000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000584000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000866e992217e0bfb8371f9ad32a53dcc47d1aa04e0000000000000000584000000000b1f653c30f59172dad00879ff90f0d1e7b2d3949000000000000000000000000866e992217e0bfb8371f9ad32a53dcc47d1aa04e00000000000000005840000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000009241b620fec7887bd49527fb5840ed32400a46d8b1160b168de929810f1d3f19a3fe95adea0c000000706b20f62a00000001000027149ea9794d33dbcef3f77718e903816e877ab2577f4d7ee6535840638f1f60fc671dd600000001d4c35258ed54c3527e0078fa33871e986db11854d9cb4f779d89e6040000000000000000000000000000000000000000000000004c000000000000000000000000ffff821a0003adad1a056ca4bdf5f6" + }, + "1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde": { + "note": "V3 (mainnet-v3.json)", + "cbor": "84ad00d9010283825820c6cf76c3d2aa02bfcb5d499a834a7b5459ae12af141d51dabbbf9393440d584d01825820d35733f875afe519acfd0288d2ff5577a8103e4d26aa9ad610fa56ec1aebecc603825820fe6e9ad8f95ca32e319c13fe139bb4553f2bcdc9da2a2523b6475423aa5d27ad000184a300583931d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bbfc3a49de39c60c33bdb77452ca00bdf64067604bed87534157c2b1ad01821b0000000c0f672255a2581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1faa1454e494748541b00000035da0799fc581cd8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bba15820e52e7facd0b8784e27b271082a030a60cdabef1619480a507896eeacb0c5c26401028201d8185885d8799f9f4040ff9f581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa454e49474854ff181e1a3794ae191b0000000127500da01a27b0d3e0d8799f1b004b5d4bcf53aa881b002386f26fc10000ffd8799f1b005a93a7fa0d6b121b002386f26fc10000ff1a0005f9b41a00200fac1b0000002d0f3ee467190296ffa300581d71c3e28c36c3447315ba5a56f33da6a6ddc1770a876a8d9f0cb3a97c4c01821a003d0900a1581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1faa1454e494748541a09a693d3028201d8185901c2d8799fd8799f581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cfffd8799fd8799f581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cfffd8799fd8799fd8799f581c2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9ffffffffd87980d8799fd8799f581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cfffd8799fd8799fd8799f581c2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9ffffffffd87980d8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c5820697ad50acfc26540cb2c4ca02fa2a89aab8b5ad77672164ecf1799232336ea2cffd905029f82d8799fd8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c5820697ad50acfc26540cb2c4ca02fa2a89aab8b5ad77672164ecf1799232336ea2cffd87a80ffd8799fd8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c582014642f8656ab7122d915564a9f1813537426af54d486f86d9c82348b92d7e727ffd87980ffd8799f1a09a693d3ff1a01debfa5ff1a001e8480d87a80ff825839015d51cb941436e0f9ec7e58e17cd6161f61e0ef61899e393c364a9663f67137f8442aa24a92838cbaf17e7a5f48866d0a2127d81e870078371a000f42408258390136340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cf2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9821a05445599a5581c1c9cc348fcb844ba233b1d9582d177a99c25b0117cbb4ca14b9bcebba1581b4354565f5661756c745365656b65725f436f6d6d6f6e5f3032333201581c97582816c1b128480a10840f2ec96ff57f7eece61290fdc9a16309cda1444563686f1a008d7e58581cc02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29afa14c746170707930322d3039323801581ce992ef75f2367e6ecd93716ae88eba0d005dd91fd3a21f650b6496b5a14553555247451b0000000d22553250581cf0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9aa14968616e646c6531323301021a00077af9031a0bf59eac05a1581df1d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb000758202f19e9571e0f0b7a66c41459332a0b1ba53244b89e06f8571208b7d5fd95eba6081a0bf59d440b5820259d18bc5b39eae3a6abbab7003bf31a2df68e7986f9d67205d588b80d2ccdcd0dd9010283825820f12ff80165d30b86da14c56945f62817684dfbd8fbb719ebf9295d49e75b35d004825820f12ff80165d30b86da14c56945f62817684dfbd8fbb719ebf9295d49e75b35d005825820f12ff80165d30b86da14c56945f62817684dfbd8fbb719ebf9295d49e75b35d0060ed9010282581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cf581c661ae4b23b24ba9656d78b7637e6a66e889fa788c16c88017e4940521082583901661ae4b23b24ba9656d78b7637e6a66e889fa788c16c88017e494052c2c5baab297046f996aea6faa54eb92b6005bdb22c8288de08064e371a00a7267a111a003dbb4612d901028282582064d111b957e7d7848ffdde5149aa77fa4090a7fa1ad0ac108067900614848501008258202cafd7c92f7093e5229af274be83dea660b0590b4174bbed79ba662b44fbd1ee00a200d90102828258207de85658aabb026debffa9dfbc7aa09aa447c0e3da4fe24ed6f6f68a22ff8fa7584035592e51b9c1e520c2aac79d198b9c7ca89f7f07d6c83dce81e86239cca59fb31288810b231efe63af60b522049d07d2647291ce06e2177afb7ac6ad1dfa2f0382582005473eff70fd9868e387f101eac2beb2d856e216328c76d3180704bb453eaca05840f5aa15765ba678705e0d165b3cc9d9e9dbdeabdde1384cd067bc3da9db18885cd96c45223e8ccdce8871c2a02fda330743e04a9722406d0960d0e63d565fce0b05a282000282582402030200ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe51fc6d3821a00012dd11a018bd75782030082582400030200ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe51fc6d3821a001008c81a147934f2f5d90103a100a11902a2a1636d73678271537465656c537761703a20322e302e31306f506172746e65723a20457465726e6c" + } + } +} \ No newline at end of file diff --git a/applications/viewer/src/lib/julc/replay/fixtures/mainnet-v3.json b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-v3.json new file mode 100644 index 00000000..b81a6f3e --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-v3.json @@ -0,0 +1 @@ +{"hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","epoch":662,"tx":{"fees":490233,"ttl":200646316,"validity_interval_start":200645956,"required_signers":["661ae4b23b24ba9656d78b7637e6a66e889fa788c16c88017e494052","36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cf"],"inputs":[{"tx_hash":"c6cf76c3d2aa02bfcb5d499a834a7b5459ae12af141d51dabbbf9393440d584d","output_index":1,"address":"addr1qymrgr94r5al92tfzjsyrdmlecnwyqx6dg8juwz770xkfnetuksxal52w53p6vrc0s8f3zn8j4wstq6kgk6z6r44pmusnsctvh","stake_address":"stake1uy47tgrwl6982gsaxpu8cr5c3fne2hg9sdtytdpdp66sa7gk0wzjc","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"5544352"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"50479730"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"d35733f875afe519acfd0288d2ff5577a8103e4d26aa9ad610fa56ec1aebecc6","output_index":3,"address":"addr1qymrgr94r5al92tfzjsyrdmlecnwyqx6dg8juwz770xkfnetuksxal52w53p6vrc0s8f3zn8j4wstq6kgk6z6r44pmusnsctvh","stake_address":"stake1uy47tgrwl6982gsaxpu8cr5c3fne2hg9sdtytdpdp66sa7gk0wzjc","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"3939846"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"562334350"},{"unit":"1c9cc348fcb844ba233b1d9582d177a99c25b0117cbb4ca14b9bcebb4354565f5661756c745365656b65725f436f6d6d6f6e5f30323332","policy_id":"1c9cc348fcb844ba233b1d9582d177a99c25b0117cbb4ca14b9bcebb","asset_name":"CTV_VaultSeeker_Common_0232","quantity":"1"},{"unit":"97582816c1b128480a10840f2ec96ff57f7eece61290fdc9a16309cd4563686f","policy_id":"97582816c1b128480a10840f2ec96ff57f7eece61290fdc9a16309cd","asset_name":"Echo","quantity":"9272920"},{"unit":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af746170707930322d30393238","policy_id":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af","asset_name":"tappy02-0928","quantity":"1"},{"unit":"e992ef75f2367e6ecd93716ae88eba0d005dd91fd3a21f650b6496b55355524745","policy_id":"e992ef75f2367e6ecd93716ae88eba0d005dd91fd3a21f650b6496b5","asset_name":"SURGE","quantity":"56410583632"},{"unit":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a68616e646c65313233","policy_id":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a","asset_name":"handle123","quantity":"1"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"fe6e9ad8f95ca32e319c13fe139bb4553f2bcdc9da2a2523b6475423aa5d27ad","output_index":0,"address":"addr1x8vtd879xcmme7kmc3rfpqlhq67zj06dn53fvervtjsk0wlu8fyauwwxpsemmdm52t9qp00kgpnkqjldsaf5z47zkxks7gcmh8","stake_address":"stake1787r5jw788rqcvaaka699jsqhhmyqemqf0kcw56p2lptrtgexgmwc","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"51882395265"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"230840295631"},{"unit":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bbe52e7facd0b8784e27b271082a030a60cdabef1619480a507896eeacb0c5c264","policy_id":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb","asset_name":"asset1gt9tnvc5apz4344g3vw23tmxr9fq4x2mnv8d93","quantity":"1"}],"data_hash":null,"inline_datum":"d8799f9f4040ff9f581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa454e49474854ff181e1a3794ae191b00000001274dfd3a1a27af4d40d8799f1b004b5d4bcf53aa881b002386f26fc10000ffd8799f1b005a93a7fa0d6b121b002386f26fc10000ff1a0005f9b41a00200fac1b0000002d0f3ee467190296ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"list":[{"bytes":""},{"bytes":""}]},{"list":[{"bytes":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa"},{"bytes":"4e49474854"}]},{"int":30},{"int":932490777},{"int":4954389818},{"int":665800000},{"constructor":0,"fields":[{"int":21213203435596424},{"int":10000000000000000}]},{"constructor":0,"fields":[{"int":25495097567963920},{"int":10000000000000000}]},{"int":391604},{"int":2101164},{"int":193529308263},{"int":662}]}}],"outputs":[{"tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","output_index":0,"address":"addr1x8vtd879xcmme7kmc3rfpqlhq67zj06dn53fvervtjsk0wlu8fyauwwxpsemmdm52t9qp00kgpnkqjldsaf5z47zkxks7gcmh8","stake_address":"stake1787r5jw788rqcvaaka699jsqhhmyqemqf0kcw56p2lptrtgexgmwc","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"51798024789"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"231291197948"},{"unit":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bbe52e7facd0b8784e27b271082a030a60cdabef1619480a507896eeacb0c5c264","policy_id":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb","asset_name":"asset1gt9tnvc5apz4344g3vw23tmxr9fq4x2mnv8d93","quantity":"1"}],"data_hash":null,"inline_datum":"d8799f9f4040ff9f581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa454e49474854ff181e1a3794ae191b0000000127500da01a27b0d3e0d8799f1b004b5d4bcf53aa881b002386f26fc10000ffd8799f1b005a93a7fa0d6b121b002386f26fc10000ff1a0005f9b41a00200fac1b0000002d0f3ee467190296ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"list":[{"bytes":""},{"bytes":""}]},{"list":[{"bytes":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa"},{"bytes":"4e49474854"}]},{"int":30},{"int":932490777},{"int":4954525088},{"int":665900000},{"constructor":0,"fields":[{"int":21213203435596424},{"int":10000000000000000}]},{"constructor":0,"fields":[{"int":25495097567963920},{"int":10000000000000000}]},{"int":391604},{"int":2101164},{"int":193529308263},{"int":662}]}},{"tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","output_index":1,"address":"addr1w8p79rpkcdz8x9d6tft0x0dx5mwuzac2sa4gm8cvkw5hcnqst2ctf","stake_address":null,"amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"4000000"},{"unit":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa4e49474854","policy_id":"0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa","asset_name":"NIGHT","quantity":"161911763"}],"data_hash":null,"inline_datum":"d8799fd8799f581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cfffd8799fd8799f581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cfffd8799fd8799fd8799f581c2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9ffffffffd87980d8799fd8799f581c36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cfffd8799fd8799fd8799f581c2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9ffffffffd87980d8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c5820697ad50acfc26540cb2c4ca02fa2a89aab8b5ad77672164ecf1799232336ea2cffd905029f82d8799fd8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c5820697ad50acfc26540cb2c4ca02fa2a89aab8b5ad77672164ecf1799232336ea2cffd87a80ffd8799fd8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c582014642f8656ab7122d915564a9f1813537426af54d486f86d9c82348b92d7e727ffd87980ffd8799f1a09a693d3ff1a01debfa5ff1a001e8480d87a80ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cf"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cf"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9"}]}]}]}]},{"constructor":0,"fields":[]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"36340cb51d3bf2a96914a041b77fce26e200da6a0f2e385ef3cd64cf"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"2be5a06efe8a75221d30787c0e988a67955d05835645b42d0eb50ef9"}]}]}]}]},{"constructor":0,"fields":[]},{"constructor":0,"fields":[{"bytes":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c"},{"bytes":"697ad50acfc26540cb2c4ca02fa2a89aab8b5ad77672164ecf1799232336ea2c"}]},{"constructor":9,"fields":[{"list":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c"},{"bytes":"697ad50acfc26540cb2c4ca02fa2a89aab8b5ad77672164ecf1799232336ea2c"}]},{"constructor":1,"fields":[]}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c"},{"bytes":"14642f8656ab7122d915564a9f1813537426af54d486f86d9c82348b92d7e727"}]},{"constructor":0,"fields":[]}]}]},{"constructor":0,"fields":[{"int":161911763}]},{"int":31375269}]},{"int":2000000},{"constructor":1,"fields":[]}]}},{"tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","output_index":2,"address":"addr1q9w4rju5zsmwp70v0evwzlxkzc0krc80vxyeuwfuxe9fvclkwymls3p25f9f9quvhtchu7jlfzrx6z3pylvpapcq0qmsc9v6e7","stake_address":"stake1u8m8zdlcgs42yj5jswxt4ut70f053pndpgsj0kq7suq8sdcze8shk","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"1000000"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","output_index":3,"address":"addr1qymrgr94r5al92tfzjsyrdmlecnwyqx6dg8juwz770xkfnetuksxal52w53p6vrc0s8f3zn8j4wstq6kgk6z6r44pmusnsctvh","stake_address":"stake1uy47tgrwl6982gsaxpu8cr5c3fne2hg9sdtytdpdp66sa7gk0wzjc","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"88364441"},{"unit":"1c9cc348fcb844ba233b1d9582d177a99c25b0117cbb4ca14b9bcebb4354565f5661756c745365656b65725f436f6d6d6f6e5f30323332","policy_id":"1c9cc348fcb844ba233b1d9582d177a99c25b0117cbb4ca14b9bcebb","asset_name":"CTV_VaultSeeker_Common_0232","quantity":"1"},{"unit":"97582816c1b128480a10840f2ec96ff57f7eece61290fdc9a16309cd4563686f","policy_id":"97582816c1b128480a10840f2ec96ff57f7eece61290fdc9a16309cd","asset_name":"Echo","quantity":"9272920"},{"unit":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af746170707930322d30393238","policy_id":"c02bceffa5811fe64caa949affc0220a2c862a12f143f10ce55a29af","asset_name":"tappy02-0928","quantity":"1"},{"unit":"e992ef75f2367e6ecd93716ae88eba0d005dd91fd3a21f650b6496b55355524745","policy_id":"e992ef75f2367e6ecd93716ae88eba0d005dd91fd3a21f650b6496b5","asset_name":"SURGE","quantity":"56410583632"},{"unit":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a68616e646c65313233","policy_id":"f0ff48bbb7bbe9d59a40f1ce90e9e9d0ff5002ec48f232b49ca0fb9a","asset_name":"handle123","quantity":"1"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null}],"reference_inputs":[{"tx_hash":"64d111b957e7d7848ffdde5149aa77fa4090a7fa1ad0ac108067900614848501","output_index":0,"address":"addr1zysreurmyly2rhs8e3x376f89x97gd48x5nyrrkmjl9unffctd0zw3ydwgtk9hpse3mgyezedms0y054fa47vfkt08qs8ptaac","stake_address":"stake1uyu9kh38gjxhy9mzmscvca5zv3vkac8j862576lxym9hnsgw6zrqw","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"43522380"}],"data_hash":null,"inline_datum":"d87980","script_ref":"820359267b5926780101003333222229800aba2aba1aba0aab9faab9eaab9dab9a9bae0059bae0049bae0039bae00248888888888966003300130073754019370e90004dc024003223232330010010042259800800c00e2646644b30013372200e00515980099b8f0070028800c01900d44cc014014c04c01100d1bae300c0013756601a002601e002806852f5bded8c1370e900148a6002003337026e34008006004b8c4dd2a40092300b300c0019ba54800a6e2120009ba548002466ebcdd3800a61049f4040ff009b874801a6e1d20089b87480112222222222222229800980d007cc06403e444b300130110018801456600266e2000520068cc00400e603a0053010001400d15980099b894805000633001003980e980e980e980e980e980e980e980e980e980e8014cdc0000a4026801a2b3001337129005000c66002007301d301d301d301d301d00299b8000148025003466002007301d301d301d00299b8000148015003202e405c80b90174888c8cc88cc008008004896600200300389919912cc004cdc8803801456600266e3c01c00a20030064075133005005302300440746eb8c070004dd6980e800980f800a03a3301000400314800244466e3ccc034dd7191919800800802912cc0040062d1325980099baf004301c0018980e800c4cc00c00cc08400901b180f800a03a300c3301b0024bd70240046601a002900148888c966002602660346ea8006266e00cdc199b81375a603c60366ea8004dd69806980d9baa004375a603c603e603e603e60366ea8010dd6980f180f980f980d9baa0048b2032323259800980a180d9baa001898059980f180f9bac301f301c375400297ae0898069980f1ba937306e64dd7180f980e1baa0014bd70203432598009808980d9baa00189991192cc00566002601e00715980099b89003001899b893370200200600514a080ea294101d44c038cc084dd3998109ba800333021375000297ae04bd7045300114d87a9f4f74782074696d6520696e76616c6964ff00407464b30013014301e37540031375a6044603e6ea80062b30014a3148002266e0000c00901d203a3021301e37546020603c6ea8020dd6980f980e1baa00133704b30013014301b37540051375a603e60386ea800a290740120340038a6114d87a9f4f74782074696d65206e6f7420736574ff004068603c60366ea8c078c06cdd500298049980e1ba80024bd70244444466446530012222222301b9800acc004c07800a26464b3001302030273754003132598009919800800804912cc00400629422b30013371e6eb8c0b800400e2946266004004605e002814902c44dd598161816801c590271bae302b302837540031640986054604e6ea8c0a8004c098dd51814981518131baa330080060018acc004c06c00a26eacc0a4c0a8c098dd51814981518131baa330080070018b20484091004801a01a9811004cc078dd5001a444b3001301a00189991199119194c004c0b0c0b00066eb8c0acc0a0dd50064c00d2002488966002604600313322323298009819000cc0bcdd51819001cde40022444b3001301d30313754007159800981398189baa303530360048acc004cdd7981a80118119981a1ba90094bd7044c966002605660646ea80062646464646464646464646464b300130430018991919912cc004c11c00e264646644b3001304b003899912cc004c104c120dd5000c4c8c966002609e003198009bad304e0029bad304e0019827008a444b30013046304d37540031323259800982a000c660026eb4c14c00a6eb4c14c0066eb4c14c04d22259800cc004cdd79ba7014374e0214a14a2828a2b3001598009820808452846600294294294505120a28acc004cdc419b820080023370400600f132598009800804c566002600201115980098008024566002600200715980098261bad305701e8acc004c130dd6982b80ec56600260886eb4c15c0662b30013044375a60ae03115980099b89480000362b30013371201a90504e00c56600266e1cdd6982b80b4c004c15c0b29811cd8799f18491b0000017fae70a0381901481a19bfcc001a19bfcc00ff00a41a00b483403d039456600260986eb4c15c0722646464b3001304c375a60b4007132325980099b8700733225980099b8800100288014400505919b81003001375a60b860ba00915980099b89001003899912cc004cdd79ba6001374c660ba66ec0dd48182610101003305d337606ea40a8dd4004a5eb7bdb1822946266ebcdd30009ba63305d337606ea40a8dd40049982e99bb0375206098010101004bd6f7b63020b25980080144c96600266ebcc174c1780a8c124cc170c124cc1700052f5c097ae08acc004c8cc004004dd6182f01a112cc00400629422653001305c3754003375e60c060ba6ea800660c20049112cc004c16000e2b300130020078a51899803003000a0bc8acc004c12c00e2b300130020078a51899803003000a0bc8acc004c13400e2b300130020078a51899803003000a0bc899803003000a0bc417882f060be00282ea29345905845905818251982d9ba9379066e28cdc519b8a3371466e28cdc519b8a3371466e2a60029469001520065e49221ff5907440101003333222229800aba2aba1aba0aab9faab9eaab9dab9a9bae0059bae0049bae0039bae00248888888888966003300130073754019370e9000488c8c8cc004004010896600200300389919912cc004cdc8803801456600266e3c01c00a20030064035133005005301300440346eb8c030004dd598068009807800a01a14bd6f7b6304dc3a4005229800800ccdc09b8d0020018012e312232330010010032259800800c5a264b30013375e00860160031300c00189980180198080012014300e0014031374a90024dc3a400d374a90014dd2a400091111111114c004c05402a6028015222598009806000c400a2b3001337100029003466002007ff301800299b8000148005003456600266e2520140018cc00400e6030603060306030603060306030603060306030005337000029009a0068acc004cdc4a401400319800801cc060c060c060c060c06000a66e000052009400d19800801cc060c060c06000a66e000052005400c8091012202440492232330010013300b0030022259800800c528456600266e1cdc69bae3014301800148102294626600400460320028099016488cdc7998040012400466010002900124444530013019005980c980d002cc00c00e444660086eb8cc03400cc030cc06c0092f5c000291111919912cc004cdc3a4008003133223232980098121812800ccdc700224005375660ff48604a604a604a604a604a604a604a604a604a0073025002488896600266ebcc0a0014c058cc09cdd481025eb82264b300159800980e0024528c4c060011024456600266ebc01400633001003980b998141ba90214bd70402500d459024456600266e1c01120088acc004cdd7802a60103d87a80008acc004cdd798149815001180c99814000a5eb822b3001330123756605200404319800801cc05ccc0a0dd4810a5eb82012806a2c81222c81222c81222c8121024180a99813980a99813981418129baa00e4bd7025eb822c811860406ea8c08c004c07cdd518111811980f9baa330053758604400266e380092000375c00e603a6ea802e2b3001300f001ff8cc004c074dd50034888cc02cdd71980a0019808198111ba90024bd70000cdd7003cc074dd5005cc038cc07cdd480c25eb812222298009bad302500598129813002cc09800a6eb0c09400a6e3800d2222298009815801cdd61815001cc005200298131baa00448888c8ca600233001008802c0060550299803a40093007480190124dd618180034dd5981818188012444b300130280068992cc004c0a4022264b30013375e606a00202315980099baf303530360013022330343022330340024bd7025eb8233001003816c04d01445903045903018189baa3034303137546602e00660169003401102f181998181baa00d8acc004cdc3a400800d132598009ff81480444c96600266ebcc0d40040462b30013375e606a606c00260446606860446606800497ae04bd7046600200702d809a0288b20608b206030313754606860626ea8cc05c00cc02d2006802205e30333030375401b159800981100344c96600260520111325980099baf30350010118acc004cdd7981a981b00098111981a18111981a00125eb812f5c119800801c0b602680a22c81822c8180c0c4dd5181a18189baa33017003300b4801a0088178c0ccc0c0dd5006c566002604c00d15980099b87007480222646644b30013375e606c00202515980099baf303630370014c0103d87a80008acc004cdd7981b181b80118131981a98119981a98119981ffa981b18199baa0104bd7025eb812f5c11598009980f9bab303600202e8cc00401202501440691640c51640c51640c51640c4606a00260626ea8c0d0004c0c0dd51819981a18181baa3301600b300a480022c8172006817102e205c40b83758605e002605e605e605e605e605e605e00822646466453001375860480073758604800537586048604a604a604a604a604a604a604a00501e80eccdc7000a40013371c0029001200c3023001375c60446046004603c6ea8c084004c074dd50112036406c444444460306466446600400400244b3001001801c4c8cc896600266e4402400a2b30013371e0120051001803204c8998028029816002204c375c604ac10026eb4c098004c0a00050261980d2cc004c06c00e26464b3001301d30243754003132598009919800800805112cc00400629422b30013371e6eb8c0ac00400e29462660040046058002813102944dd598149815001c590241bae30283025375400316408c604e60486ea8c09c004c08cdd51813181398119baa330090070028acc004c06400e26eacc098c09cc08cdd51813181398119baa330090080028b2042408400a29000180d1baa00422301f330040020011149a26cac802898011e581c0005648906004c012258200005548906004c011e581c0003048906004c012258200002e489020001004bd704526415c6609e6eacc1700cc0c22c82ba2c82b9660020031482026fb80a2900020ac30450188b20aa375a60b200260b200264660020026eacc1600a0896600200314bd709010000810100008101000044c8cc88cc88cc00800800489660020031003899914c004dd7182e0014dd6982e8014c1840066eb4c1800052222332259800acc004cdc781d00644cdc780301c452820c28998329ba8005330653750004660ca6ea00052f5c11325980099baf374e0026e9c0962660cc6ea0010cc198dd4003198331ba80024bd70456600266ebcdd38009ba70218998331ba8004330663750006660cc6ea00192f5c11598009829000c56600266e2520809bee02006880445906245906220c44188660ca6ea4030cc194dd480325eb810611bad3064002375a60c860ca004305e00133002002305f00141706eacc16400cdd7182b00099801801982d801182c800a0ae8b20a48b20a48b20a48b20a48b20a48b20a48b20a48b20a48b20a48b20a48b20a48b20a43712904044bd45905145905145905122c8288c14c004c138dd5000c5904c22c8260c138004c124dd5000c590471bad304a012304a00e8b2090375c60900026eb8c120008c120004dd61823007c590441bae3044001375c608800460880026eb0c1080322c8200c108004c104004c100004c0fc004c0f8004c0f4004c0f0004c0ec004c0e8004c0e4004c0e0004c0ccdd5000c59031181a98191baa0038b20608b20608b206018190009818800991b94337146eb8c0bc006600294290004dd698179818000af2460566ea8c0b8c0acdd5198069bac302e008300648010c0acdd5198069bac302e00730064801a2b3001598009810000c528c4c05c0050294660026eacc0b8c0bcc0bc00e60386605a6ea40092f5c100940451640a48148605600260546054004605060500026e38008dd700118111baa00b8acc004c05c006330013022375400d375c005302237540169114c004c0a000e6050605200737566050605260526052605260526052605260526052003371c00491111919192cc004cdd79817801003c66002600890014dc6804c888cc00400c009222359800980d001c4a6002900252008800a0048acc004c07000e253001481129002400500245660026048007129800a40094801200280122b3001301b003894c0052002a401100140091640b4816902d205a2259800acc004c0a4cdc319b8100400100289919800800802912cc004c0accdc0800801c52844c96600266e1cc030cdc0000a400001714a313300300300140c866e0400401103145902f466002015006807202e8b205e45902a181718159baa302e302b3754605c605e00260546ea8cc030dd618168030009800a40004566002601a00319800981298111baa006912cc00400629344c966002003149a264b3001337206eb8c094c0a400cdd71812800c4cc010010cc0a0004c0a800a2c8120c0a00050261814000a04a9112cc004c074006200515980099b880014801a3300100398148014c070005003456600266e2520140018cc00400e6052605260526052605260526052605260526052005337000029009a0068acc004cdc4a401400319800801cc0a4c0a4c0a4c0a4c0a400a66e000052009400d19800801cc0a4c0a4c0a400a66e000052005400c81190232046408d3702900048888c8cc004004014896600200313302a337606ea4014dd300225eb7bdb1822653001375c605000337566052003302d00248896600266e4002400e26605c66ec0dd48049ba60080058acc004cdc7804801c6600201300280448cc0bccdd81ba900a374c0020051001401d13302e337606ea400cdd300119803003000a05440a8302b00140a52232330010010032259800800c5300103d87a80008992cc004cdd78021813000c4c058cc0a4c09c0052f5c1133003003302b00240946052002813a44b3001301c30233754005132323322598009816001c0162c8148dd698148009bad3029002302900130243754005164089223259800980e98121baa00189bae302432337606052002605260540026eb0c0a0c094dd5000c5902319198008009980d801801112cc0040062980103d87a80008992cc004cdc39b8d375c604c002902044c058cc0a4dd3998149813000998149813800a5eb812f5c1133003003302b00240946052002813a6eb800a60446ea802d222222222298009804804cc020022444444444464b30013027007899194c0040326028b30015980099b8800c00d8a51899b8800900140dd15980099b8700c301433706600466e0802402ccdc100199b8100900a8804c59037440050374cdc199b803370401090404257af99b823370400a00c01890404257afc01d00419b833370266e08024cdc0180080519b8200b00230013370401401266e00c004028cdc10058011b824828270062c81a8cdc0a4141380200525980099b880014800226010003100140ad232330010010022259800800c52f5bded8c113298009bae302f0019bab303000199801801981a00124453001001801cc8c8010c8cc0040040108966002003149a264b30010018acc004c010dd6981b981d00145268b206a899912cc004cdc81bae3038002375c607000315980098031bad30390028998028029981d800981e801c59037459037181d001181d000a070303a00140dc53001302c001a50a5140c92222264034303200140c130300019bac302f00148888888c8c8c8ca600260740033758607460760033758607400d2222598009819800c401233001004801cc8c8008c040004cc0f4cdd81ba9002375000297adef6c6091111192cc0056600200314a314a082120051300300140fc646600200200a44b300100189982219bb037520126ea00212f5bded8c113298009bae30420019bad304300198238012444b30013372001a007133048337606ea4034dd4006002c56600266e3c03400e264b3001303f001880144cc124cdd81ba900e37500020048228cdc000100644cc120cdd81ba900337500046600c00c00282210440c114005043202a40e537586074009375660740073756607400491111112cc004c0ccc0f4dd500e4660026eb8c104c0f8dd500e4dc700a4dc02400537009001a444464b3001302e0018acc006600202300a805c10207f3004480126008900320488992cc004cdc42400c00313233001001002225980099b880014802229462b3001323298009bab304b00198259826000cc024c02000e609600491112cc004c0dcc12cdd5001c4c966002608a60986ea800626464646464646464646464653001375a60b8003305c0059bad305c0049bad305c0039bad305c0024888896600260c400d13232598009832000c4ca600260ca003375c60c8005375c60c80069112cc004c1a000e33001375c60ce009375c60ce007375a60ce02b375a60ce029375a60ce027375a60ce025306701148888888cc10c0044cc10c0444cdd799826818010982d19836810198369ba69800cc0066002047008804cc118011031401e00d304600340c5488100a44100982300120623306d305e3306d305a3306d374e01a660da6e9c02ccc1b4dd400299836a610100003306d4c10100003306d4c10100003306d0013306d0113306d3750020660da6ea003ccc1b4dd4007198369ba80124bd7025eb80cc1b530103d87a80004bd7022c83286eb0c18c04a2c8308c18c004dd61830808c5905f0c170004c16c004c168004c164004c160004c15c004c158004c154004c150004c14c004c148004c134dd5000c5904b182798261baa0038b2094182580098231baa3049304a3046375466050028600e00313300200230050018a50411082222c8210c008dc680cc5904144c966002606200513259800981e98221baa001899192cc004c12c00633001375a6094005375a6094003980080853011cd8799f18491b0000017fae70a0381901481a19bfcc001a19bfcc00ff00a41a00b483403d02c4cdc01b8d01d4810d22225980099b88480080062646600200200444b30013371000290024528c56600264664530013053305430503754003375660a600530533054002488966002607c60a46ea800626644b3001304d30543754003132323232323232323232323298009bad30640019832002cdd698320024dd69832001cdd69832001244444b3001306a006899192cc004c1b00062653001306d0019bae306c0029bae306c00348896600260e0007198009bae306f0049bae306f0039bad306f0159bad306f0149bad306f0139bad306f0129837808a44444446609600226609602226464b3001306300f8acc004c1b4c1d0dd5012c4c96600260dc60ea6ea8006266004606600a64b3001306f307637540031375a60f460ee6ea80062b30013370e02c05d1480022c83a90751982781c983c983b1baa0018b20e830783075375404b1641cd13300148001200041cc4466446644b300198009839003d28528a0f0899912cc004c1b002633001019804c00a003005806c03206b00e801a09c8cc00406260ae013001801401a01900d81ac03a444530010038024006004803904e20f433223370000c60ae66e0ccdc1182b9bad307f002375a60fe61000200266e08dd6983f9840008011bad307f001307a375400460f46ea8060cc88cdc0002982b99b833370460ae6eb4c1fc008dd6983f80099b82375a60fe6100020046eb4c1fcc20004004c1e8dd5001183d1baa0088b20f0332298009bad307d0029bad307d307e0029bad307d0019bad307d307e00148888cc88c8c96600260f46102026ea8006260e266108026ea0cdc019b803370401600866e0803000cdd69842809841009baa0013308401375066e0920043370266e0801801ccdc100280425eb822c840008c96600266e2000520008a60103d87a80008acc004cdc4800a4005130713308401375000297ae08983899842009ba8329800800c00a66e0cc10c00920044004444b30013371200400310028cc00400e0033370666e00004cdc1802000a400880190830125eb81080012100023370066e08004004cdc119b823370466e09200800b00a3370400c00c66e08014014cdc099b820090023370401400266e0800c004cdc10020010c1e0dd5003183c1baa01622223375e660bc082607a607806060d6660fc05a660fc6e9a600330019800815c0460253370000800e821202100f801a084a44100a4410081a20843307e306f3307e306b3307e374e02c660fc6e9c050cc1f8dd40071983f1ba80023307e3750002660fc6ea0cdc000581a1983f0051983f00d1983f1ba80193307e3750030660fc6ea005ccc1f8dd4019a5eb812f5c0660fc980103d87a80004bd7019b803370266e06600204b00b80620ba007002001337033001025805402505d003192cc004cdc4a4101010101010101010101010101010101010101010101010101010101010101010101010020003133700002907fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8fc400507219bca4a330013370004e900252040824ae308b20da1bac306b0128b20d2306b001375860d202316419c30640013063001306200130610013060001305f001305e001305d001305c001305b001305a0013055375400316414c6605400402a60ac60a66ea80062c828860a400260a2002609a6ea8c140c144c134dd51981780d9807000c4cc008008cdc0000a408714a0825904b45904922c8240c128004c114dd5000c5904319912cc004c0ee60026eacc1240066eb8c12400a6eb8c124c12800902d44c9660026064608c6ea800626094608e6ea80062c8228c124c1280062c8220cc118dd481f998231ba903e4bd70182418221baa3047304830443754608e6602e016600a900045660026072005133225980099b8848008006266ebcdd3194c004006005301733049337606ea4024dd31981e805804a5eb7bdb1810011112cc004cdc40012400910018cc00400e601000532332298009827982818261baa0019bab304f002982798280012444b3001303a304e375400313259800982418279baa00189919191919191919191919194c004dd6982f800cc17c0166eb4c17c0126eb4c17c00e6eb4c17c0092222259800983280344c8c96600260ce00313298009834000cdd718338014dd71833801a444b3001306b0038cc004dd718350024dd71835001cdd6983500acdd6983500a4dd69835009cdd698350094c1a804522222223304600113304601113232323322598009835801456600266ebc004c188cc1d4c188cc1d4c190cc1d4dd481aa5eb80cc1d530103d87a80004bd701983a9ba69800cc006600264b3001306c0018a5eb7bdb182264646600200297adef6c602259800800c4cc1e4cdd8261014000374c00697adef6c608994c004dd7183b800cdd5983c000cc1f00092225980099b904890000389983e99bb04c01014000374c00e00b15980099b8f4890000389983e99bb04c01014000374c00e00313307d337606ea400cdd300119803003000a0f241e4307a00141e0646600200297adef6c602259800800c4cc1e0cdd8261014000375000697adef6c608994c004dd7183b000cdd6983b800cc1ec0092225980099b904890000389983e19bb04c01014000375000e00b15980099b8f4890000389983e19bb04c01014000375000e00313307c337606ea400cdd400119803003000a0f041e0307900141dc8390c0c001e06b005a400481ca01b00e804a072806402e01081c8cc1d4c198cc1d409d2f5c0660ea980103d87a80004bd704660020570358024c13800d03945907144c96600260c4027159800983618399baa0288992cc004c1b4c1d0dd5000c4cc008c96600260dc60ea6ea800626eb4c1e4c1d8dd5000c56600266e1c0680ca2900045907420e83304e03830783075375400260640131641cc60ee60e86ea80a22c8392266002900024000839088cc8966002b300133712032609000514a3133712030609000283aa2b30013375e00a60cc660f2058660f26e9a6003300102a808c04a66e0000801103d404201f00140f4660f260d4660f260cc660f26e9c058cc1e4dd380a1983c9ba800e33079375001a660f26ea0030cc1e4dd40059983c8051983c80d1983c9ba8019330793750030660f26ea0018cc1e4dd4019a5eb812f5c0660f298103d87a80004bd7046600205f0398044c14801d03d459075459075182819b833370460a000a66e00cdc099b819800814403e02082e002c004008054c140cdc199b823050005337033001028807403505c00500aa0e233700022002660a606c60646062055300102881940050571bc8001330450210304590680dd6183300945906418330009bac30640118b20c4182f800982f000982e800982e000982d800982d000982c800982c000982b800982b000982a80098281baa0018b209c3052304f3754003164134304e001304d001304937546098609a60926ea8cc0ac05cc028009003208e4c101a0008b20889800806d30011cd8799f18491b0000017fae70a0381901481a19bfcc001a19bfcc00ff00a41a00b483403d02918019b8d01a8acc004c0c000a2b30019800809402e0190418204c01520049802a400c812a264b3001337109003000c4cdd79ba6329800800c00a64b30010018a5eb7bdb1822602e6609266ec0dd48049ba60014bd6f7b63020903303c00a0084004444b30013371000490044400633001003981f8014c8c96600264660020026608200401a44b30010018a508acc004cdc39b8d375c6096609e00290204528c4cc008008c14000504a209a8992cc004c0d8c128dd5000c4c8cc8966002608c005132598009829800c4c8c966002609200313259800982b000c4c8c96600260980031323259800982d00140262c82b8dd7182c000982a1baa0028acc004c12400626464b3001305a002804c590571bae3058001305437540051641488290c148dd5000982a800c5905318289baa0028acc004c11800626464653001375a60ae003375a60ae007375a60ae0049112cc004c16c012015164160305700130560013051375400516413c8278c13cdd50009829000c5905018271baa0038acc004c10c00a2b3001304e37540070018b209e8b2098413060966ea80046600200b00e99811801807520024048609c60966ea80062c8248c134c13800a20068240dd59826000982618241baa304b304c304837546605402c60120048019046260101a0008b2086303b371a03516410916410882110421b80482026fb8090411801a40048b2078181d000981c981c800981c181c000981b801113232332298009bac30290039bac30290029bac3029302a302a302a302a302a302a302a002811c08a66e38005200099b8e0014800900718140009bae3027302800230233754604c00260446ea809d0202040408022302333003002001301f30200063004004229344d95900513011e581cbd23e2977937bb6054f625e5b566e28e294dcd8f7e8d0d33979ed90c004c0122582071b303721562cc44d041bf9c7699991e9be1a9b6ec45f73b38ac5b69569e21f0004c011e581cbc2d3b7cf1009c788b1daf2208a38026ab20d7f8209d896ad1a09c14004c0122582072f9dae79bb2dfa39bd8fadf089ea0c8377b5eaa3caef0549396668aef1f91870001","reference_script_hash":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb","inline_datum_json":{"constructor":0,"fields":[]}},{"tx_hash":"2cafd7c92f7093e5229af274be83dea660b0590b4174bbed79ba662b44fbd1ee","output_index":0,"address":"addr1z8afjx7zl8zzqmnj6ufmcdy85uh8jqg90j4m35myh6lwlrectd0zw3ydwgtk9hpse3mgyezedms0y054fa47vfkt08qsdxsvyq","stake_address":"stake1uyu9kh38gjxhy9mzmscvca5zv3vkac8j862576lxym9hnsgw6zrqw","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"1349030"},{"unit":"bc2d3b7cf1009c788b1daf2208a38026ab20d7f8209d896ad1a09c1472f9dae79bb2dfa39bd8fadf089ea0c8377b5eaa3caef0549396668aef1f9187","policy_id":"bc2d3b7cf1009c788b1daf2208a38026ab20d7f8209d896ad1a09c14","asset_name":"asset14lhphpdaeczcza569khsxrnw92u53x30y4cr2p","quantity":"1"}],"data_hash":null,"inline_datum":"d8799f1903e81a000186a0ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"int":1000},{"int":100000}]}}],"invalid":false},"scripts":[{"tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","script_hash":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb","script_content":"{\"type\":\"PlutusScriptV3\",\"content\":\"59267b592678010100333322…trimmed\"}","type":"PLUTUS_V3","redeemer":{"tag":"spend","index":2,"data":"582402030200ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe51fc6d3","ex_units":{"mem":77265,"steps":25941847}},"datum":"d8799f9f4040ff9f581c0691b2fecca1ac4f53cb6dfb00b7013e561d1f34403b957cbb5af1fa454e49474854ff181e1a3794ae191b00000001274dfd3a1a27af4d40d8799f1b004b5d4bcf53aa881b002386f26fc10000ffd8799f1b005a93a7fa0d6b121b002386f26fc10000ff1a0005f9b41a00200fac1b0000002d0f3ee467190296ff","datum_hash":"9c39c0e2dc8b25265a051794ff07ce01cac449deda4d643517f9e80148e5d99f"},{"tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","script_hash":"d8b69fc53637bcfadbc4469083f706bc293f4d9d2296646c5ca167bb","script_content":"{\"type\":\"PlutusScriptV3\",\"content\":\"59267b592678010100333322…trimmed\"}","type":"PLUTUS_V3","redeemer":{"tag":"reward","index":0,"data":"582400030200ffffffffffffffffffffffffffffffffffffffffffffffffffffffffe51fc6d3","ex_units":{"mem":1050824,"steps":343487730}},"datum":null,"datum_hash":null}],"withdrawals":[{"block_number":14078701,"block_time":1792212424,"address":"stake178vtd879xcmme7kmc3rfpqlhq67zj06dn53fvervtjsk0wczh2pdw","tx_hash":"1aa7054609376511faa874f43c34f5f4ff68db88a623df05140442d9e5602fde","tx_index":12,"amount":0,"epoch":662,"slot":200646133}],"mints":[],"votes":[],"stakes":[],"delegations":[],"params":{"cost_models_raw":{"PlutusV1":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,228465,122,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,228465,122,0,1,1,90434,519,0,1,74433,32,85848,228465,122,0,1,1,85848,228465,122,0,1,1,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,53384111,14333,10,955506,213312,0,2,43053543,10,43574283,26308,10,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,1293828,28716,63,0,1,1006041,43623,251,0,1,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21],"PlutusV2":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,228465,122,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,228465,122,0,1,1,90434,519,0,1,74433,32,85848,228465,122,0,1,1,85848,228465,122,0,1,1,955506,213312,0,2,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,43053543,10,53384111,14333,10,43574283,26308,10,1293828,28716,63,0,1,1006041,43623,251,0,1,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21],"PlutusV3":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,123203,7305,-900,1716,960,57,85848,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,123203,7305,-900,1716,960,57,85848,0,1,90434,519,0,1,74433,32,85848,123203,7305,-900,1716,960,57,85848,0,1,1,85848,123203,7305,-900,1716,960,57,85848,0,1,955506,213312,0,2,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,43053543,10,53384111,14333,10,43574283,26308,10,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,1293828,28716,63,0,1,1006041,43623,251,0,1,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21]},"protocol_major_ver":11},"genesis":{"active_slots_coefficient":0.05,"update_quorum":5,"max_lovelace_supply":"45000000000000000","network_magic":764824073,"epoch_length":432000,"system_start":1506203091,"slots_per_kes_period":129600,"slot_length":1,"max_kes_evolutions":62,"security_param":2160}} diff --git a/applications/viewer/src/lib/julc/replay/fixtures/mainnet-withdrawZero.json b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-withdrawZero.json new file mode 100644 index 00000000..a424e1fe --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fixtures/mainnet-withdrawZero.json @@ -0,0 +1 @@ +{"hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","epoch":662,"tx":{"fees":665642,"ttl":200642960,"validity_interval_start":200642780,"required_signers":["5b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b"],"inputs":[{"tx_hash":"23f462fbf5767947c0cb7169dbd3962e6705ce5cdf760a8ac342eb7b935b1329","output_index":1,"address":"addr1z84q0denmyep98ph3tmzwsmw0j7zau9ljmsqx6a4rvaau66j2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq777e2a","stake_address":"stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"467291240921"},{"unit":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235484f534b59","policy_id":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235","asset_name":"HOSKY","quantity":"19604577315792"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c4d5350","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"MSP","quantity":"1"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca449f98fa06fff1d6c17b95c65ca609660049dc683cc3e94d3846b05419fc20d","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"asset10mw5qs6xev8uvd33cye8630zmt8djk74t35muv","quantity":"9223369433876135153"}],"data_hash":null,"inline_datum":"d8799fd8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affffd8799f4040ffd8799f581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c23545484f534b59ff1b0000025e0d9e03181b0000006cc9e9a2631b000011d434cc16ff18321832d8799f190682ffd87980ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":1,"fields":[{"bytes":"1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a"}]}]},{"constructor":0,"fields":[{"bytes":""},{"bytes":""}]},{"constructor":0,"fields":[{"bytes":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235"},{"bytes":"484f534b59"}]},{"int":2602978640664},{"int":467243999843},{"int":19603116529407},{"int":50},{"int":50},{"constructor":0,"fields":[{"int":1666}]},{"constructor":0,"fields":[]}]}},{"tx_hash":"3c584cc0a84e6e7f5fdb9d6373af158cb26eb2c88f11a6ae15a573761e28112b","output_index":0,"address":"addr1z8p79rpkcdz8x9d6tft0x0dx5mwuzac2sa4gm8cvkw5hcnzkhwsx5j8l93a3uk4zvq8z50j72cwplnfrg5vwr39e6jhqwtrjt7","stake_address":"stake1u9tthgr2frljc7c7t23xqr328e09v8qle5352x8pcjuaftsr2ggra","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"4000000"},{"unit":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235484f534b59","policy_id":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235","asset_name":"HOSKY","quantity":"25029718269"}],"data_hash":null,"inline_datum":"d8799fd8799f581c1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709bffd8799fd8799f581c1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709bffd8799fd8799fd8799f581c56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4aeffffffffd87980d8799fd8799f581c1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709bffd8799fd8799fd8799f581c56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4aeffffffffd87980d8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c5820a449f98fa06fff1d6c17b95c65ca609660049dc683cc3e94d3846b05419fc20dffd8799fd87980d8799f1b00000005d3e330fdff1a201fd225d87980ff1a001e8480d87a80ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709b"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709b"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4ae"}]}]}]}]},{"constructor":0,"fields":[]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709b"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":0,"fields":[{"bytes":"56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4ae"}]}]}]}]},{"constructor":0,"fields":[]},{"constructor":0,"fields":[{"bytes":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c"},{"bytes":"a449f98fa06fff1d6c17b95c65ca609660049dc683cc3e94d3846b05419fc20d"}]},{"constructor":0,"fields":[{"constructor":0,"fields":[]},{"constructor":0,"fields":[{"int":25029718269}]},{"int":538956325},{"constructor":0,"fields":[]}]},{"int":2000000},{"constructor":1,"fields":[]}]}},{"tx_hash":"553144e0d3dc0c6afa05a152386a00d14479de4bc298fd403ab83337364ff141","output_index":2,"address":"addr1q9dhugez3ka82k2kgh7r2lg0j7aztr8uell46kydfwu3vk6n8w2cdu8mn2ha278q6q25a9rc6gmpfeekavuargcd32vsvxhl7e","stake_address":"stake1u9fnh9vx7rae4t740rsdq92wj3udyds5uumwkww35vxc4xgffy4va","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"420008340"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null}],"outputs":[{"tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","output_index":0,"address":"addr1qyw7llpdjd5trptejty0xz9f4s0wlu0ewu833tr4rh68px6khwsx5j8l93a3uk4zvq8z50j72cwplnfrg5vwr39e6jhqv309j9","stake_address":"stake1u9tthgr2frljc7c7t23xqr328e09v8qle5352x8pcjuaftsr2ggra","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"594851958"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null},{"tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","output_index":1,"address":"addr1z84q0denmyep98ph3tmzwsmw0j7zau9ljmsqx6a4rvaau66j2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq777e2a","stake_address":"stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"466698388963"},{"unit":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235484f534b59","policy_id":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235","asset_name":"HOSKY","quantity":"19629607034061"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c4d5350","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"MSP","quantity":"1"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4ca449f98fa06fff1d6c17b95c65ca609660049dc683cc3e94d3846b05419fc20d","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"asset10mw5qs6xev8uvd33cye8630zmt8djk74t35muv","quantity":"9223369433876135153"}],"data_hash":null,"inline_datum":"d8799fd8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affffd8799f4040ffd8799f581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c23545484f534b59ff1b0000025e0d9e03181b0000006ca6936e6d1b000011da077123a118321832d8799f190682ffd87980ff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"constructor":0,"fields":[{"constructor":1,"fields":[{"bytes":"1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a"}]}]},{"constructor":0,"fields":[{"bytes":""},{"bytes":""}]},{"constructor":0,"fields":[{"bytes":"a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235"},{"bytes":"484f534b59"}]},{"int":2602978640664},{"int":466651147885},{"int":19628125397921},{"int":50},{"int":50},{"constructor":0,"fields":[{"int":1666}]},{"constructor":0,"fields":[]}]}},{"tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","output_index":2,"address":"addr1q9dhugez3ka82k2kgh7r2lg0j7aztr8uell46kydfwu3vk6n8w2cdu8mn2ha278q6q25a9rc6gmpfeekavuargcd32vsvxhl7e","stake_address":"stake1u9fnh9vx7rae4t740rsdq92wj3udyds5uumwkww35vxc4xgffy4va","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"421342698"}],"data_hash":null,"inline_datum":null,"script_ref":null,"reference_script_hash":null,"inline_datum_json":null}],"reference_inputs":[{"tx_hash":"0dc17712e37a4e741767db2f90d4ffbf69faf88b9bed4c47864f7bd912924bea","output_index":0,"address":"addr1w86cprpvnyxcdkj5hlyhmzwwumh6yrxcgctpvdv50rvkknqyhz3rc","stake_address":null,"amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"1961050"},{"unit":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c4d534753","policy_id":"f5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c","asset_name":"MSGS","quantity":"1"}],"data_hash":null,"inline_datum":"d8799f9fd8799f581c5b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165bffffd8799f581cac1871003b670526048af46deffac5aa1e40043652771d58cf210be8ffd8799f581cac1871003b670526048af46deffac5aa1e40043652771d58cf210be8ffd8799f581cc4121a88897f44e5da27899320861e020e2977b34f6b2c8afaa6f3c3ffd8799f581c25af0fc61d7285b51293a67742b588e3a6e6ad20a1b7ff5dd3379587ffd87a9f581c4abd7063ce15ef6b959630a16dc5df3b56db667ee6c9c539c2511f2affff","script_ref":null,"reference_script_hash":null,"inline_datum_json":{"constructor":0,"fields":[{"list":[{"constructor":0,"fields":[{"bytes":"5b7e23228dba75595645fc357d0f97ba258cfccfff5d588d4bb9165b"}]}]},{"constructor":0,"fields":[{"bytes":"ac1871003b670526048af46deffac5aa1e40043652771d58cf210be8"}]},{"constructor":0,"fields":[{"bytes":"ac1871003b670526048af46deffac5aa1e40043652771d58cf210be8"}]},{"constructor":0,"fields":[{"bytes":"c4121a88897f44e5da27899320861e020e2977b34f6b2c8afaa6f3c3"}]},{"constructor":0,"fields":[{"bytes":"25af0fc61d7285b51293a67742b588e3a6e6ad20a1b7ff5dd3379587"}]},{"constructor":1,"fields":[{"bytes":"4abd7063ce15ef6b959630a16dc5df3b56db667ee6c9c539c2511f2a"}]}]}},{"tx_hash":"cf4ecddde0d81f9ce8fcc881a85eb1f8ccdaf6807f03fea4cd02da896a621776","output_index":0,"address":"addr1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzj2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq6a0h00","stake_address":"stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"12486070"}],"data_hash":null,"inline_datum":null,"script_ref":"8202590a63590a600100003332323232323232323222222533300832323232533300c3370e900118058008991919299980799b87480000084cc004dd5980a180a980a980a980a980a980a98068030060a99980799b87480080084c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc080cdc3a4000002264646600200200e44a66604c00229404c8c94ccc094cdc78010028a51133004004001302a002375c60500026eb8c094c07800854ccc080cdc3a40040022646464646600200202844a66605000229404c8c94ccc09ccdd798161812981618129816181698128010028a51133004004001302c002302a0013374a9001198131ba90014bd701bae3026001301e002153330203370e900200089980900419ba548000cc090cdd2a400466048604a603c00497ae04bd70099981019b87375a6044604a66446464a66604866e1d200200114bd6f7b63009bab302930220023022001323300100100322533302700114c103d87a800013232323253330283371e00e004266e9520003302c374c00297ae0133006006003375660520066eb8c09c008c0ac008c0a4004c8cc004004030894ccc09400452f5bded8c0264646464a66604c66e3d22100002100313302a337606ea4008dd3000998030030019bab3027003375c604a0046052004604e0026eb8c094c07800920004a0944c078004c08c004c06c060c8c8c8c8c8c8c94ccc08ccdc3a40000022646464646464646464646464646464646464a6660706076004264646464646464649319299981e99b87480000044c8c94ccc108c1140084c92632375a60840046eb4c10000458c8cdd81822000982218228009bac3043001303b0091533303d3370e90010008a999820181d8048a4c2c2c607601064a66607866e1d2000001132323232323232325333047304a002132498c09401458cdc3a400460886ea8c120004c120008dd6982300098230011822000982200119b8748008c0f8dd51821000981d0060a99981e19b87480080044c8c8c8c8c8c94ccc114c1200084c926302300316375a608c002608c0046088002608800466e1d2002303e3754608400260740182a66607866e1d2004001132323232323232325333047304a002132498c09401458dd6982400098240011bad30460013046002304400130440023370e9001181f1baa3042001303a00c1533303c3370e9003000899191919191919192999823982500109924c604a00a2c66e1d200230443754609000260900046eb4c118004c118008c110004c110008cdc3a4004607c6ea8c108004c0e803054ccc0f0cdc3a40100022646464646464a66608a60900042649319299982199b87480000044c8c8c8c94ccc128c13400852616375a609600260960046eb4c124004c10401854ccc10ccdc3a4004002264646464a666094609a0042930b1bad304b001304b002375a6092002608200c2c608200a2c66e1d200230423754608c002608c0046eb4c110004c110008c108004c0e803054ccc0f0cdc3a401400226464646464646464a66608e60940042649318130038b19b8748008c110dd5182400098240011bad30460013046002375a60880026088004608400260740182a66607866e1d200c001132323232323232325333047304a002132498c09801458cdc3a400460886ea8c120004c120008dd6982300098230011822000982200119b8748008c0f8dd51821000981d0060a99981e19b87480380044c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc134c14000852616375a609c002609c0046eb4c130004c130008dd6982500098250011bad30480013048002375a608c002608c0046eb4c110004c110008cdc3a4004607c6ea8c108004c0e803054ccc0f0cdc3a4020002264646464646464646464a66609260980042649318140048b19b8748008c118dd5182500098250011bad30480013048002375a608c002608c0046eb4c110004c110008c108004c0e803054ccc0f0cdc3a40240022646464646464a66608a60900042646493181200219198008008031129998238008a4c2646600600660960046464a66608c66e1d2000001132323232533304d3050002132498c0b400c58cdc3a400460946ea8c138004c138008c130004c11000858c110004c12400458dd698230009823001182200098220011bac3042001303a00c1533303c3370e900a0008a99981f981d0060a4c2c2c6074016603a018603001a603001c602c01e602c02064a66606c66e1d200000113232533303b303e002149858dd7181e000981a0090a99981b19b87480080044c8c94ccc0ecc0f800852616375c607800260680242a66606c66e1d200400113232533303b303e002149858dd7181e000981a0090a99981b19b87480180044c8c94ccc0ecc0f800852616375c607800260680242c60680222c607200260720046eb4c0dc004c0dc008c0d4004c0d4008c0cc004c0cc008c0c4004c0c4008c0bc004c0bc008c0b4004c0b4008c0ac004c0ac008c0a4004c08407858c0840748c94ccc08ccdc3a40000022a66604c60420042930b0a99981199b87480080044c8c94ccc0a0c0ac00852616375c605200260420042a66604666e1d2004001132325333028302b002149858dd7181480098108010b1810800919299981119b87480000044c8c8c8c94ccc0a4c0b00084c8c9263253330283370e9000000899192999816981800109924c64a66605666e1d20000011323253330303033002132498c04400458c0c4004c0a400854ccc0accdc3a40040022646464646464a666068606e0042930b1bad30350013035002375a606600260660046eb4c0c4004c0a400858c0a400458c0b8004c09800c54ccc0a0cdc3a40040022a666056604c0062930b0b181300118050018b18150009815001181400098100010b1810000919299981099b87480000044c8c94ccc098c0a400852616375a604e002603e0042a66604266e1d20020011323253330263029002149858dd69813800980f8010b180f800919299981019b87480000044c8c94ccc094c0a000852616375a604c002603c0042a66604066e1d20020011323253330253028002149858dd69813000980f0010b180f000919299980f99b87480000044c8c8c8c94ccc098c0a400852616375c604e002604e0046eb8c094004c07400858c0740048c94ccc078cdc3a400000226464a666046604c0042930b1bae3024001301c0021533301e3370e900100089919299981198130010a4c2c6eb8c090004c07000858c070004dd618100009810000980f8011bab301d001301d001301c00237566034002603400260320026030002602e0046eb0c054004c0340184cc004dd5980a180a980a980a980a980a980a980680300591191980080080191299980a8008a50132323253330153375e00c00229444cc014014008c054008c064008c05c004c03001cc94ccc034cdc3a40000022a666020601600e2930b0a99980699b874800800454ccc040c02c01c526161533300d3370e90020008a99980818058038a4c2c2c601600c2c60200026020004601c002600c00229309b2b118029baa001230033754002ae6955ceaab9e5573eae815d0aba24c126d8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affff004c0126d8799fd87a9f581cc8b0cc61374d409ff9c8512317003e7196a3e4d48553398c656cc124ffff0001","reference_script_hash":"c3e28c36c3447315ba5a56f33da6a6ddc1770a876a8d9f0cb3a97c4c","inline_datum_json":null},{"tx_hash":"2536194d2a976370a932174c10975493ab58fd7c16395d50e62b7c0e1949baea","output_index":0,"address":"addr1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzj2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq6a0h00","stake_address":"stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"18114930"}],"data_hash":null,"inline_datum":null,"script_ref":"8202590f7d590f7a010000332323232323232323223222232323232533300c3232323253330103370e90011807800899191919299980a19b87480000084cc004dd5980c980d180d180d180d180d180d1809003980c98090078991919191919191919191919299981019b87480080384c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc0c8cdc3a4000606200226464646464646464646464646464646464a666086666605600204002c0262a6660866660580400160022a666086605c605e032264646464646464646464646464646464a6660a6a6660a666e1cccc0e002413d221034d535000480084cdd79ba6009374c00629404c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc1f8cdc3a4000002264a6660fe66ebc0a405454ccc1fccdd78138098a99983f99baf0250111533307f3370e04601e2a6660fe66e1c08403454ccc1fccdc380f8058a99983fa99983f80b8801899983f801a504a22a6660fe66ebc0dc0c454ccc1fcccc0f002520a01f4802854ccc1fcccc0f001d20a01f4802840045280a5014a029405280a5014a029405280a5032533307f3370e90010008a51133303c375a61080260fa00a904827241083460fa0082a6660fc66e1d20020011533307e3375e0500282a6660fc66ebc09804854ccc1f8cdd78120080a99983f19b8702200e1533307e3370e0400182a6660fc66e1c07802854ccc1f8cdc380e0040a99983f19b8701a0061533307e3375e030008266ebc0d80c05280a5014a029405280a5014a02940528099baf07902b307c05c3370e9001183e9baa308101001308101002307f001307f002375a60fa00260fa0046eb4c1ec004c1ec008dd6983c800983c8011bad30770013077002375a60ea00260ea00460e600260e600460e200260e200460de00260ce02c66e1d20023069375460da00260da00460d600260d60046eb4c1a4004c1a4008dd6983380098338011bad30650013065002375a60c600260c60046eb4c184004c184008c17c004c17c008c174004c174008c16c004c14c140c12c004c0dc00458c15c004c15c008dd5982a800982a8011829800982599817013000982880098248019bab304f001304f002304d0013045001304b001304b0013042011222533304633712006004266e2400400c5280a5014a02940c94ccc10ccdc3a4000002200e2a66608666e1d2002001100210043041021304600130460023044001304400130430023041001304100130380013301c014035303d0013035001303b001303b001303200130380013030001163301300e0203758606a002606a00260680046eacc0c8004c0c8004c0c4008dd59817800981780098170011bac302c001302c0023758605400260540046eb0c0a0004c080054c098004c0780684c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc0c0cdc3a4000605e0022646464646464646464646464a666078666604800203602201c2a66607866604a03600c0022a666078604e6050028264646464646464646464646464646464646464a66609ea66609e66ebc03401c54ccc13ccdd7825001099b8733303400b04b4881034d535000480085280a501323232323232323232323232323232323232323375e6e98060dd31999812999981280f00400319b8100c533306253330623371e0109110013371e00c9110014a0266e0400520c0a8a50410010040023370201466608e03c00800466608c03a00e00a6eb8c194004c194008dd71831800982d8051bae30610013061002375c60be00260ae0106eb4c174004c174008dd6982d800982d800982d001182c000982c001182b000982b00098268250b18228009818800982880098288011bab304f001304f002304d001304533028022001304b0013043003375660920026092004608e002607e002608a002608a002607801a4444a66608266e1c005200010041323233001001006225333047001133048337606ea4018dd3001a5eb7bdb1804c8c8c8c94ccc120cdd79980600500126103d879800013304c337606ea4028dd30038028a99982419b8f00a00213232533304a3370e900000089982719bb03752018609e609000400a200a609000264a666092a66609800229445280a60103d87a800013374a9000198269ba60014bd70191980080080111299982680089982719bb037520166ea00292f5bded8c0264646464a66609c66ebccc04803c00930103d8798000133052337606ea403cdd40070028a99982719b8f00f0021323253330503370e900000089982a19bb0375202260aa609c00400a200a609c00264a66609e66e1c005200014c103d87a800013374a9000198299ba80014bd7019b8000100e133052337606ea4008dd4000998030030019bad304f003375c609a00460a2004609e00226609866ec0dd48011ba600133006006003375660920066eb8c11c008c12c008c124004c8c8008c8cc004004008894ccc11c004526132533304800114984c8c8c8c8c8c8c94ccc130cdc3a4000002266014014660a000c00a2c60940026601c0040026eb8c12800cdd7182480198268019825801182500118250009982299bb037520046ea00052f5bded8c044a66607c66e400080045300103d87980001533303e3371e0040022980103d87a800014c103d87b800014a0294052818200009820000981f800981b0009980d00a019981d8009819800981c800981c8009818000981b00098170008b1980880700f1bac303300130330013032002375660600026060002605e0046eacc0b4004c0b4004c0b0008dd6181500098150011bac302800130280023758604c002603c026446464a66604c605200420022c604e00266016004466ebcc09cc080c09cc08000400888cc0180088cdd79813180f8008011119299981019b8748000c07c0044c8c8c8c8c8c8c8c8c8c94ccc0a8cdc39998078018062441044d53475300480084c8c8008c94ccc0b0cdc3a40000022646464646464646464646464a666076607c00426464646464649318120031811803981100418108049810005191980080080611299981e8008a4c2646600600660820046042607e0022c607800260780046074002607400460700026070004606c002606c004606800260680046eb0c0c8004c0a800858c0a8004c03800458c0b8004c0b8008dd59816000981600098118009814800981480098100009813000980f0008b1980280111919191919191919299981419b87480080044cdc79bae302d302600200a14a0604c0026056002604600260520026042002604e002604e002603c002464a66603c66e1d200400113023301c00216301c00122232323253330213370e90010008a400026eb4c098c07c008c07c004c94ccc080cdc3a40040022980103d87a8000132323300100100222533302600114c103d87a800013232323253330273371e014004266e9520003302b375000297ae0133006006003375a60500066eb8c098008c0a8008c0a0004dd59812980f001180f00099198008008021129998118008a6103d87a800013232323253330243371e010004266e95200033028374c00297ae01330060060033756604a0066eb8c08c008c09c008c09400488c8cc00400400c894ccc0840045300103d87a8000132325333020300500213374a90001981200125eb804cc010010004c094008c08c0048c94ccc06ccdc3a400000226464a66604060460042930b1bae302100130190021533301b3370e900100089919299981018118010a4c2c6eb8c084004c06400854ccc06ccdc3a400800226464a66604060460042930b1bae30210013019002163019001222232533301d3370e900000089919198008008021129998118008a501323253330223371e00400a29444cc010010004c09c008dd718128009bae3022301b0051533301d3370e9001000899191919198008008041129998128008a501323253330243375e605260446052604460526054604400400a29444cc010010004c0a4008c09c004cdd2a4004660466ea40052f5c06eb8c08c004c06c0144cc02800ccdd2a40006604266e952002330213022301b0054bd7025eb80c06c010888c8c8c94ccc080c08c0044c8cc00400401c894ccc0880045288991929998109919191919191919299981499b87480080044c8c94ccc0accdd78020080a51132533302c3370e9001000899b8f002375c606260540262a66605866e1d200400113371e0046eb8c0c4c0a804c52818150091bae302f001302700214a2604e002605800260480026054002604400260500026050002603e0042660080080022940c098008c09000458cc0180148cdd78011811180d9811180d98111811980d8009810000980c00111191980080080191299980e8008a5eb804c8c94ccc070c0140084cc080008cc0100100044cc010010004c084008c07c0048cdd79ba60014c0101a0002323300100100222533301a00114bd6f7b630099191919299980d99b8f488100002100313301f337606ea4008dd3000998030030019bab301c003375c6034004603c004603800244646600200200644a66603400229404c8c8c94ccc068cdd78030008a51133005005002301a002301e002301c001301100c3016001300e00116301400130140023012001300a00514984d958c94ccc030cdc3a40000022a66601e601400c2930b0a99980619b87480080044c8c94ccc044c0500084c92632533300f3370e90000008a99980918068010a4c2c2a66601e66e1d200200115333012300d00214985854ccc03ccdc3a40080022a666024601a0042930b0b18068008b180900098050030a99980619b874801000454ccc03cc0280185261616300a0053001005232533300b3370e9000000899191919191919191919191919191919191919192999811181280109919191924c64a66604666e1d2000001132325333028302b002149858dd6981480098108038a99981199b874800800454ccc098c08401c52616163021006301901130180123253330203370e9000000899192999812981400109924c64a66604666e1d2000001132325333028302b002149858dd7181480098108010a99981199b87480080044c8c94ccc0a0c0ac00852616375c605200260420042c60420022c604c002603c0282a66604066e1d200200113232323232325333029302c002149858dd6981500098150011bad30280013028002375a604c002603c0282c603c0262c66e1d2002301f375460460026046004604200260420046eb4c07c004c07c008dd6980e800980e8011bad301b001301b002375a603200260320046eb4c05c004c05c008c054004c054008c04c004c04c008c044004c02400858c0240048c94ccc028cdc3a4000002264646464a66602260280042930b1bae30120013012002375c602000260100042c60100026eb80048c014dd5000918019baa0015734aae7555cf2ab9f5740ae855d126011e581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c0001","reference_script_hash":"ea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6b","inline_datum_json":null},{"tx_hash":"d46bd227bd2cf93dedd22ae9b6d92d30140cf0d68b756f6608e38d680c61ad17","output_index":0,"address":"addr1qyqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqzj2c79gy9l76sdg0xwhd7r0c0kna0tycz4y5s6mlenh8pq6a0h00","stake_address":"stake1u9f9v0z5zzlldgx58n8tklphu8mf7h4jvp2j2gddluemnssjfnkzz","amount":[{"unit":"lovelace","policy_id":null,"asset_name":"lovelace","quantity":"68429870"}],"data_hash":null,"inline_datum":null,"script_ref":"8202593d17593d1401000033323232323232323232232222323232533300b32323232533300f3370e90021807000899191919191919191919191919191919191919191919191919191919191919191919191919191919191919299981d299981d19299981d99b87480000044c8c8cc004004074894ccc10400452809919299982019b8f00200514a2266008008002608a0046eb8c10c004dd71820181c8018a99981d99b87480080044c8c8c8c8cc0040040b4894ccc10c00452809919299982119baf304730403047304030473048304000200514a2266008008002608e004608a00266e95200233041375200297ae0375c60820026072006264646600200204244a66608200229404c8c8c94ccc104cdd78030008a511330050050023041002304500230430013374a90001981f99ba548008cc0fcc100c0e400d2f5c097ae030390021533303a300101113375e6e98c8cc004004088894ccc0fc00452f5bded8c0264646464a66608066e3d2201000021003133044337606ea4008dd3000998030030019bab3041003375c607e00460860046082002980101a00014a029404c8c8c8c8c8c8c8c8c8c8c94ccc1154ccc114cdc39b8d01c300100813300300a00914a02a66609001429404c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc17c05c4c8c8c94ccc188c1940c84c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8cdd79ba7001374e6464646464646464646464646464666660020020800d40b202244444646464646464646464646464646464646464646464646464646464646464646464a66614a02a66614a0266e212000003153330a50133710900000f8a9998528099b8901f003153330a5013375e07c00e264a66614c0266e1d200200114a226466e241d0dd69854009919bb030ad0100130ad0130ae01001375861580200261480200461480200229405280a5014a02646464a6661560204620042666660520520460020420046eb0c2a404008dd61853808009929998530099b87480000044c8c8c8c8c8c8c8c8c8c94ccc2c004cdc42400000a266ec0dd39999999999999999981901100100d00c00b00a004803826825815023822821816801919b89006001374e0022c616a0205c61660205a66e1d200230ae0137546164020026164020046eb4c2c004004c2c004008c2b804004c2b804008cdc3a40046150026ea8c2b004004c2900401854ccc29804cdc3a400400226464646464646464a66615c0266e2120000031337606e9cccccccccccccccccc0c008000806005805004801c01412c1240a011410c1040ad28119b89001004374e0022c6166020586162020566eb4c2c004004c2c004008c2b804004c2b804008cdc3a40046150026ea8c2b004004c2900401854ccc29804cdc3a4008002264646464646464646464a66616002a6661600266e21200000513371090000018a501337606e9cccccccccccccccccc0c808800806806005805002401c13412c0a811c11410c0b528129998588099b8900600114a2266e24004010dd38008b185a808171859808169bad30b20100130b201002375a616002002616002004615c02002615c0200466e1d200230a801375461580200261480200c2a66614c0266e1d200600113232323232323232323253330b001337109000002899bb0374e646464646464646464646464646464646464646464646464646464646464a66619c0266e21200000113253330cf0100113253330d0013333053036034001022153330d0010291330d401375066e04cdc00100028021986a009ba83370203c04a661a8026ea0cdc000e0029986a009ba83370203404a661a8026ea00612f5c02661a8026ea0cdc08100129986a009ba83370266e00078014010cc35004dd419b8101c025330d401375066e00068014cc35004dd400c25eb8058cccccccc15410412402c02401c01401009054ccc33c054ccc33c040884cccc1480e40dccc14c10412408452808260b19b89003001163253330ce013370e900000089bad30d30130cc010251323253330d001337129000000899b813330a10104200c00a00116375a61a80200261980204a6198020486660e20020140bea6661960266e2008002c4cdc019b833370466e0803408120a09c013370466e0402c080cdc0a4141380201290010b1bae30cf0100130cf01002375c619a02002618a020166eb8c32c04004c32c04008dd71864808009860808049bad30c70100130c701002375a618a02002618a020046eb4c30c04004c30c04008c30404004c30404008c2fc040054ccc2e4040484cc2f404158cc2f404150cc2f404dd40049985e809ba8007330bd0137500a097ae01330bd01054330bd01056330bd01375000e6617a026ea0024cc2f404dd402725eb80dd6985e80800985e808011bad30bb0100130bb01002375a6172020026172020046eb4c2dc04004c2dc040b8dd6985a808169ba70011630b50102e30b30102d3370e90011857009baa30b20100130b201002375a616002002616002004615c02002615c0200466e1d200230a801375461580200261480200c2a66614c0266e1d20080011323232323232323253330ae01337109000002899bb0374e646464646464646464646464646464646464646464646464a66618c02a66618c0266e252000002153330c601337129000000899b8848000cdc00010008a5014a0264646464646464a66619a02002264a66619c0266660a20680640020442661a4026ea0cdc099b8002000a007330d201375066e04cdc000f00480299869009ba833700038014661a4026ea0cdc000d00499869009ba83370003000697ae016333307e333307e333307e333307e03f4890048810033702900002380a80999b814800002404403ccdc0a400001001a0160042a66619a02a66619a02044266660a006e06a660a207e08e0422940412858cdc48118009bad30d00100130d001002375a619c02002619c020046eb4c33004004c8c94ccc32004cdc4000801099191919867809ba8333305500200106205e330cf014c01010000330cf01375066e0ccdc119b813370400e00200402a66e00cdc100e80080125eb80dd69866008011bad30ca010013333305100400301a01805f153330c8013371000400226464646619e02981010000330cf01375066660aa0040020c00bc6619e026ea0cdc199b823370266e08018004008054cdc019b8201b0010024bd701bad30cc01002375a619402002666660a20060080300340ba266198029801010000330cc014c1010000330cc01375000497ae03370666e0800804405ccdc199b8200201001816375a618e020046eb4c31404004c94ccc31004cdc3a400000226466ec0c32804004c32804c32c04004c308040744c8c8c8c94ccc320054ccc32004cdc4a4000006266e25200000114a0266ec0dd419b813330990103a01000e003375066e04ccc264040e803002800458dd69866008009866008011bad30ca0100130c20101d30c20101c375c618e02002618e020046eb8c31404004c2f404160dd71861808009861808011bae30c10100130b901056375c617e02002617e020046eb8c2f404004c2d404150dd6985d80800985d808011bad30b90100130b901002375a616e02002616e020046eb4c2d404004c2d4040b0dd69859808159ba70011630b30102c30b10102b3370e90011856009baa30b00100130b001002375a615c02002615c0200461580200261480200c2a66614c0266e1d200a00113232323232323232323253330b00153330b001337109000003899b8848000014528099bb0374e64646464646464646464646464646464646464646464a66618c0266e21200000113232323253330ca0100113253330cb01333304e03102f00101d1330cf01375066e0406c010cc33c04dd419b81019003330cf01375066e0405c010cc33c04dd419b81015003330cf01375066e0404c0192f5c02c66660f666660f666660f666660f60789110048810033702900002200400319b814800001404003800c03002800854ccc328054ccc328040744cccc1340d00c8cc1380f011007052808238b29998648099b8902000213371203c0022940dd69865008011bad30c801001333304d01601400100e163253330c6013370e900000089bad30cb0130c40101f1323253330c801337129000000899b813330990103a00600400116375a61980200261880203e61880203c6eb8c32404004c32404008dd7186380800985f8082d1bae30c50100130c501002375c6186020026176020b06eb8c30404004c30404008dd7185f80800985b8082b1bad30bd0100130bd01002375a6176020026176020046eb4c2e404004c2e404008dd6985b80800985b808171bad30b50102d374e0022c616a0205c61660205a66e1d200230ae0137546164020026164020046eb4c2c004004c2c004008dd69857008009857008011856008009852008030a9998530099b87480300044c8c8c8c8c8c8c8c8c8c94ccc2c004cdc42400000a266ec0dd39919191919191919191919191919191919191929998618099b88480000044c8c8c8c8c8c8c94ccc328040044c94ccc32c04cccc1380c40bc00407454ccc32c040904cc33c04dd419b8101b007330cf01375066e04cdc080c80280199867809ba8017330cf01375066e0405400ccc33c04dd419b810130094bd70099867809ba83370266e0406c00c01ccc33c04dd419b81019005330cf01375066e0405c00ccc33c04dd400a99867809ba83370202601297ae016333307b333307b333307b03c4890048810033702900002200580499b814800002003c03400854ccc328054ccc328040744cccc1340d00c8cc1380f011007052808238b19b8901e001375a619a02002619a020046eb4c32c04004c32c04008dd69864808009919191919299986400810899866009ba833306d00405f05b330cc014c01010000330cc01375066e0000ccccc1b800800401017d2f5c026619802981010000330cc0137506660da0060ba0b666198026ea0cdc0002199983700080100182ea5eb80cdc080a80119b81016002375a618e020046eb4c31404004cccc12804c04400402c58c94ccc30c04cdc3a400000226eb4c32004c304040684c8c94ccc31404cdc4a4000002266e04ccc258040dc01801000458dd698648080098608080d18608080c9bae30c60100130c601002375c6188020026178020ae6eb8c30804004c30804008dd7186000800985c00800a99985c80809082a082b1bad30bd0100130bd01002375a6176020026176020046eb4c2e404004c2e404008dd6985b80800985b808171bad30b50102d374e0022c616a0205c61660205a66e1d200230ae0137546164020026164020046eb4c2c004004c2c004008c2b804004c2b804008cdc3a40046150026ea8c2b004004c2900401854ccc29804cdc3a401c002264646464646464646464646464646464646464a66617202a6661720266e21200000e153330b9013371090000060a99985c8099b884800002854ccc2e404cdc4240000102a6661720266e240200404cdc48198030a5014a029405280a501323232323232323232323232323253330c70153330c7010011323232323232323253330cf013375e002006266ebc10c01c52819ba548000cc348040e8cc348040e0cc348040d8cc348040d0cc348040c8cc348040c0cc34804cdd2a401c661a40266619c0204e98103d87a80004c0103d8798000330d2013750014661a4026ea008ccc34804dd401099869009ba83370203e900119869009ba801d330d201375003697ae0330d201375066e040b0120cc348040a92f5c060ec002660f41520200261a00200261a002002619e02004619a02002618a0202626660dc05a056026266ec0dd399865809ba800d330cb01375001666196026ea0024cc32c04dd400399865809ba80054bd701ba7012163370e90011863809baa30cb0100130cb01002375a6192020026192020046eb4c31c04004c31c04008dd69862808009862808011bad30c30100130c301002375a6182020026182020046eb4c2fc04004c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc368054ccc36804cdc42400000a2a6661b40266e240a40144cdd79ba6001374c0442940528099299986d8081a09986f809ba83370266e0008801800ccc37c04dd419b81020004330df01375066e00078018cc37c04dd419b8101c004330df013750034661be026ea0014cc37c04ccc36c0400530103d87a80004c0103d87980004bd7009986f809ba833702044008661be026ea0cdc099b80020006003330df01375066e04078010cc37c04dd419b8001c006330df013750034661be026ea0014cc37c04ccc36c04005300103d87a80004c0103d87980004bd70299986d0099b88480080ac4cdc48148020a50163333333305f04b05300c00a00800600400233307d00300d06b333307d01000e00200c3370205a002a6661aa0266e240b000440b04004c8c8c8c94ccc36004cdc42400000220022c66e0c008004cdc101480119b813370466e080a8004030cdc119b82028482827004038cdc0a414138020126eb8c36004004c36004008dd7186b008009867008059bae30d40100130d401002375c61a4020026194020126eb4c34004004c34004008dd69867008009867008011bad30cc0100130cc0100230ca0100130ca0100230c80100153330c20101b1330c60105f330c60105d330c60137500126618c026ea001ccc31804dd402ca5eb804cc31804174cc3180417ccc31804dd400399863009ba8009330c60137500ae97ae0375a618c02002618c020046eb4c31004004c31004008dd69861008009861008011bad30c00100130c001037375a617c0206c2c6eacc2f404004c2f404004c2d004008c2ec040d0c2e4040ccdd6985c00800985c008011bad30b60100130b601002375a6168020026168020046eb4c2c804004c2c804008dd69858008009858008011bad30ae0100130ae010023370e90011854009baa30ac0100130a401006153330a6013370e90080008991919191919191919191919299985900a9998590099b884800002454ccc2c804cdc42400000e266e21200000514a029404cdd81ba73232323232323232323232323232323232323232323253330c8013371090000008991919191919191919299986880800899299986900999982a81c01b00081109986b009ba83370266e04080014024cc35804dd419b813370203c00600e661ac026ea0cdc080e0029986b009ba833702034006661ac026ea0cdc080c005a5eb8058cccc20804cccc20804cccc20804cccc2080410d22010048810033702900002580680599b814800002805404c01004403c00854ccc344054ccc344040884cccc1500ec0e4cc15410c12c08452808270b19b89023003375a61a80200261a8020046eb4c34804004c34804008dd69868008009868008011bad30ce010013232323232323253330cf0133710002004264661a8026ea0ccc1d400419c18ccc35005301010000330d401375066e0401c004cc35004dd419b8000633330760050040010674bd70199999982b8030028020018130120330a9998678099b88002001132330d4014c01010000330d40137506660ea0020ca0c6661a8026ea0cdc0003999983b0020028008329986a009ba83370200c00297ae033333330570050060030040240260641330d3014c1010000330d3014c1010000330d301375000c661a6026ea00152f5c066e08010094cdc100201119b81018002337020320046eb4c33004008dd6986500800999982780b00a0008070b1929998640099b87480000044dd69866809863008108991929998650099b89480000044cdc099984d8081e0030020008b1bad30ce0100130c60102130c601020375c6196020026196020046eb8c32404004c30404170dd71863808009863808011bae30c50100130bd0105a375c6186020026186020046eb8c30404004c2e404160dd6985f80800985f808011bad30bd0100130bd01002375a6176020026176020046eb4c2e404004c2e4040c0dd6985b808179ba70011630b70103030b50102f3370e90011858009baa30b40100130b401002375a6164020026164020046eb4c2c004004c2c004008dd69857008009857008011856008009852008030a9998530099b87480500044cdd81ba7323232323232323232323232323232323232323253330ba0153330ba013371290000010a99985d0099b89480000044cdc42400066e000080045280a501330be01375066e00050008cc2f804dd419b80012001330be01375066e00040008cc2f804dd419b8000e001330be01375001897ae01633308a0102b00500353330b8013306a008006133702002064264a6661720266e240cc004400858ccc224040a92210048810033308801029007005375c6176020026176020046eb8c2e404004c2c404138dd7185b80800985b808011bae30b50100130ad0104c375a6166020026166020046eb4c2c404004c2c404008dd69857808009857808011bad30ad0100130ad01024375a6156020466e9c09458c2900401458c2a404004c2a404008dd6985380800985380801185280800985280801185180800985180801185080800985080801184f80800984f80801184e80800984e80801184d80800984d80801184c80800984880801181d8009981f837000984a80800984a808011bab309301001309301002309101001308901001308f01001308f01001308601004308d01005375a61160200861160200a6112020084444444444444444464646464646464646464646464646464646464646464a66614a0266e212000001132323253330a801001153330a80153330a801323232323232323253330b0013370e9000000899baf02e007153330b0013375e05c00e2660b000805a2940c2b804004c2cc04004c2ac040acc2c404004c2c404004c2c004008c2b804004c298040a44c8c8c94ccc2ac04cdc3a4000002266ebcc2c004c2a404008dd3181b8018991919191919191919baf374c0166e98cccccccc0e40d40ac010008dd7185c008009bae30b80130b90100100f00e30b001005375c616c02002616c020046eb8c2d004004c2b004008c2bc04008c2b4040054ccc2ac0409c4cdd8012812099bb002402530a9010013333222253330ad013305f00400313232323253330b401002153330b10102d13374a90001985a809ba6330b501337609801014000374c6616a0266ec13001014000375066e04004cdc0014806a5eb7bdb180cc2d404cdd81ba9006374c6616a0266ec0dd48029ba800c4bd6f7b63025eb7bdb1812f5c02c26464a66616c020042646464a66617202004008264a666174020022646464a66617402a66617402a666174026661740206c94128899b8f00500f14a0266e3c0040385280a99985d0099b8801600213374a90001985f009ba6330be0133760981014000374c6617c0266ec13001014000375066e00cdc080501900aa5eb7bdb180cc2f804cdd81ba9005374c6617c0266ec0dd48009ba83370200402c97adef6c604bd6f7b63025eb8054ccc2e804cdc380100b099ba548000cc2f804dd31985f0099bb04c1014000374c6617c0266ec13001014000375066e00cdc080501900aa5eb7bdb1812f5bded8c097ae01614c103d87a8000375c6174020046eb4c2e804004c2f00400c014c2f004008dd7185b008011bab30b60100130b80100400114c103d87a800030b701002375a616402616a026eacc2c804008c2d404008c2cc04004c0e40bc5300103d87a8000375c6068614e020466eb8c0b8c29c0408cdd7181a1853808111bae302e30a7010223756605a614c02052294054ccc2a0040904cc2b004dd419b813370003200800466158026ea0cdc080b80199856009ba83370002a00866158026ea0cdc080980199856009ba80114bd70099856009ba83370203200666158026ea0cdc099b80017004002330ac01375066e0405400ccc2b004dd419b80013004330ac01375002297ae016153330a80153330a80101b1333302b0280273302c02a02002914a020382c603200466609600400e036666609601401000200c2c64a66614a0266e1d20000011375a61540261460204026464a66614e0266e2520000011337026660f005200c0080022c6eb4c2ac04004c28c04080c28c0407cdd71854008009854008011bae30a601001309e01007375a6148020026148020046eb4c28804004c28804008dd6985000800985000801184f00800a99984c0080a09984e008091984e009ba80093309c01375000e66138026ea003d2f5c02661380202266138026ea001ccc27004dd40049984e009ba800e4bd701bad309c01001309c01002375a6134020026134020046eb4c26004004c26004008dd6984b00800984b008021bad309401003222232323232323232323232533308b013370e900000088020a999845808020998198028068a50308901001308e0100130860100b5333087013375e01400a266ebcdd30041ba600314a06116020026116020046eacc22404004c22404008c21c04004c1fc00488cccc0bc009221004881003370400290009184080984100800911111112999841009981a00300289999819999981980424410048810033702900019b800070020040030011533308201330340040031333303333330330084881004881003370200200e00c00a66e052000002133330333333033333303300848810048810033702900000380300299b814800000801000c0048888cdd81ba83370666e08008010004dd419b8333704004006002444444464646466e0ccdc0980799b813370400200266e08cdc12401000466e08cdc124141380201066e04cdc100300499b8200500a00133704900200119b803370466e08014008cdc000300419b820043370266e0801d20a09c013370400401266e0800c004cdc0a41413802002444446464646466ec0dd419b81300d33702601c00266e08cdc1001a4141380266e0520a09c01005001375066e08008cdc0a4141380200a66e08cdc099b824801120a09c0100400333704900219b800060043370466e0920083370000a00666e04cdc1002980580219b823370400c00800666e08cdc0002001001911119299983d99b87480080045200013370666e08cdc10028019bad30800130790023370466e0801120a09c01482827004c1e40048c1ec0048c8cc004004008894ccc1e800452f5bded8c02660f66466ec0c1e4004c1e8004c1f0004cc008008c1f400494ccc1d0cdc4000a40002c2a6660e866e1c005200014800054ccc1d0cdc3800a4004290010a99983a19b87001480105200213232333001001003002222533307833710002004266600600600266e0ccdc019b83005001001480104008cdc019b830014801120022337040020026eb0c1d8004c1d8008dd6183a000983a001183900098390011bad30700013070002375a60dc00260dc00460d800260d800460d400260d400460d000260c06666660220a400200a0bc940010c198004c198004c17400c58c18c0c4c188084c1840844c94ccc180c18c0744c8c94ccc188c1940e04c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc1eccdc3a402460f400a26464646464646464a6661060266e1d2000308201058132325333085015333085013371090000068a9998428099b884800009c54ccc21404cdc48138068a9998428099981600a8098140a999842809929998430099b874800800452889919b89054375a6110026466ec0c23404004c23404c23804004dd61846008009842008061842008058a9998428099baf01100315333085013371090000028a99984280998218250048a999842809826000899b89371a00290030a5014a029405280a5014a029405280a50132323232323232323232323232323232323232323232323232323232323232323253330a6013371090000008991929998540099b8902800213375e6e98004dd301f8a503333058333305833330580434890048810033702900002480480399b814800000801400c004ccc0780040800a858c94ccc29804cdc3a400000226eb4c2ac04c290040a04c8c94ccc2a004cdc4a4000002266e04ccc1e411002802000458dd69856008009852008141852008139bae30a90100130a901002375c614e02002613e0200a6eb8c29404004c29404008dd7185180800984d80801299984e0080508010802299984d8080608038802984f80800984f80801184e80800984a80805984d80800984d80801184c8080098488080419b8748008c24c04dd5184b80800984b8080098470080319b8748008c24004dd5184a00800984a008009845808021982a80319b81304800648008c24004014cc14c038cdc0982300724004611c0201a600200244446464646464a666126020082002266660120120020080046464646464646464646464646464646464646464646464646464646464646464a66615e0266ebc07400c4c8c8c8c8c8c94ccc2d404cdc42400005a26464a66616e0266e212000002153330b70153330b701009153330b7013370e66e04cdc000d0178008098a99985b8099b87337020300040222a66616e0266e1ccdc000b017807899b873370202800401a29405280a50153330b7013370e66e0406800804c54ccc2dc04cdc399b813370003005e0020222a66616e0266e1ccdc080b001007899b873370002805e01a29405280a501002161633305b02e00201e333305b00500302d00116375a6172020026172020046eb4c2dc04004c2dc04008dd6985a80800a99985780800899859809ba8012330b301375002066166026ea006d2f5c0266166026ea0040cc2cc04dd400919859809ba80194bd700b19b8748008c2bc04dd518598080098598080118588080098548080f1bad30af0100130af01002375a615a02002615a020046eb4c2ac04004c2ac04024dd69854808041bad30a80100130a801002375a614c02002614c020046eb4c29004004c29004010dd69851008019bac30a10100130a1010023758613e02002613e02004613a02002613a020046eb4c26c04004c26c04008dd6984c80800984c80801184b80800984b80800984b00800984680802184a008021849008019849008019848008011919199980080080182602d911119191919191929998488099baf374e00a980101800015333091015333091013375e9801018000374e006266ebd301018000374e00229404cc25404ccc02c0180100092f5c02c26612a0266601600c008004666601401400a006002612c0200c61280200a61280200a6124020086124020086120020064446666660700f2611c02611e02610e0200600410a02944004ccc1180052f5c04466092002660a00980042c6eb8c22404004c2040416058c21c04004c1fcc21804014dd69842808009842808011841808009841808011bac308101001307900516307f001307f002375a60fa00260fa00460f600260f600460f200260f200460ee00260ee00460ea00260ea00260e800260e600260d400460280026603008e0086eacc1b8004c1b8004c194028c1ac004c1ac008dd598348009834800983000098330009833000982e8018b1bad3063037306204716306101c22232533305f3370e90010008a4000266e0ccdc119b82004003375a60c860ba00466e0920a09c01482827004c1740048888c8c8cdc199b820010053370066e0920a09c010060013370400200666e0520a09c0100122232323232323232323253330653370e900000088010a9998328010998068028058a5030630023375e01400c60ce00260be01060ca00260ca00260c800460c400260b40024466ebc004c94ccc16ccdc3a40000022980103d87980001533305b3370e9001000899ba548008cc17cc180c16400d2f5c0266e9520043305f37526e50dd99830182c801a5eb80c1640088c94ccc164cdc3a40000022646464646464646464646464646464646464a6660dc60e2004264646464646464649319299983999b87480000044c8c94ccc1e0c1ec0084c92632375a60f00046eb4c1d800458c8cdd8183d000983d183d8009bac30790013071009153330733370e90010008a99983b18388048a4c2c2c60e201064a6660e466e1d200000113232323232323232533307d308001002132498c09801458cdc3a400460f46ea8c1f8004c1f8008dd6983e000983e001183d000983d00119b8748008c1d0dd5183c00098380060a99983919b87480080044c8c8c8c8c8c94ccc1ecc1f80084c926302400316375a60f800260f800460f400260f400466e1d20023074375460f000260e00182a6660e466e1d200400113232323232323232533307d308001002132498c09801458dd6983f000983f0011bad307c001307c002307a001307a0023370e9001183a1baa3078001307000c153330723370e900300089919191919191919299983e98400080109924c604c00a2c66e1d2002307a375460fc00260fc0046eb4c1f0004c1f0008c1e8004c1e8008cdc3a400460e86ea8c1e0004c1c003054ccc1c8cdc3a40100022646464646464a6660f660fc0042649319299983c99b87480000044c8c8c8c94ccc20004c20c0400852616375a6102020026102020046eb4c1fc004c1dc01854ccc1e4cdc3a4004002264646464a666100026106020042930b1bad308101001308101002375a60fe00260ee00c2c60ee00a2c66e1d20023078375460f800260f80046eb4c1e8004c1e8008c1e0004c1c003054ccc1c8cdc3a401400226464646464646464a6660fa6100020042649318138038b19b8748008c1e8dd5183f000983f0011bad307c001307c002375a60f400260f400460f000260e00182a6660e466e1d200c00113232323232323232533307d308001002132498c09c01458cdc3a400460f46ea8c1f8004c1f8008dd6983e000983e001183d000983d00119b8748008c1d0dd5183c00098380060a99983919b87480380044c8c8c8c8c8c8c8c8c8c8c8c8c8c94ccc20c04c2180400852616375a6108020026108020046eb4c20804004c20804008dd69840008009840008011bad307e001307e002375a60f800260f80046eb4c1e8004c1e8008cdc3a400460e86ea8c1e0004c1c003054ccc1c8cdc3a4020002264646464646464646464a6660fe6104020042649318148048b19b8748008c1f0dd51840008009840008011bad307e001307e002375a60f800260f80046eb4c1e8004c1e8008c1e0004c1c003054ccc1c8cdc3a40240022646464646464a6660f660fc0042646493181280219837802919299983d19b87480000044c8c8c8c94ccc20404c210040084c9263037003163370e9001183f1baa308201001308201002308001001307800216307800116375a60f800260f800460f400260f40046eb0c1e0004c1c003054ccc1c8cdc3a40280022a6660ea60e00182930b0b18380059814806180c806980c807180b807980b80819299983619b87480000044c8c94ccc1c4c1d000852616375c60e400260d40242a6660d866e1d20020011323253330713074002149858dd7183900098350090a99983619b87480100044c8c94ccc1c4c1d000852616375c60e400260d40242a6660d866e1d20060011323253330713074002149858dd7183900098350090b18350088b183780098378011bad306d001306d002306b001306b0023069001306900230670013067002306500130650023063001306300230610013061002305f001305700216305700123253330583370e90000008a99982d982b0010a4c2c2a6660b066e1d200200113232533305d3060002149858dd7182f000982b0010a99982c19b87480100044c8c94ccc174c18000852616375c60bc00260ac0042c60ac002464a6660ae66e1d2000001132323232533305e306100213232498c94ccc174cdc3a400000226464a6660c460ca00426493180b8008b1831800982d8018a99982e99b874800800454ccc180c16c00c5261616305b002301500316305f001305f002305d001305500216305500123253330563370e900000089919299982d982f0010a4c2c6eb4c170004c15000854ccc158cdc3a400400226464a6660b660bc0042930b1bad305c001305400216305400123253330553370e900000089919299982d182e8010a4c2c6eb4c16c004c14c00854ccc154cdc3a400400226464a6660b460ba0042930b1bad305b0013053002163053001223253330553370e90020008982d18298010a99982a99b87480080044c8c8cc004004014894ccc16c004584c8c8c8c94ccc170cdc78038010800899803003001982e8019bae305b002305f002305d001375c60b460a60042c60a60024444446464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464646464a66611e02a66611e02074266e1c08c0345288a99984780a9998478099baf0290131533308f013375e05207c2a66611e0266ebc0dc0c454ccc23c04cdd78138088a9998478099baf02500f1533308f013370e03a00e2a66611e0266e1c06c01454ccc23c04cdd780c8018a9998478080b8800899984780800a504a229405280a5014a029405280a5014a02646464646464646464646464646464646464646464a66614802a6661480266ebcdd30011ba604a153330a4013375e6e98004dd3022099b873370200600866e040e00885280a5013374a9000198540081e198540081d198540080619854009ba833305403204e02c330a80137506660a806009c058661500205c66150026e9ccc2a004dd401b19854009ba8034330a801375001066150026ea0018cc2a004dd401c25eb80cc2a004dd399854009ba8020330a801375003c66150026ea001ccc2a004dd400299854009ba80224bd7025eb8058cccc150cccc150cccc150cccc150cccc1512f5bded8c009e9101034d5350004800804c04401803c03401013c03000922010048810048302a29410cccc14ccccc14ccccc14ccccc14ccccc14d2f5bded8c009c911034d5350004800804804001803803001013802c00922010048810048302a29410ccc1c8104134028ccc1c4118130024ccc1c00fc02c024ccc1bc110028020dd6984f808011bad309d01001533309b013304d00b0091337606ea0cdc080124181514a086ea0cdc0800a4181514a08266ec0dd40011ba800133306b03a00a00833306a03f0090073374a90001984e009ba90443309c01375200297ae0304d3304e304d3304e007005304d3304e003001375c6136020026136020046eb8c26404004c244040a4dd7184b80800984b808011bae309501001308d0102716163370e90011847809baa309301001309301002309101001309101002375a611e02002611e020046eb4c23404004c23404008dd69845808009845808011bad308901001308901002375a610e02002610e02004610a02002610a0200461060200261060200461020200260f2004605a002609602e66e1d20023079375460fa00260fa00460f600260f60046eb4c1e4004c1e4008dd6983b800983b8011bad30750013075002375a60e600260e60046eb4c1c4004c1c4008c1bc004c1bc008c1b4004c1b4008c1ac004c18c008c05c004c0d401cc19c004c19c008dd5983280098328011831800982d805183080098308011bab305f001305f002305d0013055005222325333054333008001482807d200a100116533305300113253330543370e9001000880209919299982b199805000a4120069000099b8000100616375a60b400260a400660a400420064444a6660a666e1c00520001004132323300100100622533305900113305a337606ea4018dd3001a5eb7bdb1804c8c8c8c94ccc168cdd799807805001260103d879800013305e337606ea4028dd30038028a99982d19b8f00a00213232533305c3370e900000089983019bb0375201860c260b400400a200a60b400264a6660b6a6660bc00229445280a60103d87a800013374a90001982f9ba60014bd70191980080080111299982f80089983019bb037520166ea00292f5bded8c0264646464a6660c066ebccc05403c00930103d8798000133064337606ea403cdd40070028a99983019b8f00f0021323253330623370e900000089983319bb0375202260ce60c000400a200a60c000264a6660c266e1c005200014c103d87a800013374a9000198329ba80014bd7019b8000100e133064337606ea4008dd4000998030030019bad3061003375c60be00460c600460c20022660bc66ec0dd48011ba600133006006003375660b60066eb8c164008c174008c16c004c8c8008c8cc004004008894ccc164004526132533305a00114984c8c8c8c8c8c8c94ccc178cdc3a4000002266014014660c400c00a2c60b8002660220040026eb8c17000cdd7182d801982f801982e801182e001182e0009982b99bb037520046ea00052f5bded8c044a6660a066e3c00922010013371e0029110014a0464a66609e66e1d20000011323232323232323232323232323232323232323253330663069002132323232498c178018c078044c074048c06c04c58cdc3a400460c66ea8c19c004c19c008c194004c194008dd6983180098318011bad30610013061002375a60be00260be0046eb4c174004c174008dd6982d800982d801182c800982c801182b800982b801182a80098268010b1826800911299982799b890030021337120020062940894ccc134cdc80010008a60103d87980001533304d3371e0040022980103d87a800014c103d87b8000237260024466e280080048c94ccc128cdc3a400000226464a66609e60a40042649318030008b182800098240010a99982519b87480080044c8c8c8c8c8c94ccc14cc15800852616375a60a800260a80046eb4c148004c148008dd6982800098240010b1824000919299982499b87480000044c8c8c8c94ccc140c14c00852616375c60a200260a20046eb8c13c004c11c00858c11c0048c94ccc120cdc3a400000226464a66609a60a00042930b1bae304e0013046002153330483370e900100089919299982698280010a4c2c6eb8c138004c11800858c118004c12c02858c004004894ccc12000452000133700900119801001182580098008009112999823801099baf374e00298010180001533304700114a026660060066094004609400266600202e97ae022330040013300b00500222232323330010010040022225333046337100029000080109998018019980299b8e00700100233702002900119b81371a0069001111982200080119801815919191919191919299982299982299baf0010404a09444c94ccc118cdc3a4000002266608c66ebc008cdd2a4000660946096608801c97ae04a094454ccc118cdc3a4004002266608c66ebc008cdd2a4004660946096608801c97ae04a09444ccc118cdd780119ba548008cc128c12cc1100392f5c09412898220068a50304900130410013047001303f00130450013045001303c0013300202623375e608460766084607600207066002052466ebcc104c0e8c104c0e8c104c108c0e80040dc88c8cc00400400c894ccc10400452f5c026464a666080600a00426608800466008008002266008008002608a00460860022c464a66607666e212000001132333001001489400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000480008894ccc0f8cdc48020008a511323253330403370e00290010a50133300500533714666e312000002004337169001199b8c337000049001241000200866e0000d20023371c00600266e380140045281b8d001330010060133001001222533303933712900a0008999801801981f981f981f981f981f981f981f981f981f981f80119b81001480504cc010008004c0040048894ccc0dccdc4800a4000260780042666006006607a00466e0400520023232323232323232533303b3370e9001181d00189919299981e99b8748008c0f000c4c8c94ccc0fccdc499b810010033370466e092014481e120d00f13370066e0ccdc0800801a40080062c6eb4c10c004c0ec00c58dd69820800981c8018b181f800981b801981e800981a801981d800981d801181c800981880a9bac3037001302f001323253330323370e90001818800899191919191919191919299981e19b8733300d003038489044d53475300480084c8c8008c94ccc0f8cdc3a40000022646464646464646464646464a66609a60a0004264646464646493180f803180f003980e804180e004980d805198208059180d8008b18270009827001182600098260011825000982500118240009824001182300098230011bac3044001303c00216303c001300e00116304000130400023756607c002607c002606a002607600260760026064002607000260600022c646600200203c44a66606c002298103d87a80001323253330353232323232323232533303d3370e9001000899b8f375c608460760040722940c0ec004c100004c0e0004c0f8004c0d8004c0f0004c0f0004c0cc0084cdd2a40006607200497ae0133004004001303a002303800123253330323370e900000089919299981b981d0010a4c2c6eb8c0e0004c0c000854ccc0c8cdc3a400400226464a66606e60740042930b1bae30380013030002153330323370e900200089919299981b981d0010a4c2c6eb8c0e0004c0c000858c0c0004888c8c8c94ccc0d4cdc3a40040022900009bad303a303300230330013253330343370e90010008a6103d87a8000132323300100100222533303a00114c103d87a8000132323232533303b3371e014004266e9520003303f375000297ae0133006006003375a60780066eb8c0e8008c0f8008c0f0004dd5981c98190011819000991980080080211299981b8008a6103d87a800013232323253330383371e010004266e9520003303c374c00297ae0133006006003375660720066eb8c0dc008c0ec008c0e40048c94ccc0c0cdc3a40080022606a605c0042c605c0026eb0c0c8004c0c8008c0c0004c0c0008dd7181700098170011bac302c001302c002375a6054002604403c6eacc0a0004c0a0004c09c008dd618128009812801181180098118011bab3021001302100130200023756603c002603c002603a0046eb0c06c004c06c008dd6180c800980c8011bac3017001300f0053015001300d00116301300130130023011001300900414984d958c94ccc02ccdc3a4000002264646464646464646464a6660306036004264646493198070019180800099299980b99b87480000044c8c94ccc070c07c00852616375c603a002602a00a2a66602e66e1d20020011533301a301500514985858c054010cc03001c8dd68008b1bac3019001301900230170013017002375c602a002602a0046eb0c04c004c04c008dd6980880098048028b18048021119198008008019129998080008a4c26466006006602800460066024002464a66601466e1d200000113232533300f3012002149858dd6980800098040010a99980519b874800800454ccc034c02000852616163008001375c0024600a6ea80048c00cdd5000ab9a5573aaae7955cfaba05742ae8930011e581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c004c0122d87a9f581cea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6bff0001","reference_script_hash":"1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a","inline_datum_json":null}],"invalid":false},"scripts":[{"tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","script_hash":"c3e28c36c3447315ba5a56f33da6a6ddc1770a876a8d9f0cb3a97c4c","script_content":"{\"type\":\"PlutusScriptV2\",\"content\":\"590a63590a60010000333232…trimmed\"}","type":"PLUTUS_V2","redeemer":{"tag":"spend","index":1,"data":"d87980","ex_units":{"mem":25305,"steps":8177555}},"datum":"d8799fd8799f581c1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709bffd8799fd8799f581c1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709bffd8799fd8799fd8799f581c56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4aeffffffffd87980d8799fd8799f581c1deffc2d9368b1857992c8f308a9ac1eeff1f9770f18ac751df4709bffd8799fd8799fd8799f581c56bba06a48ff2c7b1e5aa2600e2a3e5e561c1fcd234518e1c4b9d4aeffffffffd87980d8799f581cf5808c2c990d86da54bfc97d89cee6efa20cd8461616359478d96b4c5820a449f98fa06fff1d6c17b95c65ca609660049dc683cc3e94d3846b05419fc20dffd8799fd87980d8799f1b00000005d3e330fdff1a201fd225d87980ff1a001e8480d87a80ff","datum_hash":"a68a4714db51b7c1c5d840ccd7091457ac71e929809c83d74d2b325fdb289598"},{"tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","script_hash":"ea07b733d932129c378af627436e7cbc2ef0bf96e0036bb51b3bde6b","script_content":"{\"type\":\"PlutusScriptV2\",\"content\":\"590f7d590f7a010000332323…trimmed\"}","type":"PLUTUS_V2","redeemer":{"tag":"spend","index":0,"data":"d87980","ex_units":{"mem":77308,"steps":23525984}},"datum":"d8799fd8799fd87a9f581c1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44affffd8799f4040ffd8799f581ca0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c23545484f534b59ff1b0000025e0d9e03181b0000006cc9e9a2631b000011d434cc16ff18321832d8799f190682ffd87980ff","datum_hash":"8905d200e7126f9342ecbbb6e958792ab264ac5e2edc1cd0722ba5e4354c69a7"},{"tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","script_hash":"1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a","script_content":"{\"type\":\"PlutusScriptV2\",\"content\":\"593d17593d14010000333232…trimmed\"}","type":"PLUTUS_V2","redeemer":{"tag":"reward","index":0,"data":"d8799f009f1a001e8480ff4100d87a809fd87a80ffff","ex_units":{"mem":1336746,"steps":430057225}},"datum":null,"datum_hash":null}],"withdrawals":[{"block_number":14078555,"block_time":1792209120,"address":"stake17y02a946720zw6pw50upt2arvxsvvpvaghjtl054h0f0gjsfyjz59","tx_hash":"1da5cdfa6287dc0df7283eb10e50e50c0f236eaa65fcad79516b4d6760c557e4","tx_index":14,"amount":0,"epoch":662,"slot":200642829}],"mints":[],"votes":[],"stakes":[],"delegations":[],"params":{"cost_models_raw":{"PlutusV1":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,228465,122,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,228465,122,0,1,1,90434,519,0,1,74433,32,85848,228465,122,0,1,1,85848,228465,122,0,1,1,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,53384111,14333,10,955506,213312,0,2,43053543,10,43574283,26308,10,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,1293828,28716,63,0,1,1006041,43623,251,0,1,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21],"PlutusV2":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,228465,122,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,228465,122,0,1,1,90434,519,0,1,74433,32,85848,228465,122,0,1,1,85848,228465,122,0,1,1,955506,213312,0,2,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,43053543,10,53384111,14333,10,43574283,26308,10,1293828,28716,63,0,1,1006041,43623,251,0,1,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21],"PlutusV3":[100788,420,1,1,1000,173,0,1,1000,59957,4,1,11183,32,201305,8356,4,16000,100,16000,100,16000,100,16000,100,16000,100,16000,100,100,100,16000,100,94375,32,132994,32,61462,4,72010,178,0,1,22151,32,91189,769,4,2,85848,123203,7305,-900,1716,960,57,85848,0,1,1,1000,42921,4,2,30623,28755,75,1,898148,27279,1,51775,558,1,39184,1000,60594,1,141895,32,83150,32,15299,32,76049,1,13169,4,22100,10,28999,74,1,28999,74,1,43285,552,1,44749,541,1,33852,32,68246,32,72362,32,7243,32,7391,32,11546,32,85848,123203,7305,-900,1716,960,57,85848,0,1,90434,519,0,1,74433,32,85848,123203,7305,-900,1716,960,57,85848,0,1,1,85848,123203,7305,-900,1716,960,57,85848,0,1,955506,213312,0,2,270652,22588,4,1457325,64566,4,20467,1,4,0,141992,32,100788,420,1,1,81663,32,59498,32,20142,32,24588,32,20744,32,25933,32,24623,32,43053543,10,53384111,14333,10,43574283,26308,10,16000,100,16000,100,962335,18,2780678,6,442008,1,52538055,3756,18,267929,18,76433006,8868,18,52948122,18,1995836,36,3227919,12,901022,1,166917843,4307,36,284546,36,158221314,26549,36,74698472,36,333849714,1,254006273,72,2174038,72,2261318,64571,4,207616,8310,4,1293828,28716,63,0,1,1006041,43623,251,0,1,100181,726,719,0,1,100181,726,719,0,1,100181,726,719,0,1,107878,680,0,1,95336,1,281145,18848,0,1,180194,159,1,1,158519,8942,0,1,159378,8813,0,1,107490,3298,1,106057,655,1,1964219,24520,3,607153,231697,53144,0,1,116711,1957,4,231883,10,1000,24838,7,1,232010,32,321837444,25087669,18,617887431,67302824,36,356924,18413,45,21,219951,9444,1,1000,172116,183150,6,24,21,213283,618401,1998,28258,1,1000,38159,2,22,1000,95933,1,1,11,1000,277577,12,21]},"protocol_major_ver":11},"genesis":{"active_slots_coefficient":0.05,"update_quorum":5,"max_lovelace_supply":"45000000000000000","network_magic":764824073,"epoch_length":432000,"system_start":1506203091,"slots_per_kes_period":129600,"slot_length":1,"max_kes_evolutions":62,"security_param":2160}} diff --git a/applications/viewer/src/lib/julc/replay/fork.test.ts b/applications/viewer/src/lib/julc/replay/fork.test.ts new file mode 100644 index 00000000..173e6f86 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fork.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { buildRuns, type ReplayInput } from './build'; +import { applyEdits, countEdits, refOf } from './fork'; +import { slotConfigFor } from './slots'; +import withdrawZero from './fixtures/mainnet-withdrawZero.json'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const f = withdrawZero as any; +const base: ReplayInput = { + hash: f.hash, + tx: f.tx, + scripts: f.scripts, + withdrawals: f.withdrawals, + mints: f.mints, + votes: [], + certificates: [], + datums: {}, + params: f.params, + slots: slotConfigFor(f.genesis.network_magic, f.genesis) +}; +const runs = (input: ReplayInput) => Object.fromEntries(buildRuns(input).map((r) => [r.key, r])); + +describe('applyEdits', () => { + it('changes nothing without edits', () => { + expect(buildRuns(applyEdits(base, {}))).toEqual(buildRuns(base)); + expect(countEdits({})).toBe(0); + }); + + it('replaces a redeemer of one run only', () => { + const r = runs(applyEdits(base, { redeemers: { 'spend#0': 'd87a80' } })); + expect(r['spend#0'].request!.transaction.redeemer).toEqual({ format: 'cbor', value: 'd87a80' }); + expect(r['spend#1'].request!.transaction.redeemer).toEqual({ format: 'cbor', value: 'd87980' }); + }); + + it('replaces an inline datum of a spent input', () => { + const input = [...f.tx.inputs].sort((a, b) => (a.tx_hash < b.tx_hash ? -1 : 1))[0]; + const r = runs(applyEdits(base, { datums: { [refOf(input)]: '182a' } })); + expect(r['spend#0'].datum).toBe('182a'); + expect(r['spend#0'].request!.transaction.inputs[0].datum).toEqual({ kind: 'inline', data: { format: 'cbor', value: '182a' } }); + }); + + it('edits signers, validity, fee, outputs, mint and withdrawals', () => { + const edits = { + signers: [], + validFrom: null, + validTo: f.tx.ttl + 100, + fee: '1', + outputs: { 0: { lovelace: '5', assets: { [`${'aa'.repeat(28)}01`]: '7' } }, 1: { removed: true } }, + mints: { [`${'bb'.repeat(28)}02`]: '-3' }, + withdrawals: { [f.withdrawals[0].address]: '10' }, + protocol: 10 as const + }; + const tx = runs(applyEdits(base, edits))['spend#0'].request!; + expect(tx.transaction.signatories).toEqual([]); + expect(tx.transaction.validRange.from).toBeNull(); + expect(tx.transaction.validRange.to).toBe(BigInt(base.slots!.zeroTime) + BigInt(f.tx.ttl + 100 - base.slots!.zeroSlot) * 1000n); + expect(tx.transaction.fee).toBe('1'); + expect(tx.transaction.outputs.length).toBe(f.tx.outputs.length - 1); + expect(tx.transaction.outputs[0].value.lovelace).toBe('5'); + expect(tx.transaction.outputs[0].value.assets).toContainEqual({ policyId: 'aa'.repeat(28), tokenName: '01', quantity: '7' }); + expect(tx.transaction.mint).toEqual([{ policyId: 'bb'.repeat(28), tokenName: '02', quantity: '-3' }]); + expect(tx.transaction.withdrawals).toEqual([{ credential: f.withdrawals[0].address, amount: '10' }]); + expect(tx.target.protocol).toBe(10); + expect(countEdits(edits)).toBe(9); + }); + + it('runs with another epoch’s parameters', () => { + const other = { ...f.params, cost_models_raw: { ...f.params.cost_models_raw, PlutusV2: [1, 2, 3] } }; + const tx = runs(applyEdits(base, {}, other))['spend#0'].request!; + expect(tx.costModel!.parameters).toEqual([1n, 2n, 3n]); + }); +}); diff --git a/applications/viewer/src/lib/julc/replay/fork.ts b/applications/viewer/src/lib/julc/replay/fork.ts new file mode 100644 index 00000000..ebf43439 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/fork.ts @@ -0,0 +1,132 @@ +import type { ReplayInput, StoreScriptRun, StoreUtxo } from './build'; +import { purposeOf } from './build'; + +/* + * A fork of a transaction: edits applied to the store-shaped transaction before it is rebuilt, so everything + * downstream (ledger order, purposes, the context fixes, the redeemer map) stays consistent for every script. + * Absent fields mean "as on chain". + */ + +export interface OutputEdit { + removed?: boolean; + address?: string; + lovelace?: string; + /** unit → quantity; "0" removes the asset. */ + assets?: Record; +} + +export interface ForkEdits { + /** Run key (spend#0, …) → redeemer CBOR. */ + redeemers?: Record; + /** Spent input ("txHash#index") → datum CBOR. A datum given by hash keeps its hash. */ + datums?: Record; + /** Replaces the required signers (key hashes). */ + signers?: string[]; + /** Slots; null removes the bound. */ + validFrom?: number | null; + validTo?: number | null; + fee?: string; + /** By output index. */ + outputs?: Record; + /** unit → quantity minted (negative burns); "0" removes. */ + mints?: Record; + /** Stake address → amount. */ + withdrawals?: Record; + /** Run with this protocol version's rules (julc supports 10 and 11). */ + protocol?: 10 | 11; +} + +export const refOf = (u: { tx_hash: string; output_index: number }) => `${u.tx_hash}#${u.output_index}`; + +function redeemerOf(s: StoreScriptRun) { + if (!s.redeemer) return null; + if (typeof s.redeemer !== 'string') return s.redeemer; + try { + return JSON.parse(s.redeemer); + } catch { + return null; + } +} + +export const runKeyOf = (s: StoreScriptRun): string | null => { + const r = redeemerOf(s); + return r?.tag ? `${purposeOf(r.tag) ?? String(r.tag).toLowerCase()}#${Number(r.index ?? 0)}` : null; +}; + +function editAmounts(amount: StoreUtxo['amount'], lovelace: string | undefined, assets: Record | undefined): StoreUtxo['amount'] { + let out = amount.map((a) => (a.unit === 'lovelace' && lovelace != null ? { ...a, quantity: lovelace } : a)); + for (const [unit, quantity] of Object.entries(assets ?? {})) { + out = out.filter((a) => a.unit !== unit); + if (quantity !== '0') out.push({ unit, quantity }); + } + return out; +} + +/** The transaction with the edits applied. `params` (another epoch's) replaces the transaction's own. */ +export function applyEdits(base: ReplayInput, e: ForkEdits, params?: ReplayInput['params']): ReplayInput { + const datums = { ...base.datums }; + const inputs = base.tx.inputs.map((u) => { + const datum = e.datums?.[refOf(u)]; + if (datum == null) return u; + if (u.data_hash) { + datums[u.data_hash] = datum; + return u; + } + return { ...u, inline_datum: datum }; + }); + // The scripts endpoint also carries the spent datum; keep it in step with the edit. + const scripts = base.scripts.map((s) => { + const key = runKeyOf(s); + const r = redeemerOf(s); + let next = s; + if (key && r && e.redeemers?.[key] != null) next = { ...next, redeemer: { ...r, data: e.redeemers[key] } }; + if (s.datum_hash && datums[s.datum_hash] !== base.datums[s.datum_hash]) next = { ...next, datum: datums[s.datum_hash] }; + return next; + }); + const outputs = base.tx.outputs + .filter((u) => !e.outputs?.[u.output_index]?.removed) + .map((u) => { + const o = e.outputs?.[u.output_index]; + return o ? { ...u, address: o.address ?? u.address, amount: editAmounts(u.amount, o.lovelace, o.assets) } : u; + }); + let mints = base.mints.map((m) => (e.mints?.[m.unit] != null ? { ...m, quantity: e.mints[m.unit] } : m)); + for (const [unit, quantity] of Object.entries(e.mints ?? {})) if (!mints.some((m) => m.unit === unit)) mints.push({ unit, quantity }); + mints = mints.filter((m) => String(m.quantity) !== '0'); + const withdrawals = base.withdrawals.map((w) => (e.withdrawals?.[w.address] != null ? { ...w, amount: e.withdrawals[w.address] } : w)); + const p = params ?? base.params; + + return { + ...base, + tx: { + ...base.tx, + inputs, + outputs, + fees: e.fee ?? base.tx.fees, + required_signers: e.signers ?? base.tx.required_signers, + validity_interval_start: e.validFrom !== undefined ? e.validFrom : base.tx.validity_interval_start, + ttl: e.validTo !== undefined ? e.validTo : base.tx.ttl + }, + scripts, + mints, + withdrawals, + datums, + params: p && e.protocol ? { ...p, protocol_major_ver: e.protocol } : p, + paramsEpoch: params ? undefined : base.paramsEpoch + }; +} + +/** How many things a fork changes, for the "N edits" badge. */ +export function countEdits(e: ForkEdits): number { + return ( + Object.keys(e.redeemers ?? {}).length + + Object.keys(e.datums ?? {}).length + + (e.signers ? 1 : 0) + + (e.validFrom !== undefined ? 1 : 0) + + (e.validTo !== undefined ? 1 : 0) + + (e.fee != null ? 1 : 0) + + Object.keys(e.outputs ?? {}).length + + Object.keys(e.mints ?? {}).length + + Object.keys(e.withdrawals ?? {}).length + + (e.protocol ? 1 : 0) + ); +} diff --git a/applications/viewer/src/lib/julc/replay/from-cbor.test.ts b/applications/viewer/src/lib/julc/replay/from-cbor.test.ts new file mode 100644 index 00000000..51b57bfa --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/from-cbor.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; +import { decodeTransaction } from '$lib/tx/decode'; +import { buildRuns, type ReplayInput, type StoreCert } from './build'; +import { replayInputFromCbor } from './from-cbor'; +import { slotConfigFor } from './slots'; +import cborFx from './fixtures/mainnet-tx-cbor.json'; +import withdrawZero from './fixtures/mainnet-withdrawZero.json'; +import v3 from './fixtures/mainnet-v3.json'; +import mint from './fixtures/mainnet-mint.json'; +import proposal from './fixtures/mainnet-proposal.json'; + +/* + * The same Mainnet transactions replayed two ways: from their CBOR (fixtures/mainnet-tx-cbor.json) and from Yaci + * Store's JSON (the other fixtures). Both must describe the same runs and the same transaction to julc. + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function storeInput(f: any): ReplayInput { + const certificates: StoreCert[] = [ + ...(f.stakes ?? []).map((s: { cert_index: number; address: string; registration: boolean }) => ({ cert_index: s.cert_index, type: s.registration ? 'registration' : 'deregistration', address: s.address })), + ...(f.delegations ?? []).map((d: { cert_index: number; address: string; pool_id: string }) => ({ cert_index: d.cert_index, type: 'delegation', address: d.address, pool_id: d.pool_id })) + ]; + // As load.ts does: a proposing run's script is the guardrail its proposal names, its body fetched by hash (taken + // from the transaction's witnesses here). + const witnessed = new Map(decodeTransaction((cborFx.txs as Record)[f.hash].cbor).witnesses.scripts.map((s) => [s.hash, s])); + const scripts = f.scripts.map((s: { script_hash: string | null; script_content: string | null; type: string | null; redeemer: { tag: string; index: number } }) => { + const guardrail = !s.script_hash && /^propos/.test(s.redeemer.tag) ? f.proposals?.[s.redeemer.index]?.details?.policyHash : null; + const hash = s.script_hash ?? guardrail; + const w = hash ? witnessed.get(hash) : null; + return { ...s, script_hash: hash, type: s.type ?? (w ? `plutus${w.language}` : null), script_content: s.script_content ?? (w ? JSON.stringify({ content: w.cbor }) : null) }; + }); + return { hash: f.hash, tx: f.tx, scripts, withdrawals: f.withdrawals ?? [], mints: f.mints ?? [], votes: f.votes ?? [], certificates, proposals: f.proposals ?? [], datums: {}, params: f.params, slots: slotConfigFor(f.genesis.network_magic, f.genesis) }; +} + +function cborInput(store: ReplayInput): ReplayInput { + const cbor = (cborFx.txs as Record)[store.hash].cbor; + const known = new Map([...store.tx.inputs, ...(store.tx.reference_inputs ?? [])].map((u) => [`${u.tx_hash}#${u.output_index}`, u])); + const { input, missing } = replayInputFromCbor(decodeTransaction(cbor), { resolve: (r) => known.get(`${r.txHash}#${r.index}`) ?? null, params: store.params, slots: store.slots, mainnet: true }); + expect(missing).toEqual([]); + // Reference scripts aren't in the transaction: the loader fetches their bodies by hash. Take them from the store here. + const bodies = new Map(store.scripts.map((s) => [s.script_hash, s])); + return { ...input, scripts: input.scripts.map((s) => (s.script_content ? s : { ...s, type: bodies.get(s.script_hash)?.type ?? null, script_content: bodies.get(s.script_hash)?.script_content ?? null })) }; +} + +const summary = (input: ReplayInput) => + buildRuns(input).map((p) => ({ key: p.key, scriptHash: p.scriptHash, language: p.language, redeemer: p.redeemer, declared: p.declared, datum: p.datum, skip: p.skip })); +const txOf = (input: ReplayInput, key: string) => { + const t = buildRuns(input).find((p) => p.key === key)!.request!.transaction; + return { inputs: t.inputs, referenceInputs: t.referenceInputs, outputs: t.outputs, mint: t.mint, withdrawals: t.withdrawals, validRange: t.validRange, signatories: t.signatories, fee: t.fee }; +}; + +describe('replays from CBOR match replays from the store', () => { + for (const [name, f] of Object.entries({ withdrawZero, v3, mint, proposal })) { + it(`${name}: same runs and the same transaction`, () => { + const store = storeInput(f); + const cbor = cborInput(store); + expect(summary(cbor)).toEqual(summary(store)); + const first = buildRuns(store).find((p) => p.request)!.key; + expect(txOf(cbor, first)).toEqual(txOf(store, first)); + expect(buildRuns(cbor).every((p) => p.source === 'cbor')).toBe(true); + expect(buildRuns(store).every((p) => p.source === 'store')).toBe(true); + }); + } + + it('encodes a proposal’s governance action from CBOR exactly as from the store’s JSON', () => { + const store = storeInput(proposal); + const cbor = cborInput(store); + const [fromStore] = buildRuns(store); + const [fromCbor] = buildRuns(cbor); + expect(fromCbor.proposals).toEqual(fromStore.proposals); + expect(fromCbor.proposals[0]).toBeTruthy(); + }); + + it('uses the witnessed datums exactly', () => { + const cbor = (cborFx.txs as Record)['a5554f4aa71cac02c10f6f523779ad210c5eb384b681789b15eb60014c48ad2e'].cbor; + const tx = decodeTransaction(cbor); + const { input, missing } = replayInputFromCbor(tx, { resolve: () => null, params: null, slots: null, mainnet: true }); + expect(missing).toHaveLength(2); + expect(input.witnessDatums).toEqual([{ hash: tx.witnesses.datums[0].hash, datum: tx.witnesses.datums[0].cbor }]); + expect(input.datums[tx.witnesses.datums[0].hash]).toBe(tx.witnesses.datums[0].cbor); + }); +}); diff --git a/applications/viewer/src/lib/julc/replay/from-cbor.ts b/applications/viewer/src/lib/julc/replay/from-cbor.ts new file mode 100644 index 00000000..76da7a02 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/from-cbor.ts @@ -0,0 +1,265 @@ +import { bech32Decode, bech32Encode } from '$lib/bech32'; +import { encodePlutusData, type PlutusData } from '$lib/cbor'; +import { bytesToHex, untag, type CborItem } from '$lib/tx/cbor-item'; +import { paymentCredential, type Credential, type DecodedTx, type Proposal, type TxOutput } from '$lib/tx/decode'; +import type { ReplayInput, StoreCert, StoreProposal, StoreScriptRun, StoreUtxo, StoreVote } from './build'; + +/* + * A replay from the transaction's own CBOR (when Yaci Store saved it): the decoded body and witnesses are turned into + * the same ReplayInput the store-JSON path builds, so the rest of the pipeline (buildRuns, context fixes, debugger) + * is shared. What the CBOR makes exact: every redeemer and the script it runs, witnessed datums, outputs as encoded, + * certificates in order, votes, and proposals (their governance action is encoded from the CBOR, not from JSON). + * The spent inputs and reference inputs aren't in a transaction; they come resolved from the store. + */ + +const hexCmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); +/** Ledger order of credentials: script hashes before key hashes, then by hash. */ +const credKey = (c: Credential) => (c.script ? '0' : '1') + c.hash; + +function stakeAddress(c: Credential, mainnet: boolean): string { + const header = (c.script ? 0xf0 : 0xe0) | (mainnet ? 1 : 0); + return bech32Encode(mainnet ? 'stake' : 'stake_test', new Uint8Array([header, ...c.hash.match(/../g)!.map((h) => parseInt(h, 16))])); +} + +function addressCredential(address: string): Credential | null { + const d = bech32Decode(address); + return d ? paymentCredential(bytesToHex(d.bytes)) : null; +} + +function outputUtxo(hash: string, o: TxOutput, index: number): StoreUtxo { + return { + tx_hash: hash, + output_index: index, + address: o.address, + amount: [{ unit: 'lovelace', quantity: String(o.value.lovelace) }, ...o.value.assets.map((a) => ({ unit: a.policyId + a.name, quantity: String(a.quantity) }))], + data_hash: o.datum.kind === 'hash' ? o.datum.hash : null, + inline_datum: o.datum.kind === 'inline' ? o.datum.cbor : null, + reference_script_hash: o.scriptRef?.hash ?? null + }; +} + +// --- governance actions straight from their CBOR (cardano-ledger's ToPlutusData) --- + +const I = (n: bigint | number): PlutusData => ({ int: String(n) }); +const B = (h: string): PlutusData => ({ bytes: h }); +const C = (constructor: number, fields: PlutusData[] = []): PlutusData => ({ constructor, fields }); +const L = (list: PlutusData[]): PlutusData => ({ list }); +const nothing = C(1); + +function itemBytes(i: CborItem | undefined): string { + if (i?.type !== 'bytes') throw new Error('expected bytes'); + return bytesToHex(i.value); +} +function itemInt(i: CborItem | undefined): bigint { + if (i?.type === 'uint' || i?.type === 'nint') return i.value; + throw new Error('expected an integer'); +} + +/** A protocol-parameter value as the ledger gives it to Plutus: rationals as [n, d], arrays as lists, maps as maps. */ +function paramData(i: CborItem): PlutusData { + const x = untag(i); + if (!x) throw new Error('missing value'); + if (x.type === 'uint' || x.type === 'nint') return I(x.value); + if (x.type === 'tag' && x.tag === 30) return paramData(x.item); + if (x.type === 'array') return L(x.items.map(paramData)); + if (x.type === 'map') return { map: x.entries.map((e) => ({ k: paramData(e.k), v: paramData(e.v) })).sort((a, b) => Number(BigInt((a.k as { int: string }).int ?? 0) - BigInt((b.k as { int: string }).int ?? 0))) }; + if (x.type === 'bytes') return B(bytesToHex(x.value)); + throw new Error(`unexpected ${x.type} in a parameter update`); +} + +function maybePrev(i: CborItem | undefined): PlutusData { + if (!i || i.type !== 'array') return nothing; + return C(0, [C(0, [B(itemBytes(i.items[0])), I(itemInt(i.items[1]))])]); +} +const maybeHash = (i: CborItem | undefined): PlutusData => (i?.type === 'bytes' ? C(0, [B(bytesToHex(i.value))]) : nothing); +function credentialData(i: CborItem): { key: string; data: PlutusData } { + if (i.type !== 'array') throw new Error('expected a credential'); + const c = { script: itemInt(i.items[0]) === 1n, hash: itemBytes(i.items[1]) }; + return { key: credKey(c), data: C(c.script ? 1 : 0, [B(c.hash)]) }; +} + +function actionData(a: CborItem): PlutusData { + if (a.type !== 'array') throw new Error('expected a governance action'); + const f = a.items; + switch (Number(itemInt(f[0]))) { + case 0: { + const update = untag(f[2]); + if (update?.type !== 'map') throw new Error('expected a parameter update'); + const entries = update.entries.map((e) => ({ k: Number(itemInt(e.k)), v: paramData(e.v) })).sort((x, y) => x.k - y.k); + return C(0, [maybePrev(f[1]), { map: entries.map((e) => ({ k: I(e.k), v: e.v })) }, maybeHash(f[3])]); + } + case 1: { + const v = f[2]?.type === 'array' ? f[2].items : []; + return C(1, [maybePrev(f[1]), C(0, [I(itemInt(v[0])), I(itemInt(v[1]))])]); + } + case 2: { + const w = untag(f[1]); + if (w?.type !== 'map') throw new Error('expected withdrawals'); + const entries = w.entries.map((e) => { + const acct = itemBytes(e.k); + const c = { script: (parseInt(acct.slice(0, 2), 16) & 0x10) !== 0, hash: acct.slice(2) }; + return { key: credKey(c), k: C(c.script ? 1 : 0, [B(c.hash)]), v: I(itemInt(e.v)) }; + }); + return C(2, [{ map: entries.sort((x, y) => hexCmp(x.key, y.key)).map((e) => ({ k: e.k, v: e.v })) }, maybeHash(f[2])]); + } + case 3: + return C(3, [maybePrev(f[1])]); + case 4: { + const removed = (untag(f[2])?.type === 'array' ? (untag(f[2]) as { items: CborItem[] }).items : []).map(credentialData).sort((x, y) => hexCmp(x.key, y.key)); + const added = (untag(f[3])?.type === 'map' ? (untag(f[3]) as { entries: { k: CborItem; v: CborItem }[] }).entries : []) + .map((e) => ({ ...credentialData(e.k), epoch: itemInt(e.v) })) + .sort((x, y) => hexCmp(x.key, y.key)); + const q = untag(f[4]); + const [n, d] = q?.type === 'tag' && q.item.type === 'array' ? q.item.items : q?.type === 'array' ? q.items : []; + return C(4, [maybePrev(f[1]), L(removed.map((r) => r.data)), { map: added.map((x) => ({ k: x.data, v: I(x.epoch) })) }, C(0, [I(itemInt(n)), I(itemInt(d))])]); + } + case 5: { + const c = f[2]?.type === 'array' ? f[2].items : []; + return C(5, [maybePrev(f[1]), C(0, [maybeHash(c[1])])]); + } + default: + return C(6); + } +} + +/** A ProposalProcedure as Plutus data CBOR: deposit, return credential, governance action. */ +export function procedureCbor(p: Proposal): string { + const acct = p.rewardAccount; + const ret = { script: (parseInt(acct.slice(0, 2), 16) & 0x10) !== 0, hash: acct.slice(2) }; + return encodePlutusData(C(0, [I(p.deposit), C(ret.script ? 1 : 0, [B(ret.hash)]), actionData(p.action)])); +} + +const PROPOSAL_TYPES: Record = { + parameterChange: 'PARAMETER_CHANGE_ACTION', + hardFork: 'HARD_FORK_INITIATION_ACTION', + treasuryWithdrawals: 'TREASURY_WITHDRAWALS_ACTION', + noConfidence: 'NO_CONFIDENCE', + updateCommittee: 'UPDATE_COMMITTEE', + newConstitution: 'NEW_CONSTITUTION', + info: 'INFO_ACTION' +}; + +const VOTER_TYPES = { committeeScript: 'CONSTITUTIONAL_COMMITTEE_HOT_SCRIPT_HASH', committeeKey: 'CONSTITUTIONAL_COMMITTEE_HOT_KEY_HASH', drepScript: 'DREP_SCRIPT_HASH', drepKey: 'DREP_KEY_HASH', pool: 'STAKING_POOL_KEY_HASH' } as const; +const VOTER_ORDER = ['committeeScript', 'committeeKey', 'drepScript', 'drepKey', 'pool']; + +export interface CborReplayContext { + /** The spent inputs and reference inputs, resolved from the store (address, value, datum, reference script). */ + resolve: (ref: { txHash: string; index: number }) => StoreUtxo | null; + params: ReplayInput['params']; + paramsEpoch?: number | null; + slots: ReplayInput['slots']; + mainnet: boolean; +} + +/** + * The ReplayInput for a decoded transaction. `missing` lists inputs the store couldn't resolve (their runs can't be + * rebuilt). Scripts that only exist as reference scripts come without a body; load.ts fetches those by hash. + */ +export function replayInputFromCbor(tx: DecodedTx, ctx: CborReplayContext): { input: ReplayInput; missing: string[] } { + const missing: string[] = []; + const resolve = (ref: { txHash: string; index: number }) => { + const u = ctx.resolve(ref); + if (!u) missing.push(`${ref.txHash}#${ref.index}`); + return u; + }; + const inputs = tx.inputs.map(resolve).filter((u): u is StoreUtxo => !!u); + const referenceInputs = tx.referenceInputs.map(resolve).filter((u): u is StoreUtxo => !!u); + + const witnessed = new Map(tx.witnesses.scripts.map((s) => [s.hash, s])); + const datums: Record = Object.fromEntries(tx.witnesses.datums.map((d) => [d.hash, d.cbor])); + + // Which script each redeemer runs, by the ledger's ordering of what the purpose points into. + const sortedInputs = [...tx.inputs].sort((a, b) => hexCmp(a.txHash, b.txHash) || a.index - b.index); + const policies = [...new Set(tx.mint.map((m) => m.policyId))].sort(hexCmp); + const withdrawals = [...tx.withdrawals].sort((a, b) => hexCmp(credKey(a.credential), credKey(b.credential))); + const voters = [...new Map(tx.votes.map((v) => [`${v.voter.kind}:${v.voter.hash}`, v.voter])).values()].sort((a, b) => VOTER_ORDER.indexOf(a.kind) - VOTER_ORDER.indexOf(b.kind) || hexCmp(a.hash, b.hash)); + const scriptOf = (tag: string, index: number): string | null => { + switch (tag) { + case 'spend': { + const ref = sortedInputs[index]; + const u = ref ? ctx.resolve(ref) : null; + const c = u ? addressCredential(u.address) : null; + return c?.script ? c.hash : null; + } + case 'mint': + return policies[index] ?? null; + case 'reward': + return withdrawals[index]?.credential.script ? withdrawals[index].credential.hash : null; + case 'cert': { + const c = tx.certificates[index]?.credential; + return c?.script ? c.hash : null; + } + case 'vote': + return voters[index] && /Script$/.test(voters[index].kind) ? voters[index].hash : null; + case 'propose': + return tx.proposals[index]?.policy ?? null; + } + return null; + }; + + const scripts: StoreScriptRun[] = tx.witnesses.redeemers.map((r) => { + const hash = scriptOf(r.tag, r.index) ?? ''; + const s = witnessed.get(hash); + const spent = r.tag === 'spend' ? sortedInputs[r.index] && ctx.resolve(sortedInputs[r.index]) : null; + return { + script_hash: hash, + type: s && s.language !== 'native' ? `plutus${s.language}` : null, + script_content: s ? JSON.stringify({ content: s.cbor }) : null, + redeemer: { tag: r.tag, index: r.index, data: r.data, ex_units: { mem: Number(r.exUnits.mem), steps: Number(r.exUnits.steps) } }, + datum: spent ? (spent.inline_datum ?? (spent.data_hash ? (datums[spent.data_hash] ?? null) : null)) : null, + datum_hash: spent?.data_hash ?? null + }; + }); + + const certificates: StoreCert[] = tx.certificates.map((c) => { + const address = c.credential ? stakeAddress(c.credential, ctx.mainnet) : ''; + if (c.kind === 'stakeRegistration' || c.kind === 'registration') return { cert_index: c.index, type: 'registration', address }; + if (c.kind === 'stakeDeregistration' || c.kind === 'unregistration') return { cert_index: c.index, type: 'deregistration', address }; + if (c.kind === 'stakeDelegation') return { cert_index: c.index, type: 'delegation', address, pool_id: c.poolId ? bech32Encode('pool', new Uint8Array(c.poolId.match(/../g)!.map((h) => parseInt(h, 16)))) : null }; + return { cert_index: c.index, type: 'other', address }; + }); + + const votes: StoreVote[] = tx.votes.map((v) => ({ voter_type: VOTER_TYPES[v.voter.kind], voter_hash: v.voter.hash, gov_action_tx_hash: v.action.txHash, gov_action_index: v.action.index, vote: v.vote.toUpperCase() })); + + const proposals: StoreProposal[] = tx.proposals.map((p, index) => ({ + index, + type: PROPOSAL_TYPES[p.kind], + deposit: String(p.deposit), + return_address: p.returnAddress, + details: { + policyHash: p.policy, + ...(p.kind === 'treasuryWithdrawals' && p.action.type === 'array' && untag(p.action.items[1])?.type === 'map' + ? { withdrawals: Object.fromEntries((untag(p.action.items[1]) as { entries: { k: CborItem; v: CborItem }[] }).entries.map((e) => [itemBytes(e.k), String(itemInt(e.v))])) } + : {}) + } + })); + + return { + missing, + input: { + hash: tx.hash, + tx: { + fees: String(tx.fee), + ttl: tx.ttl == null ? null : Number(tx.ttl), + validity_interval_start: tx.validityStart == null ? null : Number(tx.validityStart), + required_signers: tx.requiredSigners, + inputs, + outputs: tx.outputs.map((o, i) => outputUtxo(tx.hash, o, i)), + reference_inputs: referenceInputs + }, + scripts, + withdrawals: tx.withdrawals.map((w) => ({ address: w.address, amount: String(w.amount) })), + mints: tx.mint.map((m) => ({ unit: m.policyId + m.name, quantity: String(m.quantity) })), + votes, + certificates, + proposals, + datums, + paramsEpoch: ctx.paramsEpoch, + params: ctx.params, + slots: ctx.slots, + source: 'cbor', + witnessDatums: tx.witnesses.datums.map((d) => ({ hash: d.hash, datum: d.cbor })), + procedures: tx.proposals.map(procedureCbor) + } + }; +} diff --git a/applications/viewer/src/lib/julc/replay/load.ts b/applications/viewer/src/lib/julc/replay/load.ts new file mode 100644 index 00000000..063f86b8 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/load.ts @@ -0,0 +1,169 @@ +import { api, tryApi } from '$lib/api/client'; +import { purposeOf, type ReplayInput, type StoreCert, type StoreProposal, type StoreScriptRun, type StoreUtxo, type StoreVote } from './build'; +import { slotConfigFor, type Genesis } from './slots'; +import { replayInputFromCbor } from './from-cbor'; +import { decodeTransaction, wholeTxHex } from '$lib/tx/decode'; +import { utxoOf } from '../authoring/utxo'; + +/* + * The store data a replay needs beyond what the transaction page already loaded: the epoch's cost models and + * protocol version, genesis (slot times), votes, certificates and datums referenced by hash. Fetched in the + * browser only when the user asks for a re-run. + */ + +export interface PageTx { + hash: string; + tx: ReplayInput['tx']; + scripts: StoreScriptRun[]; + withdrawals: { address: string; amount: number | string }[]; + mints: { unit: string; quantity: number | string }[]; + epoch: number | null; +} + +function safeJson(text: string) { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +interface BfStake { + cert_index: number; + address: string; + registration: boolean; +} +interface BfDelegation { + cert_index: number; + address: string; + pool_id: string; +} + +/** The transaction's stored CBOR (hex), when the store saves it (store.transaction.save-cbor); otherwise null. */ +export async function loadTxCbor(hash: string): Promise { + const r = await tryApi<{ cbor_hex?: string; cborHex?: string }>(`/txs/${hash}/cbor/hex`); + return wholeTxHex(r?.cbor_hex ?? r?.cborHex); +} + +/** The epoch's parameters (the latest when the store lacks them, e.g. a devnet's epoch 0) and slot times. */ +async function paramsAndSlots(epoch: number | null) { + const [epochParams, genesis] = await Promise.all([epoch != null ? tryApi(`/epochs/${epoch}/parameters`) : null, tryApi('/genesis')]); + const params = epochParams ?? (await api('/epochs/latest/parameters')); + return { params, paramsEpoch: epochParams ? epoch : null, genesis, slots: slotConfigFor(genesis?.network_magic, genesis) }; +} + +/** Script bodies (and types) the transaction doesn't carry, e.g. reference scripts, fetched by hash. */ +function withScriptBodies(scripts: StoreScriptRun[]): Promise { + return Promise.all( + scripts.map(async (s) => { + if (!s.script_hash || !s.redeemer || (s.script_content && s.type)) return s; + const [info, body] = await Promise.all([s.type ? null : tryApi<{ type?: string }>(`/scripts/${s.script_hash}`), s.script_content ? null : tryApi<{ cbor?: string }>(`/scripts/${s.script_hash}/cbor`)]); + return { ...s, type: s.type ?? info?.type ?? null, script_content: s.script_content ?? (body?.cbor ? JSON.stringify({ content: body.cbor }) : null) }; + }) + ); +} + +/** + * The replay input of a transaction: from its stored CBOR when the store has it (exact), else rebuilt from Yaci + * Store's JSON. `cbor` can be passed in when the caller already loaded it. + */ +export async function loadReplayInput(page: PageTx, cbor?: string | null): Promise { + const hex = cbor === undefined ? await loadTxCbor(page.hash) : cbor; + if (hex) { + try { + return await fromCbor(page, hex); + } catch { + /* not decodable here: rebuild from the store's JSON */ + } + } + return fromStore(page); +} + +async function fromCbor(page: PageTx, hex: string): Promise { + const tx = decodeTransaction(hex); + if (tx.hash !== page.hash) throw new Error('The stored CBOR is of another transaction.'); + const known = new Map([...page.tx.inputs, ...(page.tx.reference_inputs ?? [])].map((u) => [`${u.tx_hash}#${u.output_index}`, u])); + // Inputs the transaction page didn't resolve (rare) are looked up one by one. + const unknown = [...tx.inputs, ...tx.referenceInputs].filter((r) => !known.has(`${r.txHash}#${r.index}`)); + for (const r of await Promise.all(unknown.map((r) => tryApi(`/utxos/${r.txHash}/${r.index}`).then(utxoOf)))) if (r) known.set(`${r.tx_hash}#${r.output_index}`, r); + const env = await paramsAndSlots(page.epoch); + const { input } = replayInputFromCbor(tx, { + resolve: (r) => known.get(`${r.txHash}#${r.index}`) ?? null, + params: env.params, + paramsEpoch: env.paramsEpoch, + slots: env.slots, + mainnet: env.genesis?.network_magic === 764824073 + }); + // Datums of spent inputs given by hash but not witnessed (the ledger needs them witnessed; this covers stores + // that resolve inputs without their datum). + const wanted = [...new Set(input.tx.inputs.map((u) => u.data_hash).filter((h): h is string => !!h && !input.datums[h]))]; + for (const [h, c] of await Promise.all(wanted.map((h) => tryApi<{ cbor: string }>(`/scripts/datum/${h}/cbor`).then((d) => [h, d?.cbor] as const)))) if (c) input.datums[h] = c; + return { ...input, scripts: await withScriptBodies(input.scripts) }; +} + +async function fromStore(page: PageTx): Promise { + const { hash, tx } = page; + // Certificates and votes are only fetched when a script runs for one: some stores don't allow these + // endpoints from the browser (no CORS), and most transactions have neither. + const tags = new Set(page.scripts.map((s) => purposeOf((typeof s.redeemer === 'string' ? safeJson(s.redeemer) : s.redeemer)?.tag))); + const [env, votes, stakes, delegations, proposals] = await Promise.all([ + paramsAndSlots(page.epoch), + tags.has('vote') ? tryApi(`/governance/votes/txs/${hash}`) : null, + tags.has('cert') ? tryApi(`/blockfrost/txs/${hash}/stakes`) : null, + tags.has('cert') ? tryApi(`/blockfrost/txs/${hash}/delegations`) : null, + tags.has('propose') ? tryApi(`/governance/proposals/${hash}`) : null + ]); + + // Datums given by hash on inputs and outputs, unless the scripts endpoint already resolved them. + const known = new Set(page.scripts.filter((s) => s.datum && s.datum_hash).map((s) => s.datum_hash)); + const wanted = [...new Set([...tx.inputs, ...tx.outputs].map((u: StoreUtxo) => u.data_hash).filter((h): h is string => !!h && !known.has(h)))]; + const found = await Promise.all(wanted.map((h) => tryApi<{ cbor: string }>(`/scripts/datum/${h}/cbor`).then((d) => [h, d?.cbor] as const))); + const datums: Record = {}; + for (const [h, c] of found) if (c) datums[h] = c; + + const certificates: StoreCert[] = [ + ...(Array.isArray(stakes) ? stakes : []).map((s) => ({ cert_index: s.cert_index, type: s.registration ? ('registration' as const) : ('deregistration' as const), address: s.address })), + ...(Array.isArray(delegations) ? delegations : []).map((d) => ({ cert_index: d.cert_index, type: 'delegation' as const, address: d.address, pool_id: d.pool_id })) + ]; + + // A proposing run has no script hash in the store: it is the guardrail its proposal names. + const byIndex = new Map((Array.isArray(proposals) ? proposals : []).map((p) => [Number(p.index), p])); + const scripts = await withScriptBodies( + page.scripts.map((run) => { + const r = typeof run.redeemer === 'string' ? safeJson(run.redeemer) : run.redeemer; + const guardrail = !run.script_hash && purposeOf(r?.tag) === 'propose' ? byIndex.get(Number(r?.index ?? 0))?.details?.policyHash : null; + return guardrail ? { ...run, script_hash: guardrail } : run; + }) + ); + + return { + paramsEpoch: env.paramsEpoch, + hash, + tx, + scripts, + withdrawals: page.withdrawals, + mints: page.mints, + votes: Array.isArray(votes) ? votes : [], + certificates, + proposals: Array.isArray(proposals) ? proposals : [], + datums, + params: env.params, + slots: env.slots, + source: 'store' + }; +} + +/** Another epoch's parameters (cost models, protocol version), to run a transaction under them. */ +export const loadParams = (epoch: number) => api(`/epochs/${epoch}/parameters`); + +/** A transaction as the re-run needs it, loaded in the browser (the transaction page loads the same on the server). */ +export async function loadPageTx(hash: string, epoch: number | null = null, scripts?: StoreScriptRun[]): Promise { + const [tx, s, withdrawals, mints] = await Promise.all([ + api(`/txs/${hash}`), + scripts ? Promise.resolve(scripts) : tryApi(`/txs/${hash}/scripts`), + tryApi(`/txs/${hash}/withdrawals`), + tryApi<{ unit: string; quantity: number | string }[]>(`/assets/txs/${hash}`) + ]); + const e = epoch ?? (await tryApi<{ epoch: number }>(`/blocks/${tx.block_height}`))?.epoch ?? null; + return { hash, tx, scripts: s ?? [], withdrawals: withdrawals ?? [], mints: (mints ?? []).map((m) => ({ unit: m.unit, quantity: m.quantity })), epoch: e }; +} diff --git a/applications/viewer/src/lib/julc/replay/run.test.ts b/applications/viewer/src/lib/julc/replay/run.test.ts new file mode 100644 index 00000000..5d67bc4e --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/run.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('../engine.svelte', () => ({ julc: {} })); +const { argsFor, vmRequest } = await import('./run'); +import type { RunPlan } from './build'; + +const plan = (over: Partial): RunPlan => ({ + key: 'spend#0', + purpose: 'spend', + index: 0, + scriptHash: 'ab'.repeat(28), + language: 'V2', + declared: null, + redeemer: 'd87980', + datum: '01', + hashedDatums: [], + witnessDatums: [], + proposals: [], + request: { script: { script: '4e4d01000033222220051200120011', language: 'V2' }, transaction: {} as never, target: { language: 'PlutusV2', protocol: 11 }, costModel: null, maxCpu: 10n }, + notes: [], + ...over +}); +const c = (value: string) => ({ format: 'cbor' as const, value }); + +describe('argsFor', () => { + it('applies V3 scripts to the context only', () => { + expect(argsFor(plan({ language: 'V3' }), 'ctx')).toEqual([c('ctx')]); + }); + it('applies V1/V2 spending scripts to datum, redeemer and context', () => { + expect(argsFor(plan({}), 'ctx')).toEqual([c('01'), c('d87980'), c('ctx')]); + expect(argsFor(plan({ datum: null }), 'ctx')).toBeNull(); + }); + it('applies other V1/V2 scripts to redeemer and context', () => { + expect(argsFor(plan({ purpose: 'mint', datum: null }), 'ctx')).toEqual([c('d87980'), c('ctx')]); + }); +}); + +describe('vmRequest', () => { + it('keeps the script, target, cost model and limits of the plan', () => { + const p = plan({}); + expect(vmRequest(p, [c('x')])).toEqual({ script: p.request!.script, args: [c('x')], target: p.request!.target, costModel: null, maxCpu: 10n, maxMem: undefined }); + }); +}); diff --git a/applications/viewer/src/lib/julc/replay/run.ts b/applications/viewer/src/lib/julc/replay/run.ts new file mode 100644 index 00000000..46dfafa7 --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/run.ts @@ -0,0 +1,131 @@ +import { julc } from '../engine.svelte'; +import type { DataInput, Evaluation, JulcClient, TransactionRequest } from '../types'; +import { buildRuns, type RunPlan } from './build'; +import { TAG_ORDER, fixContext, ownRedeemer } from './fix-context'; +import { loadReplayInput, type PageTx } from './load'; + +/* + * The re-run pipeline shared by the Scripts tab and the debugger: rebuild the transaction, let julc build and + * evaluate each redeemer's context, then apply the context corrections (fix-context.ts) and evaluate again with the + * VM. Every result keeps the arguments the script was finally applied to, so the debugger replays exactly the run + * the user saw. + */ + +export interface RunResult { + state: 'running' | 'done' | 'error' | 'skipped'; + plan: RunPlan; + evaluation?: Evaluation; + error?: string; + ms?: number; + /** The context was corrected and the script run again (julc#244 workarounds). */ + fixed?: boolean; + /** What the script was applied to: V3 the context; V1/V2 (datum,) redeemer, context. */ + args?: DataInput[]; +} + +export type VmRequest = Omit & { args: DataInput[] }; + +export function prepareRuns(page: PageTx): Promise { + return loadReplayInput(page).then(buildRuns); +} + +/** The VM arguments for a plan, given its ScriptContext (CBOR); null for a V1/V2 spend without a known datum. */ +export function argsFor(plan: RunPlan, contextCbor: string): DataInput[] | null { + const cbor = (value: string): DataInput => ({ format: 'cbor', value }); + if (plan.language === 'V3') return [cbor(contextCbor)]; + if (plan.purpose === 'spend') return plan.datum ? [cbor(plan.datum), cbor(plan.redeemer!), cbor(contextCbor)] : null; + return [cbor(plan.redeemer!), cbor(contextCbor)]; +} + +/** The request to apply a plan's script to `args` with the VM (evaluate or debug). */ +export function vmRequest(plan: RunPlan, args: DataInput[]): VmRequest { + const req = plan.request!; + return { script: req.script, args, target: req.target, costModel: req.costModel, maxCpu: req.maxCpu, maxMem: req.maxMem }; +} + +const usesUplc = (c: JulcClient) => { + if (!c.uplc) throw new Error('This julc engine can’t evaluate transactions (VM-only build).'); + return c.uplc; +}; + +/** + * Run every plan, reporting each result through `update` as it comes. Resolves with an error message when the + * engine itself can't run (unsupported browser, failed start), otherwise null. + */ +export async function runAll(plans: RunPlan[], update: (key: string, result: RunResult) => void): Promise { + const results: Record = {}; + const set = (key: string, r: RunResult) => { + results[key] = r; + update(key, r); + }; + for (const plan of plans) set(plan.key, plan.request ? { state: 'running', plan } : { state: 'skipped', plan, error: plan.skip }); + + for (const plan of plans) { + if (!plan.request) continue; + const started = performance.now(); + try { + const evaluation = await julc.run((c) => usesUplc(c).evaluateTransaction(plan.request!)); + const ms = Math.round(performance.now() - started); + if (evaluation.ok === false && evaluation.error) set(plan.key, { state: 'error', plan, error: evaluation.error, ms }); + else { + const args = evaluation.scriptContextCbor ? (argsFor(plan, evaluation.scriptContextCbor) ?? undefined) : undefined; + set(plan.key, { state: 'done', plan, evaluation, ms, args }); + } + } catch (e) { + set(plan.key, { state: 'error', plan, error: (e as Error)?.message ?? String(e) }); + if (julc.status === 'unsupported' || julc.status === 'error') return julc.error ?? (e as Error)?.message ?? 'The julc engine failed.'; + } + } + await fixContexts(plans, results, set); + return null; +} + +/* + * julc#244 workarounds (see fix-context.ts): put values in ledger order, restore datums given by hash, fix V1 shapes + * and, when every run produced its context, give each context the complete redeemer map. Runs whose context changed + * are evaluated again with the VM. Redeemers are only merged when all scripts share one Plutus version (V2 or V3): + * purposes are encoded differently per version. + */ +async function fixContexts(plans: RunPlan[], results: Record, set: (key: string, r: RunResult) => void) { + const withContext = plans.filter((p) => p.request && results[p.key]?.evaluation?.scriptContextCbor); + const language = plans[0]?.language; + let entries: NonNullable>[] | null = null; + if (plans.length > 1 && withContext.length === plans.length && language !== 'V1' && plans.every((p) => p.language === language)) { + const ordered = [...plans].sort((a, b) => TAG_ORDER[a.purpose] - TAG_ORDER[b.purpose] || a.index - b.index); + const found = ordered.map((p) => ownRedeemer(results[p.key].evaluation!.scriptContextCbor!)); + if (found.every((e) => e)) entries = found as NonNullable<(typeof found)[number]>[]; + } + for (const plan of withContext) { + const first = results[plan.key]; + const fixed = fixContext(first.evaluation!.scriptContextCbor!, plan.language!, entries, plan.hashedDatums, plan.witnessDatums, plan.proposals); + if (!fixed) continue; + const args = argsFor(plan, fixed.cbor); + if (!args) continue; + const started = performance.now(); + try { + const evaluation = await julc.run((c) => c.vm.evaluate(vmRequest(plan, args))); + if (evaluation.ok === false && evaluation.error) continue; + const did = [ + fixed.sortedValues && 'put asset values in ledger order', + fixed.completedRedeemers && `added the other ${plans.length - 1} redeemer${plans.length > 2 ? 's' : ''}`, + fixed.restoredDatums && 'added the datums given by hash under their on-chain hashes', + fixed.v1Shape && 'gave V1 withdrawals and datums their list shape', + fixed.restoredProposals && 'put the proposal’s governance action' + ].filter((d): d is string => !!d); + const what = did.length > 1 ? `${did.slice(0, -1).join(', ')} and ${did.at(-1)}` : did[0]; + set(plan.key, { + ...first, + evaluation: { ...evaluation, scriptContextCbor: fixed.cbor, language: first.evaluation!.language }, + ms: Math.round(performance.now() - started), + fixed: true, + args, + plan: { + ...plan, + notes: [`The viewer ${what} in the context, which julc doesn’t do yet (julc#244).`, ...plan.notes.filter((n) => !(fixed.completedRedeemers && n.includes('julc#244')))] + } + }); + } catch { + /* keep the first result */ + } + } +} diff --git a/applications/viewer/src/lib/julc/replay/slots.ts b/applications/viewer/src/lib/julc/replay/slots.ts new file mode 100644 index 00000000..5f47c4ca --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/slots.ts @@ -0,0 +1,39 @@ +/* + * Slot → POSIX time (ms), which is how a ScriptContext expresses a transaction's validity range. + * + * Public networks started in the Byron era with 20 s slots, so their genesis start time can't be used directly: + * they use the time and slot where Shelley (1 s slots) began. Devnets (and other custom networks) have no Byron + * slots, so time = system start + slot × slot length, from Yaci Store's /genesis. + */ + +export interface SlotConfig { + /** POSIX ms of `zeroSlot`. */ + zeroTime: number; + zeroSlot: number; + /** Slot length in ms. */ + slotLength: number; +} + +const KNOWN: Record = { + 764824073: { zeroTime: 1596059091000, zeroSlot: 4492800, slotLength: 1000 }, // mainnet + 1: { zeroTime: 1655769600000, zeroSlot: 86400, slotLength: 1000 }, // preprod + 2: { zeroTime: 1666656000000, zeroSlot: 0, slotLength: 1000 } // preview +}; + +export interface Genesis { + network_magic?: number; + /** Seconds since the epoch. */ + system_start?: number; + /** Seconds (may be fractional on fast devnets). */ + slot_length?: number; +} + +export function slotConfigFor(magic: number | null | undefined, genesis: Genesis | null | undefined): SlotConfig | null { + if (magic != null && KNOWN[magic]) return KNOWN[magic]; + if (genesis?.system_start == null || !genesis.slot_length) return null; + return { zeroTime: Math.round(genesis.system_start * 1000), zeroSlot: 0, slotLength: Math.round(genesis.slot_length * 1000) }; +} + +export function slotToPosixMs(slot: number, config: SlotConfig): bigint { + return BigInt(config.zeroTime) + BigInt(slot - config.zeroSlot) * BigInt(config.slotLength); +} diff --git a/applications/viewer/src/lib/julc/replay/verdict.ts b/applications/viewer/src/lib/julc/replay/verdict.ts new file mode 100644 index 00000000..191a34dc --- /dev/null +++ b/applications/viewer/src/lib/julc/replay/verdict.ts @@ -0,0 +1,29 @@ +import type { RunResult } from './run'; + +/* How a re-run compares with the chain, shared by the Scripts tab, the monitor and the what-if lab. */ + +export const passed = (r?: RunResult | null) => !!r && r.state === 'done' && !!r.evaluation && (r.evaluation.accepted ?? r.evaluation.status === 'success'); + +/** CPU and memory match the declared ex-units to the unit. */ +export const exact = (r?: RunResult | null) => + passed(r) && r!.plan.declared != null && r!.evaluation!.cpu === r!.plan.declared.steps && r!.evaluation!.mem === r!.plan.declared.mem; + +/** + * Transaction builders often declare the evaluated budget plus a safety margin (e.g. +20%). When memory and CPU are + * both scaled by the same factor, the run is a reproduction too; returns the margin in percent. + */ +export function margin(r?: RunResult | null): number | null { + if (!passed(r) || !r!.plan.declared || exact(r) || !r!.evaluation!.mem || !r!.evaluation!.cpu) return null; + const m = Number(r!.plan.declared.mem) / Number(r!.evaluation!.mem); + const c = Number(r!.plan.declared.steps) / Number(r!.evaluation!.cpu); + return m > 1.001 && Math.abs(m - c) < 0.0005 ? Math.round((m - 1) * 1000) / 10 : null; +} + +export type Outcome = 'passed' | 'failed' | 'out of budget' | 'couldn’t run' | 'running'; + +export function outcome(r?: RunResult | null): Outcome { + if (!r || r.state === 'running') return 'running'; + if (r.state !== 'done') return 'couldn’t run'; + if (passed(r)) return 'passed'; + return r.evaluation?.status === 'budgetExhausted' ? 'out of budget' : 'failed'; +} diff --git a/applications/viewer/src/lib/julc/types.ts b/applications/viewer/src/lib/julc/types.ts new file mode 100644 index 00000000..5ce7a6c0 --- /dev/null +++ b/applications/viewer/src/lib/julc/types.ts @@ -0,0 +1,299 @@ +/* + * The parts of the julc WebAssembly API (julc-wasm.d.ts, API version 0) that the viewer uses. The engine itself is + * downloaded at build time (scripts/fetch-julc.mjs), so its own typings aren't available to the type checker. + * Long and BigInteger fields are bigint. + */ + +export type PlutusVersion = 'V1' | 'V2' | 'V3'; + +export interface ScriptInput { + script: string; + language?: PlutusVersion; + params?: DataInput[]; +} + +export type DataInput = { format: 'cbor' | 'json' | 'uplc' | 'auto'; value: string }; + +export interface ScriptInfo { + inputFormat: string | null; + wrapping: string | null; + programVersion: string | null; + language: string | null; + languageSource: string | null; + scriptHash: string | null; + flatBytes: number; + termCount: number; + builtins: string[] | null; + paramsApplied: number; + warnings: string[] | null; +} + +export interface DecodeResponse { + ok: boolean; + error: string | null; + info: ScriptInfo | null; + uplcText: string | null; +} + +/** A transaction described as JSON; julc builds the ScriptContext from it (MockTransaction in julc-models.d.ts). */ +export interface MockTxIn { + txId: string; + index: bigint; + address: { payment: string; stake?: string }; + value: MockValue; + datum: MockDatum; + referenceScript?: string | null; +} +export interface MockTxOut { + address: { payment: string; stake?: string }; + value: MockValue; + datum: MockDatum; + referenceScript?: string | null; +} +export interface MockValue { + lovelace: string; + assets: { policyId: string; tokenName: string; quantity: string }[]; +} +export type MockDatum = { kind: 'none' } | { kind: 'inline'; data: DataInput } | { kind: 'hash'; hash: string; data?: DataInput }; +export interface MockCertificate { + type: 'regStaking' | 'unregStaking' | 'delegStaking' | 'regDRep' | 'unregDRep' | 'updateDRep'; + credential: string; + deposit?: string | null; + poolId?: string | null; +} +export interface MockVote { + voterType: 'drep' | 'committee' | 'pool'; + voter: string; + actionTxId: string; + actionIndex: bigint; + vote: 'yes' | 'no' | 'abstain'; +} +export interface MockPurpose { + type: 'spend' | 'mint' | 'reward' | 'certify' | 'vote' | 'propose'; + index?: number; + target?: string; + voterType?: 'drep' | 'committee' | 'pool'; +} +export interface MockTransaction { + purpose: MockPurpose; + redeemer: DataInput; + inputs: MockTxIn[]; + referenceInputs: MockTxIn[]; + outputs: MockTxOut[]; + fee: string; + mint: { policyId: string; tokenName: string; quantity: string }[]; + certificates: MockCertificate[]; + withdrawals: { credential: string; amount: string }[]; + validRange: { from: bigint | null; fromInclusive: boolean; to: bigint | null; toInclusive: boolean }; + signatories: string[]; + datums: DataInput[]; + txId: string; + votes: MockVote[]; + proposals: MockProposal[]; +} +/** A governance proposal; julc builds its action roughly (governance/action.ts encodes the real one). */ +export interface MockProposal { + deposit: string; + /** "key:" or "script:". */ + returnCredential: string; + actionType: string; + /** The guardrail (policy) script hash. */ + guardrail: string | null; + withdrawals: { credential: string; amount: string }[] | null; +} + +export interface Target { + language: 'PlutusV1' | 'PlutusV2' | 'PlutusV3'; + protocol: number; +} + +export interface TransactionRequest { + script: ScriptInput; + transaction: MockTransaction; + target: Target; + costModel?: { target: Target; parameters: bigint[] } | null; + maxCpu?: bigint; + maxMem?: bigint; +} + +export interface Span { + startLine: number; + startColumn: number; + endLine: number; + endColumn: number; +} + +export interface Evaluation { + ok: boolean; + error: string | null; + status: 'success' | 'failure' | 'budgetExhausted' | string | null; + accepted: boolean; + message: string | null; + cpu: bigint; + mem: bigint; + traces: string[] | null; + failedSpan: Span | null; + result: string | null; + lastBuiltins: string[] | null; + scriptContext: string | null; + scriptContextCbor: string | null; + scriptHash: string | null; + language: string | null; + costModelId?: string; +} + +/** The whole run, known after the first session action. Steps are 0-based machine transitions. */ +export interface Timeline { + totalSteps: bigint; + traceSteps: bigint[] | null; + errorStep: bigint | null; + status: string | null; + truncated: boolean; + cpu: bigint; + mem: bigint; +} + +export interface Snapshot { + step: bigint; + /** 'compute' (about to evaluate a term) or 'return' (returning a value to a frame). */ + phase: string | null; + /** Position in the formatted UPLC (decode / prettyPrint text), 1-based lines and columns. */ + span: Span | null; + termKind: string | null; + value: string | null; + environment: { name: string | null; value: string | null }[] | null; + frames: { kind: string | null; detail: string | null; span: Span | null }[] | null; + stackDepth: number; + cpu: bigint; + mem: bigint; + cpuDelta: bigint; + memDelta: bigint; + traces: string[] | null; + finished: boolean; + status: string | null; + error: string | null; + stopReason: string | null; + /** Source-debug sessions: the Java line this step belongs to. */ + javaLocation?: { fileName: string | null; line: number; column: number; fragment: string | null } | null; + /** Source-debug sessions: whether Java locals can be read at this step. */ + localsAvailability?: string | null; +} + +export interface Breakpoints { + lines?: number[]; + builtins?: string[]; + onTrace?: boolean; + onError?: boolean; +} + +export interface DebugReply { + ok: boolean; + error: string | null; + timeline: Timeline | null; + snapshot: Snapshot | null; +} + +/** A debug session in the engine's worker. Going back replays from step 0, which is fast (milliseconds). */ +export interface DebugSession { + sessionId: string; + isOpen(): boolean; + step(): Promise; + goto(step: bigint): Promise; + continue(breakpoints?: Breakpoints): Promise; + over(breakpoints?: Breakpoints): Promise; + out(breakpoints?: Breakpoints): Promise; + snapshot(): Promise; + close(): Promise<{ closed: boolean }>; +} + +export type FeatureGroup = 'vm' | 'debug' | 'compiler' | 'uplc' | 'sourceDebug' | 'sourceDebugLocals'; + +export interface Features { + api: 0; + variant: 'full' | 'vm'; + groups: FeatureGroup[]; + defaultProtocol: number; +} + +export interface Manifest { + api: 0; + version: string; + launcher: string; + variant: 'full' | 'vm'; + groups?: FeatureGroup[]; + julcVersion: string; +} + +export interface JulcClient { + version(): string; + features(): Features; + dispose(): void; + vm: { + decode(request: { script: ScriptInput }): Promise; + hash(request: { script: ScriptInput }): Promise<{ scriptHash: string }>; + prettyPrint(request: { script: ScriptInput; width?: number }): Promise<{ uplcText: string }>; + /** Apply the script to raw arguments (V3: the context; V1/V2: datum, redeemer, context). */ + evaluate(request: Omit & { args: DataInput[] }): Promise; + /** Same request as evaluate; the script isn't run until the first action. */ + debug(request: Omit & { args: DataInput[] }): Promise; + }; + /** Low-level session protocol; the `timeline` action runs the whole script once and returns its timeline. */ + debug?: { + act(request: { sessionId: string; action: 'timeline'; step?: bigint }): Promise; + }; + /** Full engine only: compile julc Java (or other julc languages) in the browser. */ + compiler?: { + check(request: { source: string; language: string }): Promise; + compile(request: { source: string; language: string; blueprint?: boolean }): Promise; + }; + /** Full engine only. */ + uplc?: { + evaluateTransaction(request: TransactionRequest): Promise; + /** julc's sample transaction for a purpose (one key input and output). */ + defaultTransaction?(request: { purpose: MockPurpose['type'] }): Promise; + }; +} + +/** Errors thrown by the engine for bad requests and timeouts (a failing script is a result, not an error). */ +export interface JulcError extends Error { + code?: string; + status?: number; +} + +/** A compiler message, with its position in the source (1-based). */ +export interface CompilerDiagnostic { + level: string | null; + code: string | null; + message: string | null; + startLine: number | null; + startCol: number | null; + endLine: number | null; + endCol: number | null; + suggestion: string | null; +} +export interface Field { + name: string | null; + type: string | null; +} +/** What `compiler.check` learns from a validator's source: purpose, parameters, datum and redeemer shapes. */ +export interface CheckResponse { + valid: boolean; + contractName: string | null; + purpose: string | null; + params: Field[] | null; + datumName: string | null; + datumFields: Field[] | null; + redeemerVariants: { name: string | null; tag: number; fields: Field[] | null }[] | null; + redeemerFields: Field[] | null; + diagnostics: CompilerDiagnostic[] | null; +} +export interface CompileResponse { + uplcText: string | null; + blueprintJson: string | null; + /** CBOR of the script, before parameters are applied; null when compilation failed. */ + compiledCode: string | null; + scriptHash: string | null; + scriptSizeBytes: number; + params: Field[] | null; + diagnostics: CompilerDiagnostic[] | null; +} 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..475c6f4b --- /dev/null +++ b/applications/viewer/src/lib/live/chain.svelte.ts @@ -0,0 +1,367 @@ +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; +/** Fetch the blocks in a gap up to this many; a bigger gap (a sleeping laptop, a long-hidden tab) reloads the recent blocks. */ +const MAX_GAP = 16; + +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); + 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); + + 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; + private hiddenAt = 0; + + start() { + if (this.started || typeof window === 'undefined') return; + this.started = true; + this.status = 'connecting'; + setInterval(() => (this.tick = Date.now()), 1000); + setInterval(() => this.refreshNetwork(), 30000); + // Hidden tabs don't poll, and a sleeping computer drops the socket: on return, catch up with the tip at once. + document.addEventListener('visibilitychange', () => { + if (document.hidden) { + this.hiddenAt = Date.now(); + return; + } + if (this.status === 'polling') this.poll(); + else if (Date.now() - this.hiddenAt > 10000) this.catchUp(); + }); + this.refreshNetwork(); + this.seed().finally(() => this.connect()); + } + + private recent() { + return Promise.all([ + api('/blocks', { params: { page: 1, count: 16 } }), + tryApi('/txs', { params: { page: 1, count: 16 } }) + ]); + } + + /** 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 this.recent(); + 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; + } + } + + /** Replaces the recent blocks and txs, after the viewer missed too many blocks to fill in. */ + private async reseed() { + const [blocks, txs] = await this.recent(); + const fresh: LiveBlock[] = (blocks?.blocks ?? []).map((b: Any) => toBlock(b)); + if (!fresh.length) return; + const tip = this.tip; + this.blocks = []; + this.txs = []; + this.mergeBlocks(fresh); + this.mergeTxs((txs?.transaction_summaries ?? []).map((t: Any) => toTx(t))); + if (tip && this.tip && this.tip.epoch !== tip.epoch) this.refreshNetwork(); + } + + /** Brings the recent blocks up to the store's tip, whatever the live mode. */ + private async catchUp() { + try { + const latest = await api('/blocks/latest', { timeoutMs: 8000 }); + await this.addBlock(toBlock(latest), true); + 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': { + const init: LiveBlock[] = (d.blocks ?? []).map((b: Any) => toBlock(b, 0)); + const newest = init.reduce((m, b) => (!m || b.number > m.number ? b : m), null); + // The socket came back after a devnet reset: drop the old chain's blocks and txs. + if (newest && replacesChain(this.blocks, newest)) { + this.blocks = []; + this.txs = []; + } + this.mergeBlocks(init); + this.mergeTxs((d.recent_txs ?? []).map((t: Any) => toTx(t, 0))); + break; + } + case 'BLOCK_DATA': + this.addBlock(toBlock(d)).catch(() => {}); + break; + case 'RECENT_TXS_DATA': + this.mergeTxs((d.recent_txs ?? []).map((t: Any) => toTx(t))); + break; + } + } + + /** + * Adds a new block. If blocks were missed before it (the socket dropped, the tab was hidden), fills the gap first, + * or reloads the recent blocks when the gap is long, so averages never span a gap. + */ + private async addBlock(b: LiveBlock, withTxs = false) { + const tip = this.tip; + // A devnet reset, or a rollback past the tip: the blocks held are from another chain. + if (replacesChain(this.blocks, b)) return this.reseed(); + if (tip && b.number <= tip.number && this.blocks.some((x) => x.number === b.number)) return this.mergeBlocks([b]); + if (tip && b.number - tip.number > MAX_GAP) return this.reseed(); + const gap = tip ? b.number - tip.number - 1 : 0; + if (tip && gap > 0) { + const missed = await tryApi('/blocks', { params: { page: 1, count: b.number - tip.number } }); + this.mergeBlocks((missed?.blocks ?? []).filter((x: Any) => x.number > tip.number).map((x: Any) => toBlock(x))); + } + this.mergeBlocks([b]); + if (tip && b.epoch !== tip.epoch) this.refreshNetwork(); + if (withTxs && gap > 0) { + // Blocks were missed: their transactions too. The latest page covers them (up to MAX_TXS). + const txs = await tryApi('/txs', { params: { page: 1, count: MAX_TXS } }); + this.mergeTxs((txs?.transaction_summaries ?? []).map((t: Any) => toTx(t))); + } else if (withTxs && b.txCount > 0) { + const txs = await tryApi(`/blocks/${b.number}/txs`); + this.mergeTxs((txs ?? []).map((t) => toTx(t))); + } + } + + 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 = contiguous([...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) await this.catchUp(); + this.pollTimer = setTimeout(() => this.poll(), POLL_MS); + } +} + +/** + * Whether `b` belongs to a different chain than the blocks held (newest first): it is below the tip and either at a + * height we don't hold (a devnet reset starts again from block 0) or with another hash there (a rollback). + */ +export function replacesChain(blocks: LiveBlock[], b: LiveBlock): boolean { + const tip = blocks[0]; + if (!tip || b.number >= tip.number) return false; + const held = blocks.find((x) => x.number === b.number); + if (!held) return true; + return !!(held.hash && b.hash && held.hash !== b.hash); +} + +/** The newest run of consecutive blocks (newest first): a list with a gap in it would skew every average. */ +export function contiguous(blocks: LiveBlock[]): LiveBlock[] { + let n = 1; + while (n < blocks.length && blocks[n].number === blocks[n - 1].number - 1) n++; + return blocks.slice(0, n); +} + +export const chain = new Chain(); diff --git a/applications/viewer/src/lib/live/chain.test.ts b/applications/viewer/src/lib/live/chain.test.ts new file mode 100644 index 00000000..0ea4c69e --- /dev/null +++ b/applications/viewer/src/lib/live/chain.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { contiguous, replacesChain, type LiveBlock } from './chain.svelte'; + +const block = (number: number, time: number): LiveBlock => ({ + number, + slot: time, + epoch: 1, + time, + txCount: 0, + size: 0, + receivedAt: 0 +}); + +describe('contiguous', () => { + it('keeps the newest run of consecutive blocks', () => { + // The tip after a night asleep, then blocks from before it: the old ones would make the average block time hours long. + const list = [block(1000, 50000), block(10, 200), block(9, 180), block(8, 160)]; + expect(contiguous(list).map((b) => b.number)).toEqual([1000]); + }); + + it('keeps a list without gaps as is', () => { + const list = [block(12, 240), block(11, 220), block(10, 200)]; + expect(contiguous(list)).toEqual(list); + }); + + it('handles an empty list', () => { + expect(contiguous([])).toEqual([]); + }); +}); + +describe('replacesChain', () => { + const held = [12, 11, 10].map((n) => ({ ...block(n, n * 20), hash: `h${n}` })); + + it('spots a devnet reset: a block below the tip at a height not held', () => { + expect(replacesChain(held, { ...block(3, 60), hash: 'new3' })).toBe(true); + }); + + it('spots a rollback: another hash at a held height below the tip', () => { + expect(replacesChain(held, { ...block(11, 220), hash: 'other' })).toBe(true); + }); + + it('leaves the same block, the tip and new blocks alone', () => { + expect(replacesChain(held, { ...block(11, 220), hash: 'h11' })).toBe(false); + expect(replacesChain(held, { ...block(11, 220) })).toBe(false); + expect(replacesChain(held, { ...block(12, 240), hash: 'other' })).toBe(false); + expect(replacesChain(held, { ...block(13, 260), hash: 'h13' })).toBe(false); + expect(replacesChain([], block(1, 20))).toBe(false); + }); +}); diff --git a/applications/viewer/src/lib/names/NameTag.svelte b/applications/viewer/src/lib/names/NameTag.svelte new file mode 100644 index 00000000..60a4e661 --- /dev/null +++ b/applications/viewer/src/lib/names/NameTag.svelte @@ -0,0 +1,51 @@ + + +{#if tag} + {#if kind === 'action'} + {tag.text} + {:else} + {tag.text} + {/if} +{/if} diff --git a/applications/viewer/src/lib/names/keys.test.ts b/applications/viewer/src/lib/names/keys.test.ts new file mode 100644 index 00000000..ee5656d3 --- /dev/null +++ b/applications/viewer/src/lib/names/keys.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { bech32Encode } from '$lib/bech32'; +import { actionKey, drepKey, poolKey, scriptKeyOfAddress, tokenKey } from './keys'; + +const hash = '96ffa0b04d238780ca6b9d2343612820fe3178e79f392a711d705a85'; +const bytes = (hex: string) => new Uint8Array(hex.match(/../g)!.map((h) => parseInt(h, 16))); + +describe('name keys', () => { + it('finds the script hash of script addresses only', () => { + // Minswap's order contract address (script payment part, key stake part). + const minswap = 'addr1wxt2d5z2cxpaxj0jwlcqmyav08lpwrwwxacna2dyj0melqgugxgdz'; + expect(scriptKeyOfAddress(minswap)).toBe('96a6d04ac183d349f277f00d93ac79fe170dce37713ea9a493f79f81'); + expect(scriptKeyOfAddress(bech32Encode('addr', bytes('61' + hash)))).toBeNull(); // key enterprise address + expect(scriptKeyOfAddress(bech32Encode('stake', bytes('f1' + hash)))).toBe(hash); + expect(scriptKeyOfAddress(bech32Encode('stake', bytes('e1' + hash)))).toBeNull(); + expect(scriptKeyOfAddress('DdzFFzCqrht…')).toBeNull(); + }); + + it('reads DRep ids in any form as the credential hash', () => { + expect(drepKey('drep1y2t0lg9sf53c0qx2dwwjxsmp9qs0uvtcu70nj2n3r4c94pgvaw2z2')).toBe(hash); // CIP-129 + expect(drepKey(bech32Encode('drep', bytes(hash)))).toBe(hash); // CIP-105 + expect(drepKey(hash.toUpperCase())).toBe(hash); + expect(drepKey('drep_always_abstain')).toBeNull(); + }); + + it('reads pool ids as hex', () => { + const hex = '153806dbcd134ddee69a8c5204e38ac80448f62342f8c23cfe4b7edf'; + expect(poolKey('pool1z5uqdk7dzdxaae5633fqfcu2eqzy3a3rgtuvy087fdld7yws0xt')).toBe(hex); + expect(poolKey(hex)).toBe(hex); + expect(poolKey('pool1nope')).toBeNull(); + }); + + it('reads governance action ids as tx#index', () => { + const tx = '14d6a48df8b9099a3e727e1bf6a9ac6c911341a9601e91c7d3ac799bfd89b01b'; + expect(actionKey(tx, 0)).toBe(`${tx}#0`); + expect(actionKey(`${tx}_3`)).toBe(`${tx}#3`); + expect(actionKey('gov_action1znt2fr0chyye50nj0cdld2dvdjg3xsdfvq0fr37n43uehlvfkqdsqjnuq3n')).toBe(`${tx}#0`); + expect(actionKey('nope')).toBeNull(); + }); + + it('accepts token units only', () => { + expect(tokenKey('29D222CE763455E3D7A09A665CE554F00AC89D2E99A1A83D267170C64D494E')).toBe('29d222ce763455e3d7a09a665ce554f00ac89d2e99a1a83d267170c64d494e'); + expect(tokenKey('lovelace')).toBeNull(); + }); +}); diff --git a/applications/viewer/src/lib/names/keys.ts b/applications/viewer/src/lib/names/keys.ts new file mode 100644 index 00000000..ccd71c89 --- /dev/null +++ b/applications/viewer/src/lib/names/keys.ts @@ -0,0 +1,68 @@ +import { bech32Decode } from '$lib/bech32'; + +/* + * The keys names are stored under, from whatever form an id comes in: + * - scripts: the 28-byte script hash (hex), also from a script address or a script stake address. + * - dreps: the 28-byte credential hash (hex), from a CIP-129 or CIP-105 id or the hash itself. + * - pools: the 28-byte pool hash (hex), from pool1… or hex. + * - actions: "#", from the pair or a CIP-129 gov_action1… id. + * - tokens: the unit (policy id + asset name, hex). + */ + +const HASH = /^[0-9a-f]{56}$/; +const toHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); + +/** The script hash in an address's payment part, or of a script stake address; null for keys and Byron. */ +export function scriptKeyOfAddress(address: string | null | undefined): string | null { + if (!address) return null; + const d = bech32Decode(address); + if (!d || d.bytes.length < 29) return null; + const type = d.bytes[0] >> 4; + if (d.hrp === 'addr' || d.hrp === 'addr_test') return [1, 3, 5, 7].includes(type) ? toHex(d.bytes.subarray(1, 29)) : null; + if (d.hrp === 'stake' || d.hrp === 'stake_test') return type === 0xf ? toHex(d.bytes.subarray(1, 29)) : null; + return null; +} + +export function scriptKey(hash: string | null | undefined): string | null { + const h = (hash ?? '').toLowerCase(); + return HASH.test(h) ? h : null; +} + +export function drepKey(id: string | null | undefined): string | null { + const s = (id ?? '').trim(); + if (HASH.test(s.toLowerCase())) return s.toLowerCase(); + const d = bech32Decode(s); + if (!d) return null; + if ((d.hrp === 'drep' || d.hrp === 'drep_script') && d.bytes.length === 28) return toHex(d.bytes); + if (d.hrp === 'drep' && d.bytes.length === 29) return toHex(d.bytes.subarray(1)); + return null; +} + +export function poolKey(id: string | null | undefined): string | null { + const s = (id ?? '').trim(); + if (HASH.test(s.toLowerCase())) return s.toLowerCase(); + const d = bech32Decode(s); + return d?.hrp === 'pool' && d.bytes.length === 28 ? toHex(d.bytes) : null; +} + +export function actionKey(txHashOrId: string | null | undefined, index?: number | string | null): string | null { + const s = (txHashOrId ?? '').trim().toLowerCase(); + if (/^[0-9a-f]{64}$/.test(s) && index != null && /^\d+$/.test(String(index))) return `${s}#${Number(index)}`; + const m = s.match(/^([0-9a-f]{64})[#_](\d+)$/); + if (m) return `${m[1]}#${Number(m[2])}`; + const d = bech32Decode(s); + if (d?.hrp === 'gov_action' && d.bytes.length >= 33) { + let i = 0; + for (const b of d.bytes.subarray(32)) i = i * 256 + b; + return `${toHex(d.bytes.subarray(0, 32))}#${i}`; + } + return null; +} + +export function tokenKey(unit: string | null | undefined): string | null { + const u = (unit ?? '').toLowerCase(); + return /^[0-9a-f]{56}([0-9a-f]{2}){0,32}$/.test(u) ? u : null; +} + +/** The key function for each kind. */ +export const KEY = { scripts: scriptKey, dreps: drepKey, pools: poolKey, actions: (id: string) => actionKey(id), tokens: tokenKey }; diff --git a/applications/viewer/src/lib/names/names.svelte.ts b/applications/viewer/src/lib/names/names.svelte.ts new file mode 100644 index 00000000..1e0f9040 --- /dev/null +++ b/applications/viewer/src/lib/names/names.svelte.ts @@ -0,0 +1,81 @@ +import { browser } from '$app/environment'; +import { SvelteMap } from 'svelte/reactivity'; +import { KEY, scriptKeyOfAddress } from './keys'; +import type { NameKind, NamesResult, PoolName, ScriptName, TokenName } from './types'; + +/* + * Names in the browser. Components ask for the name of an id while they render; the ids asked for within a few + * milliseconds go to /names in one request, and the answers fill reactive maps, so the names appear when they + * arrive. An id without a name is asked again after a few minutes (the server may still be loading). When the + * server answers that names are off (not Mainnet), nothing more is asked for in this session. + */ + +type Value = { scripts: ScriptName; dreps: string; pools: PoolName; actions: string; tokens: TokenName }; + +const KINDS: NameKind[] = ['scripts', 'dreps', 'pools', 'actions', 'tokens']; +const RETRY_MS = 5 * 60_000; +const BATCH = 300; + +const known = { + scripts: new SvelteMap(), + dreps: new SvelteMap(), + pools: new SvelteMap(), + actions: new SvelteMap(), + tokens: new SvelteMap() +}; +const missedAt = new Map(); +const pending = new Set(); +const queue = new Map>(KINDS.map((k) => [k, new Set()])); +let timer: ReturnType | null = null; +let off = false; + +function want(kind: NameKind, key: string) { + if (!browser || off) return; + const id = `${kind}:${key}`; + if (known[kind].has(key) || pending.has(id) || Date.now() - (missedAt.get(id) ?? -Infinity) < RETRY_MS) return; + pending.add(id); + queue.get(kind)!.add(key); + timer ??= setTimeout(flush, 30); +} + +async function flush() { + timer = null; + const asked = new Map(KINDS.map((k) => [k, [...queue.get(k)!].slice(0, BATCH)])); + for (const [k, keys] of asked) for (const key of keys) queue.get(k)!.delete(key); + if (KINDS.some((k) => queue.get(k)!.size)) timer = setTimeout(flush, 30); + let res: (NamesResult & { enabled?: boolean | null }) | null = null; + try { + const r = await fetch('/names', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(Object.fromEntries(asked)) }); + if (r.ok) res = await r.json(); + } catch { + /* the viewer's server is unreachable: the ids are asked again later */ + } + if (res?.enabled === false) off = true; + const now = Date.now(); + for (const [kind, keys] of asked) { + for (const key of keys) { + const id = `${kind}:${key}`; + pending.delete(id); + const v = res?.[kind]?.[key]; + if (v) (known[kind] as SvelteMap).set(key, v); + else missedAt.set(id, now); + } + } +} + +function lookup(kind: K, id: string | null | undefined): Value[K] | null { + const key = id ? (KEY[kind] as (s: string) => string | null)(id) : null; + if (!key) return null; + const v = known[kind].get(key) as Value[K] | undefined; + if (v === undefined) want(kind, key); + return v ?? null; +} + +/** The dApp label for a script hash, or for the script behind an address or a stake address. */ +export const scriptName = (hashOrAddress: string | null | undefined): ScriptName | null => + lookup('scripts', /^(addr|stake)/.test(hashOrAddress ?? '') ? scriptKeyOfAddress(hashOrAddress) : hashOrAddress); +export const drepName = (id: string | null | undefined) => lookup('dreps', id); +export const poolName = (id: string | null | undefined) => lookup('pools', id); +/** A governance action's title: by "#", "_" or gov_action1…. */ +export const actionTitle = (id: string | null | undefined) => lookup('actions', id); +export const tokenName = (unit: string | null | undefined) => lookup('tokens', unit); diff --git a/applications/viewer/src/lib/names/types.ts b/applications/viewer/src/lib/names/types.ts new file mode 100644 index 00000000..c4923ed9 --- /dev/null +++ b/applications/viewer/src/lib/names/types.ts @@ -0,0 +1,47 @@ +/* + * Names the viewer shows next to ids on Mainnet. Script labels come from curated registries; DRep names, pool + * tickers and governance action titles are what their owners put in their own metadata (checked against the hash on + * chain, but not unique and not verified); token tickers come from the Cardano Foundation's token registry. + */ + +export interface ScriptName { + /** e.g. "Minswap Order Contract v2" */ + label: string; + project: string; + category?: string; + url?: string; + source: 'strica' | 'crfa'; +} + +export interface PoolName { + ticker?: string; + name?: string; +} + +export interface TokenName { + ticker?: string; + name?: string; + decimals?: number; +} + +/** What the browser asks for: lists of keys per kind (see keys.ts for their form). */ +export interface NamesQuery { + scripts?: string[]; + dreps?: string[]; + pools?: string[]; + actions?: string[]; + tokens?: string[]; +} + +/** The answer: only the keys that have a name. */ +export interface NamesResult { + /** false off Mainnet (or with NAMES=off): the browser stops asking. null while the server is still finding out. */ + enabled?: boolean | null; + scripts: Record; + dreps: Record; + pools: Record; + actions: Record; + tokens: Record; +} + +export type NameKind = keyof NamesQuery; 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/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/server/analytics-sections.ts b/applications/viewer/src/lib/server/analytics-sections.ts new file mode 100644 index 00000000..3d0bd513 --- /dev/null +++ b/applications/viewer/src/lib/server/analytics-sections.ts @@ -0,0 +1,42 @@ +import { stakeAccountSql, stakeRewardsSql } from '$lib/analytics/queries'; +import type { AnalyticsSource } from '$lib/analytics/types'; +import { analyticsInfo, analyticsSql } from './analytics'; + +/* + * Page sections answered from Yaci Store analytics when the regular endpoint is too slow for a busy stake account. + * Each returns null when the store has no analytics, so the caller keeps its own answer (a notice). + */ + +type Fetch = typeof globalThis.fetch; + +/** Epochs per page of rewards. */ +export const REWARD_EPOCHS = 20; + +/** What a stake account controls and the pool it last delegated to, as of the last export. */ +export async function analyticsStakeAccount(stake: string, fetch: Fetch): Promise<({ controlled_amount?: number; pool_id?: string | null } & AnalyticsSource) | null> { + const info = await analyticsInfo(fetch); + if (!info) return null; + const [row] = await analyticsSql<{ controlled_amount: number | string | null; pool_id: string | null }>(stakeAccountSql(stake, info.tables), { fetch, maxRows: 1 }); + if (!row || (row.controlled_amount == null && row.pool_id == null)) return null; + return { + controlled_amount: row.controlled_amount == null ? undefined : Number(row.controlled_amount), + pool_id: row.pool_id, + exportedTo: info.exportedTo, + live: false + }; +} + +/** A page of a stake account's rewards (or other rewards), `REWARD_EPOCHS` epochs at a time, newest first. */ +export async function analyticsRewards(stake: string, opts: { fetch: Fetch; page: number; currentEpoch: number; rest?: boolean }): Promise<{ rows: Record[]; fromEpoch: number; toEpoch: number; source: AnalyticsSource } | null> { + const info = await analyticsInfo(opts.fetch); + if (!info) return null; + const toEpoch = opts.currentEpoch - REWARD_EPOCHS * (opts.page - 1); + const fromEpoch = Math.max(0, toEpoch - REWARD_EPOCHS + 1); + const rows = await analyticsSql>(stakeRewardsSql({ stake, fromEpoch, toEpoch, rest: opts.rest, tables: info.tables }), { fetch: opts.fetch, maxRows: 1000 }); + return { + rows: rows.map((r) => ({ ...r, amount: r.amount == null ? r.amount : Number(r.amount) })), + fromEpoch, + toEpoch, + source: { exportedTo: info.exportedTo, live: info.live && !opts.rest } + }; +} diff --git a/applications/viewer/src/lib/server/analytics.ts b/applications/viewer/src/lib/server/analytics.ts new file mode 100644 index 00000000..6bbbf389 --- /dev/null +++ b/applications/viewer/src/lib/server/analytics.ts @@ -0,0 +1,116 @@ +import { env } from '$env/dynamic/private'; +import { api, ApiError } from '$lib/api/client'; +import type { Tables } from '$lib/analytics/queries'; +import { IndexerGuard } from './indexer-guard'; + +/* + * Yaci Store's analytics query API (DuckDB over the store's daily Parquet exports, optionally joined with live + * PostgreSQL), where the store has it enabled. The devkit's own store doesn't, and answers 404, so everything that + * uses it is a fallback: the viewer asks the regular endpoints first and comes here when one is too slow for a busy + * stake account (bloxbean/yaci-store#1269). Every query is bounded by the store's own timeout, unlike those endpoints. + * + * YACI_ANALYTICS=off turns it off even when the store has it. + */ +export const ANALYTICS = { + /** How long a probe result is kept, and how soon to try again after a probe failed. */ + probeMs: 10 * 60_000, + retryProbeMs: 60_000, + /** The store cancels a query after 30 s; a little longer here, so its answer (408) arrives first. */ + timeoutMs: 35_000, + /** How long answers are kept. History only changes with the next daily export. */ + cacheMs: 10 * 60_000, + /** Analytics queries running at once: the store has one reader per CPU core, shared with everyone. */ + maxConcurrent: 2, + waitMs: 10_000, + heavyMs: 15 * 60_000 +} as const; + +/** + * Tables read through the live views (exports joined with PostgreSQL for the newest data). Others are read from the + * exports only: on yaci-store 3.0.0-beta5 some live queries outlive the store's timeout (bloxbean/yaci-store#1270). + */ +const LIVE_OK = new Set(['reward']); + +export interface AnalyticsInfo { + /** The store joins its exports with live PostgreSQL (its exported tables are then also `parquet_`). */ + live: boolean; + /** The last exported day (ISO date), when known. */ + exportedTo: string | null; + tables: Tables; +} + +const guard = new IndexerGuard({ heavyMs: ANALYTICS.heavyMs, maxEntries: 500, maxConcurrent: ANALYTICS.maxConcurrent }); + +let probed: { info: AnalyticsInfo | null; until: number } | null = null; +let probing: Promise | null = null; + +/** Whether the store has the analytics API, and how to query it; null when it doesn't. Probed every 10 minutes. */ +export function analyticsInfo(fetch: typeof globalThis.fetch): Promise { + if ((env.YACI_ANALYTICS ?? '').trim().toLowerCase() === 'off') return Promise.resolve(null); + if (probed && probed.until > Date.now()) return Promise.resolve(probed.info); + probing ??= probe(fetch) + .then( + (info) => ((probed = { info, until: Date.now() + ANALYTICS.probeMs }), info), + () => ((probed = { info: null, until: Date.now() + ANALYTICS.retryProbeMs }), null) + ) + .finally(() => (probing = null)); + return probing; +} + +async function probe(fetch: typeof globalThis.fetch): Promise { + try { + await query('SELECT 1 AS ok', { fetch, timeoutMs: 8_000 }); + } catch (e) { + // 404: the store doesn't have the API. 401/403: it does, but not for this caller (for example a reverse proxy + // that keeps it internal). Either way, there is nothing to use until the next probe. + if (e instanceof ApiError && [401, 403, 404].includes(e.status)) return null; + throw e; + } + // With live data on, the exports are also available on their own as parquet_
. + const live = await query<{ d: string | null }>('SELECT max(date) AS d FROM parquet_transaction', { fetch, timeoutMs: 20_000 }).then( + (rows) => ({ live: true, exportedTo: rows[0]?.d ?? null }), + () => null + ); + const exportedTo = live?.exportedTo ?? (await query<{ d: string | null }>('SELECT max(date) AS d FROM transaction', { fetch, timeoutMs: 20_000 }).then((r) => r[0]?.d ?? null, () => null)); + return { live: !!live, exportedTo, tables: tablesFor(!!live) }; +} + +export function tablesFor(live: boolean): Tables { + const read = (t: string) => (live && !LIVE_OK.has(t) ? `parquet_${t}` : t); + return { + transaction: read('transaction'), + transactionScripts: read('transaction_scripts'), + block: read('block'), + adapot: read('adapot'), + epochStake: read('epoch_stake'), + drepDist: read('drep_dist'), + reward: read('reward'), + rewardRest: read('reward_rest'), + stakeBalance: read('stake_address_balance'), + delegation: read('delegation') + }; +} + +function query(sql: string, opts: { fetch: typeof globalThis.fetch; maxRows?: number; timeoutMs?: number }): Promise { + return api('/analytics/query/sql', { fetch: opts.fetch, body: { sql, maxRows: opts.maxRows ?? 100 }, timeoutMs: opts.timeoutMs ?? ANALYTICS.timeoutMs }).then( + (rows) => rows ?? [], + (e) => { + // The store's own timeout: treat it like ours, so the guard skips the query for a while. + if (e instanceof ApiError && e.status === 408) throw new ApiError(e.message, 408, e.path, true); + throw e; + } + ); +} + +/** + * Runs a query through the guard: identical queries share one call and answers are kept for `cacheMs`; a query that + * timed out is skipped for 15 minutes; at most two run at once. + */ +export function analyticsSql(sql: string, opts: { fetch: typeof globalThis.fetch; maxRows?: number; cacheMs?: number }): Promise { + return guard.call(sql, sql, () => query(sql, opts), { cacheMs: opts.cacheMs ?? ANALYTICS.cacheMs, waitMs: ANALYTICS.waitMs }); +} + +/** The current UTC day, as an ISO date. */ +export function today(): string { + return new Date().toISOString().slice(0, 10); +} diff --git a/applications/viewer/src/lib/server/blockfrost.test.ts b/applications/viewer/src/lib/server/blockfrost.test.ts new file mode 100644 index 00000000..ee95a5e8 --- /dev/null +++ b/applications/viewer/src/lib/server/blockfrost.test.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BlockfrostAbsent, bfApi, blockfrostPresent, resetBlockfrost } from './blockfrost'; +import { indexerGuard } from './heavy-guard'; +import { HeavyError } from './indexer-guard'; + +const reply = (status: number, body: unknown) => + vi.fn(async (_url: string | URL | Request, _init?: RequestInit) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })); + +describe('bfApi', () => { + beforeEach(() => resetBlockfrost()); + + it('answers from the Blockfrost-compatible endpoint', async () => { + const fetch = reply(200, { amount: [{ unit: 'lovelace', quantity: '5' }] }); + expect(await bfApi('k-ok', '/addresses/a1', { fetch })).toEqual({ amount: [{ unit: 'lovelace', quantity: '5' }] }); + expect(String(fetch.mock.calls[0][0])).toContain('/blockfrost/addresses/a1'); + }); + + it('treats "No endpoint" as a store without the API, and stops asking for a while', async () => { + const fetch = reply(404, { status: 404, detail: 'No endpoint GET /api/v1/blockfrost/addresses/a2.' }); + await expect(bfApi('k-absent', '/addresses/a2', { fetch })).rejects.toBeInstanceOf(BlockfrostAbsent); + await expect(bfApi('k-absent-2', '/addresses/a3', { fetch })).rejects.toBeInstanceOf(BlockfrostAbsent); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("marks the key heavy on a 500 (the store's own timeout)", async () => { + const fetch = reply(500, { status: 500, error: 'Internal Server Error' }); + await expect(bfApi('k-slow', '/addresses/a4/transactions', { fetch })).rejects.toBeInstanceOf(HeavyError); + expect(indexerGuard.isHeavy('k-slow')).toBe(true); + await expect(bfApi('k-slow', '/addresses/a4/transactions', { fetch })).rejects.toBeInstanceOf(HeavyError); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('counts as present once the store answers, and not after "No endpoint"', async () => { + expect(blockfrostPresent()).toBe(false); + await bfApi('k-p1', '/addresses/p1', { fetch: reply(200, {}) }); + expect(blockfrostPresent()).toBe(true); + await expect(bfApi('k-p2', '/addresses/p2', { fetch: reply(404, { detail: 'No endpoint GET /x.' }) })).rejects.toBeInstanceOf(BlockfrostAbsent); + expect(blockfrostPresent()).toBe(false); + }); + + it('passes other errors on', async () => { + const fetch = reply(400, { status: 400, detail: 'Invalid address' }); + await expect(bfApi('k-bad', '/addresses/nope', { fetch })).rejects.toMatchObject({ status: 400 }); + }); +}); diff --git a/applications/viewer/src/lib/server/blockfrost.ts b/applications/viewer/src/lib/server/blockfrost.ts new file mode 100644 index 00000000..ba789b6e --- /dev/null +++ b/applications/viewer/src/lib/server/blockfrost.ts @@ -0,0 +1,59 @@ +import { ApiError, type ApiOptions } from '$lib/api/client'; +import { GUARD, guardEnabled, guardedApi, indexerGuard } from './heavy-guard'; +import { HeavyError } from './indexer-guard'; + +/* + * Yaci Store's Blockfrost-compatible address endpoints, where the store has them (not the devkit's own store, which + * answers 404 "No endpoint"). They are preferred for addresses because the store runs their queries under a 15 s + * statement timeout: on a busy address they fail fast with a 500 instead of leaving a query running after the viewer + * gives up, as native /addresses/{a}/transactions does (bloxbean/yaci-store#1269). And /blockfrost/addresses/{a} + * gives an exact balance where summing UTxO pages can only give a lower bound. + * + * The store is checked again 10 minutes after it answered "No endpoint". A 500 (the store's timeout) skips the key for a + * minute at most: the query has already stopped, so trying again is cheap, and often fast once the data is cached. + */ + +const ABSENT_MS = 10 * 60_000; +let absentUntil = 0; +let seen = false; + +/** The store answered a Blockfrost-compatible call (any answer but "No endpoint") and hasn't said otherwise since. */ +export const blockfrostPresent = () => seen && Date.now() >= absentUntil; + +/** The store has no Blockfrost-compatible API: use the native endpoint. */ +export class BlockfrostAbsent extends Error { + constructor() { + super('Yaci Store has no Blockfrost-compatible API'); + } +} + +/** + * GET /blockfrost{path} through the guard under `key`. Throws BlockfrostAbsent when the store doesn't have it; a 500 + * (the store's own timeout) marks the key heavy, like a timeout, and is thrown as HeavyError. + */ +export async function bfApi(key: string, path: string, opts: ApiOptions & { cacheMs?: number; heavyMs?: number } = {}): Promise { + if (Date.now() < absentUntil) throw new BlockfrostAbsent(); + try { + const value = await guardedApi(key, `/blockfrost${path}`, opts); + seen = true; + return value; + } catch (e) { + if (e instanceof ApiError && e.status === 404 && /no endpoint/i.test(e.message)) { + absentUntil = Date.now() + ABSENT_MS; + seen = false; + throw new BlockfrostAbsent(); + } + if (e instanceof ApiError) seen = true; + if (guardEnabled() && e instanceof ApiError && e.status >= 500) { + indexerGuard.markHeavy(key, Math.min(opts.heavyMs ?? GUARD.heavyMs, GUARD.blockfrostHeavyMs)); + throw new HeavyError(key); + } + throw e; + } +} + +/** For tests. */ +export function resetBlockfrost() { + absentUntil = 0; + seen = false; +} diff --git a/applications/viewer/src/lib/server/heavy-guard.ts b/applications/viewer/src/lib/server/heavy-guard.ts new file mode 100644 index 00000000..5c59bd87 --- /dev/null +++ b/applications/viewer/src/lib/server/heavy-guard.ts @@ -0,0 +1,71 @@ +import { env } from '$env/dynamic/private'; +import { api, ApiError, type ApiOptions } from '$lib/api/client'; +import type { Slow } from '$lib/api/slow'; +import { BusyError, HeavyError, IndexerGuard } from './indexer-guard'; + +/* + * Settings for busy addresses on a large Yaci Store, in one place (bloxbean/yaci-store#1269). Once Yaci Store cancels + * queries the viewer abandons, set HEAVY_ADDRESS_GUARD=false to go back to the plain behaviour: balance from + * /amounts when /balance isn't there, no skipped requests, the default timeout. + */ +export const GUARD = { + /** UTxO pages summed for a balance when /balance isn't available, and their size. */ + balancePages: 5, + pageSize: 100, + /** How long an endpoint that timed out is skipped for that address. */ + heavyMs: 15 * 60_000, + /** The same for a page of UTxOs, which is usually quick: a timeout there more likely means a busy store. */ + utxoHeavyMs: 2 * 60_000, + /** + * The same after the store's own timeout on a Blockfrost-compatible call. Its query has already stopped, and a second + * try often finds the data cached, so it is skipped only briefly (and visitors can try again at once). + */ + blockfrostHeavyMs: 60_000, + /** How long a stake account's answer is kept (a busy one can take ~30 s). */ + accountCacheMs: 60_000, + /** How long the UTxO pages after the first, read for a balance, are kept. */ + balanceCacheMs: 60_000, + maxEntries: 1000, + /** Guarded calls running against the store at once, and how long a call waits for a free slot. */ + maxConcurrent: 4, + waitMs: 10_000, + timeoutMs: { + balance: 10_000, + utxos: 30_000, + transactions: 15_000, + account: 40_000, + accountLists: 15_000, + /** Blockfrost-compatible calls: the store gives up on them itself after 15 s. */ + blockfrost: 20_000 + } +} as const; + +export function guardEnabled(): boolean { + return (env.HEAVY_ADDRESS_GUARD ?? '').trim().toLowerCase() !== 'false'; +} + +export const indexerGuard = new IndexerGuard({ heavyMs: GUARD.heavyMs, maxEntries: GUARD.maxEntries, maxConcurrent: GUARD.maxConcurrent }); + +/** + * A Yaci Store call through the guard under `key` (the endpoint without paging): skipped while the key is heavy (for + * `heavyMs` after a timeout), shared with an identical call in flight, kept for `cacheMs`. With the guard off, a plain + * call. + */ +export function guardedApi(key: string, path: string, opts: ApiOptions & { cacheMs?: number; heavyMs?: number } = {}): Promise { + const { cacheMs, heavyMs, ...apiOpts } = opts; + if (!guardEnabled()) return api(path, apiOpts); + const query = new URLSearchParams(Object.entries(opts.params ?? {}).flatMap(([k, v]) => (v == null || v === '' ? [] : [[k, String(v)]]))).toString(); + return indexerGuard.call(key, query ? `${path}?${query}` : path, () => api(path, apiOpts), { cacheMs, heavyMs, waitMs: GUARD.waitMs }); +} + +/** Turns a call into a section result that never rejects, so a page can stream it. */ +export function settle(p: Promise): Promise> { + return p.then( + (value) => ({ ok: true as const, value }), + (e) => ({ + ok: false as const, + reason: e instanceof HeavyError || (e instanceof ApiError && e.timedOut) ? ('heavy' as const) : e instanceof BusyError ? ('busy' as const) : ('error' as const), + message: (e as Error)?.message ?? String(e) + }) + ); +} diff --git a/applications/viewer/src/lib/server/indexer-guard.test.ts b/applications/viewer/src/lib/server/indexer-guard.test.ts new file mode 100644 index 00000000..6dbcc3b7 --- /dev/null +++ b/applications/viewer/src/lib/server/indexer-guard.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; +import { ApiError } from '$lib/api/client'; +import { BusyError, HeavyError, IndexerGuard } from './indexer-guard'; + +const timeout = (path = '/x') => new ApiError('The request timed out', 0, path, true); + +function setup(opts: Partial<{ maxEntries: number; maxConcurrent: number }> = {}) { + let t = 0; + const guard = new IndexerGuard({ heavyMs: 15 * 60_000, maxEntries: opts.maxEntries ?? 1000, maxConcurrent: opts.maxConcurrent ?? 4, now: () => t }); + return { guard, advance: (ms: number) => (t += ms) }; +} + +const deferred = () => { + let resolve!: (v: T) => void; + const promise = new Promise((r) => (resolve = r)); + return { promise, resolve }; +}; + +describe('IndexerGuard', () => { + it('stops skipping a key when asked to', async () => { + const { guard } = setup(); + guard.markHeavy('/addresses/a/transactions'); + expect(guard.isHeavy('/addresses/a/transactions')).toBe(true); + guard.forget('/addresses/a/transactions'); + expect(guard.isHeavy('/addresses/a/transactions')).toBe(false); + expect(await guard.call('/addresses/a/transactions', '/addresses/a/transactions?page=1', async () => 'ok')).toBe('ok'); + }); + + it('skips a key for 15 minutes after a timeout, then tries again', async () => { + const { guard, advance } = setup(); + let calls = 0; + const run = async () => { + calls++; + throw timeout(); + }; + await expect(guard.call('/addresses/a/transactions', '/addresses/a/transactions?page=1', run)).rejects.toBeInstanceOf(HeavyError); + // Any page of the same endpoint is skipped without reaching the store. + await expect(guard.call('/addresses/a/transactions', '/addresses/a/transactions?page=2', run)).rejects.toBeInstanceOf(HeavyError); + expect(calls).toBe(1); + expect(guard.isHeavy('/addresses/a/transactions')).toBe(true); + expect(guard.isHeavy('/addresses/b/transactions')).toBe(false); + + advance(15 * 60_000 - 1); + expect(guard.isHeavy('/addresses/a/transactions')).toBe(true); + advance(1); + expect(guard.isHeavy('/addresses/a/transactions')).toBe(false); + await expect(guard.call('/addresses/a/transactions', '/addresses/a/transactions?page=1', async () => 'ok')).resolves.toBe('ok'); + }); + + it('can mark a key heavy for a shorter time', async () => { + const { guard, advance } = setup(); + await expect(guard.call('/u', '/u?page=1', async () => Promise.reject(timeout()), { heavyMs: 120_000 })).rejects.toBeInstanceOf(HeavyError); + advance(119_999); + expect(guard.isHeavy('/u')).toBe(true); + advance(1); + expect(guard.isHeavy('/u')).toBe(false); + }); + + it('does not mark a key heavy for other errors', async () => { + const { guard } = setup(); + await expect(guard.call('/k', '/k', async () => Promise.reject(new ApiError('Not found', 404, '/k')))).rejects.toBeInstanceOf(ApiError); + expect(guard.isHeavy('/k')).toBe(false); + }); + + it('shares one call between identical requests in flight', async () => { + const { guard } = setup(); + const d = deferred(); + let calls = 0; + const run = () => (calls++, d.promise); + const a = guard.call('/accounts/s', '/accounts/s', run); + const b = guard.call('/accounts/s', '/accounts/s', run); + d.resolve('account'); + expect(await Promise.all([a, b])).toEqual(['account', 'account']); + expect(calls).toBe(1); + // Once settled, a new request calls again (nothing cached without cacheMs). + await guard.call('/accounts/s', '/accounts/s', async () => (calls++, 'again')); + expect(calls).toBe(2); + }); + + it('keeps a successful answer for cacheMs', async () => { + const { guard, advance } = setup(); + let calls = 0; + const run = async () => ++calls; + expect(await guard.call('/accounts/s', '/accounts/s', run, { cacheMs: 60_000 })).toBe(1); + advance(59_999); + expect(await guard.call('/accounts/s', '/accounts/s', run, { cacheMs: 60_000 })).toBe(1); + advance(1); + expect(await guard.call('/accounts/s', '/accounts/s', run, { cacheMs: 60_000 })).toBe(2); + }); + + it('keeps at most maxEntries, dropping the least recently used', async () => { + const { guard } = setup({ maxEntries: 2 }); + guard.markHeavy('/a'); + guard.markHeavy('/b'); + expect(guard.isHeavy('/a')).toBe(true); // /a is now the most recently used + guard.markHeavy('/c'); + expect(guard.isHeavy('/b')).toBe(false); + expect(guard.isHeavy('/a')).toBe(true); + expect(guard.isHeavy('/c')).toBe(true); + }); + + it('runs at most maxConcurrent calls at once and gives up after waitMs', async () => { + const { guard } = setup({ maxConcurrent: 1 }); + const d = deferred(); + const first = guard.call('/a', '/a', () => d.promise); + let started = false; + const second = guard.call('/b', '/b', async () => ((started = true), 'b')); + await new Promise((r) => setTimeout(r, 5)); + expect(started).toBe(false); + d.resolve('a'); + expect(await first).toBe('a'); + expect(await second).toBe('b'); + + const hold = deferred(); + const busy = guard.call('/c', '/c', () => hold.promise); + await expect(guard.call('/d', '/d', async () => 'd', { waitMs: 10 })).rejects.toBeInstanceOf(BusyError); + hold.resolve('c'); + await busy; + await expect(guard.call('/e', '/e', async () => 'e', { waitMs: 10 })).resolves.toBe('e'); + }); +}); diff --git a/applications/viewer/src/lib/server/indexer-guard.ts b/applications/viewer/src/lib/server/indexer-guard.ts new file mode 100644 index 00000000..746c682c --- /dev/null +++ b/applications/viewer/src/lib/server/indexer-guard.ts @@ -0,0 +1,145 @@ +import { ApiError } from '$lib/api/client'; + +/* + * Protects a large Yaci Store from the viewer's slowest queries (bloxbean/yaci-store#1269). When the viewer gives up + * on a request, Yaci Store keeps running its database query, for minutes or hours, so asking again is the most + * harmful thing the viewer can do. Per endpoint key (the path without paging): + * - a timeout marks the key heavy for a while, and calls for it fail at once without reaching the store; + * - identical requests in flight share one call; + * - successful answers can be kept briefly; + * - only a few guarded calls run against the store at once, so many different heavy keys can't pile up. + * Server-side and in memory, for the single viewer process. + */ + +/** The store is known to be too slow for this key: the call wasn't made. */ +export class HeavyError extends Error { + constructor(public key: string) { + super('Yaci Store is too slow for this request right now'); + } +} + +/** All guarded slots stayed busy for the whole timeout: the call wasn't made. */ +export class BusyError extends Error { + constructor() { + super('Yaci Store is busy with other slow requests'); + } +} + +export interface GuardOptions { + /** How long a timed-out key stays heavy. */ + heavyMs: number; + /** Most entries kept (heavy keys and cached answers together); the least recently used go first. */ + maxEntries: number; + /** Most guarded calls running against the store at once. */ + maxConcurrent: number; + now?: () => number; +} + +export interface CallOptions { + /** Keep a successful answer this long. */ + cacheMs?: number; + /** The longest to wait for a free slot before giving up with BusyError. */ + waitMs?: number; + /** How long a timeout marks the key heavy, instead of the guard's default. */ + heavyMs?: number; +} + +type Entry = { kind: 'heavy'; until: number } | { kind: 'value'; until: number; value: unknown }; + +export class IndexerGuard { + private entries = new Map(); + private inFlight = new Map>(); + private running = 0; + private waiting: (() => void)[] = []; + private now: () => number; + + constructor(private opts: GuardOptions) { + this.now = opts.now ?? Date.now; + } + + /** Whether calls for this key are being skipped because one timed out recently. */ + isHeavy(key: string): boolean { + return this.get(`heavy ${key}`)?.kind === 'heavy'; + } + + markHeavy(key: string, heavyMs = this.opts.heavyMs) { + this.set(`heavy ${key}`, { kind: 'heavy', until: this.now() + heavyMs }); + } + + /** Stop skipping this key (a visitor asked to try again). */ + forget(key: string) { + this.entries.delete(`heavy ${key}`); + } + + /** + * Runs `run` for `url` (the full request, paging included) under `key` (the endpoint without paging), unless the + * key is heavy (HeavyError), an answer is cached, or the same url is already in flight. A timeout (ApiError with + * timedOut) marks the key heavy and is rethrown as HeavyError. + */ + call(key: string, url: string, run: () => Promise, opts: CallOptions = {}): Promise { + if (this.isHeavy(key)) return Promise.reject(new HeavyError(key)); + const cached = this.get(`value ${url}`); + if (cached?.kind === 'value') return Promise.resolve(cached.value as T); + const pending = this.inFlight.get(url); + if (pending) return pending as Promise; + const p = (async () => { + await this.acquire(opts.waitMs); + try { + const value = await run(); + if (opts.cacheMs) this.set(`value ${url}`, { kind: 'value', until: this.now() + opts.cacheMs, value }); + return value; + } catch (e) { + if (e instanceof ApiError && e.timedOut) { + this.markHeavy(key, opts.heavyMs); + throw new HeavyError(key); + } + throw e; + } finally { + this.release(); + } + })().finally(() => this.inFlight.delete(url)); + this.inFlight.set(url, p); + return p; + } + + private get(k: string): Entry | undefined { + const e = this.entries.get(k); + if (!e) return undefined; + this.entries.delete(k); + if (e.until <= this.now()) return undefined; + this.entries.set(k, e); // most recently used last + return e; + } + + private set(k: string, e: Entry) { + this.entries.delete(k); + this.entries.set(k, e); + while (this.entries.size > this.opts.maxEntries) this.entries.delete(this.entries.keys().next().value!); + } + + private acquire(waitMs?: number): Promise { + if (this.running < this.opts.maxConcurrent) { + this.running++; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const go = () => { + clearTimeout(timer); + this.running++; + resolve(); + }; + const timer = waitMs + ? setTimeout(() => { + this.waiting = this.waiting.filter((w) => w !== go); + reject(new BusyError()); + }, waitMs) + : undefined; + this.waiting.push(go); + }); + } + + private release() { + this.running--; + this.waiting.shift()?.(); + } +} diff --git a/applications/viewer/src/lib/server/insights.ts b/applications/viewer/src/lib/server/insights.ts new file mode 100644 index 00000000..a9630326 --- /dev/null +++ b/applications/viewer/src/lib/server/insights.ts @@ -0,0 +1,147 @@ +import { addDays } from '$lib/analytics/queries'; +import { + adaPotsSql, + dailyScriptsSql, + dailyTxsSql, + drepPowerSql, + epochBlocksSql, + poolBlocksSql, + poolStakeSql, + scriptDailySql, + stakeHistorySql, + stakeRewardsByEpochSql, + topScriptsSql +} from '$lib/analytics/insights'; +import type { AnalyticsSource, DayPoint, DRepPower, NetworkInsights, PoolInsights, ScriptInsights, StakeInsights } from '$lib/analytics/types'; +import { analyticsInfo, analyticsSql, today, type AnalyticsInfo } from './analytics'; +import { SwrCache } from './swr-cache'; + +/* + * Charts and numbers from Yaci Store analytics, for stores that have it; null otherwise (the devkit's store), so the + * pages leave them out. The exports are daily, so answers are kept and served while they refresh in the background: + * network-wide charts for an hour, one script's, pool's or account's for 30 minutes. Only the first request after a + * restart (or after a day) waits for the store. + */ + +type Fetch = typeof globalThis.fetch; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Row = Record; + +export const INSIGHTS = { + networkTtlMs: 60 * 60_000, + entityTtlMs: 30 * 60_000, + maxStaleMs: 24 * 60 * 60_000, + maxEntries: 2000, + /** Days in the daily charts, days the top scripts are counted over, and epochs in the per-epoch charts. */ + days: 90, + topScriptDays: 7, + epochs: 50 +} as const; + +const cache = new SwrCache({ maxEntries: INSIGHTS.maxEntries, maxStaleMs: INSIGHTS.maxStaleMs }); +const n = (v: unknown) => (v == null ? 0 : Number(v)); + +/** Rows of one query, through the cache (keyed by the SQL itself). */ +function rows(sql: string, ttlMs: number, fetch: Fetch, maxRows = 1000): Promise { + return cache.get(sql, ttlMs, () => analyticsSql(sql, { fetch, maxRows, cacheMs: 0 })); +} + +/** The same, null when that query fails: one missing chart shouldn't hide the others. */ +const maybe = (sql: string, ttlMs: number, fetch: Fetch, maxRows?: number) => rows(sql, ttlMs, fetch, maxRows).catch(() => null); + +const sourceOf = (info: AnalyticsInfo): AnalyticsSource => ({ exportedTo: info.exportedTo, live: false }); + +/** Network-wide: daily transactions and script use, the most-run scripts, the ada pots, blocks per epoch. */ +export async function networkInsights(fetch: Fetch, currentEpoch: number | null): Promise { + const info = await analyticsInfo(fetch); + if (!info) return null; + const t = info.tables; + const ttl = INSIGHTS.networkTtlMs; + const from = addDays(today(), -INSIGHTS.days); + const fromEpoch = Math.max(0, (currentEpoch ?? 0) - INSIGHTS.epochs); + const [txs, scripts, top, pots, epochs] = await Promise.all([ + maybe(dailyTxsSql(from, t), ttl, fetch), + maybe(dailyScriptsSql(from, t), ttl, fetch), + maybe(topScriptsSql(addDays(today(), -INSIGHTS.topScriptDays), 10, t), ttl, fetch), + maybe(adaPotsSql(t), ttl, fetch), + currentEpoch != null ? maybe(epochBlocksSql(fromEpoch, t), ttl, fetch) : null + ]); + const byDate = new Map(); + const day = (date: string) => byDate.get(date) ?? byDate.set(date, { date, txs: 0, fees: 0, runs: 0, scriptTxs: 0, steps: 0, mem: 0 }).get(date)!; + for (const r of txs ?? []) Object.assign(day(String(r.date)), { txs: n(r.txs), fees: n(r.fees) }); + for (const r of scripts ?? []) Object.assign(day(String(r.date)), { runs: n(r.runs), scriptTxs: n(r.txs), steps: n(r.steps), mem: n(r.mem) }); + return { + ...sourceOf(info), + from, + days: txs || scripts ? [...byDate.values()].sort((a, b) => a.date.localeCompare(b.date)) : null, + topScripts: top?.map((r) => ({ script_hash: String(r.script_hash), runs: n(r.runs), txs: n(r.txs), steps: n(r.steps), mem: n(r.mem) })) ?? null, + topScriptDays: INSIGHTS.topScriptDays, + pots: pots?.map((r) => ({ epoch: n(r.epoch), treasury: n(r.treasury), reserves: n(r.reserves), fees: n(r.fees), rewards: n(r.rewards) })) ?? null, + epochs: epochs?.map((r) => ({ epoch: n(r.epoch), blocks: n(r.blocks), pools: n(r.pools), txs: n(r.txs) })) ?? null + }; +} + +/** DReps (and the abstain / no-confidence options) with the most voting power. */ +export async function drepPower(fetch: Fetch, currentEpoch: number | null): Promise { + const info = await analyticsInfo(fetch); + if (!info) return null; + // Without the current epoch, look through every epoch for the latest (the table is small). + const r = await maybe(drepPowerSql(currentEpoch != null ? currentEpoch - 3 : 0, 10, info.tables), INSIGHTS.networkTtlMs, fetch, 10); + if (!r?.length) return null; + return { + ...sourceOf(info), + epoch: n(r[0].epoch), + total: n(r[0].total), + dreps: n(r[0].dreps), + top: r.map((x) => ({ drep_id: x.drep_id ?? null, drep_type: String(x.drep_type), amount: n(x.amount) })) + }; +} + +/** One script's runs and ex-units per day. */ +export async function scriptInsights(hash: string, fetch: Fetch): Promise { + const info = await analyticsInfo(fetch); + if (!info) return null; + const from = addDays(today(), -INSIGHTS.days); + const r = await maybe(scriptDailySql(hash, from, info.tables), INSIGHTS.entityTtlMs, fetch); + if (!r) return null; + return { + ...sourceOf(info), + from, + days: r.map((x) => ({ date: String(x.date), runs: n(x.runs), txs: n(x.txs), steps: n(x.steps), mem: n(x.mem), maxSteps: n(x.max_steps) })) + }; +} + +/** One pool's blocks, active stake and delegators per finished epoch. */ +export async function poolInsights(poolHash: string, fetch: Fetch, currentEpoch: number | null): Promise { + const info = await analyticsInfo(fetch); + if (!info || currentEpoch == null) return null; + const fromEpoch = Math.max(0, currentEpoch - INSIGHTS.epochs); + const [blocks, stake] = await Promise.all([ + maybe(poolBlocksSql(poolHash, fromEpoch, info.tables), INSIGHTS.entityTtlMs, fetch), + maybe(poolStakeSql(poolHash, fromEpoch, info.tables), INSIGHTS.entityTtlMs, fetch) + ]); + if (!blocks && !stake) return null; + const byEpoch = new Map(); + const at = (epoch: number) => byEpoch.get(epoch) ?? byEpoch.set(epoch, { epoch, blocks: 0, stake: 0, delegators: 0 }).get(epoch)!; + for (const r of stake ?? []) Object.assign(at(n(r.epoch)), { stake: n(r.stake), delegators: n(r.delegators) }); + for (const r of blocks ?? []) at(n(r.epoch)).blocks = n(r.blocks); + // Finished epochs only: the current one has some of its blocks and no stake snapshot yet. + return { ...sourceOf(info), epochs: [...byEpoch.values()].filter((x) => x.epoch < currentEpoch).sort((a, b) => a.epoch - b.epoch) }; +} + +/** A stake account's rewards and active stake per epoch. */ +export async function stakeInsights(stake: string, fetch: Fetch, currentEpoch: number | null): Promise { + const info = await analyticsInfo(fetch); + if (!info || currentEpoch == null) return null; + const fromEpoch = Math.max(0, currentEpoch - INSIGHTS.epochs); + const [rewards, history] = await Promise.all([ + maybe(stakeRewardsByEpochSql(stake, fromEpoch, info.tables), INSIGHTS.entityTtlMs, fetch), + maybe(stakeHistorySql(stake, fromEpoch, info.tables), INSIGHTS.entityTtlMs, fetch) + ]); + if (!rewards?.length && !history?.length) return null; + const byEpoch = new Map(); + const at = (epoch: number) => byEpoch.get(epoch) ?? byEpoch.set(epoch, { epoch, rewards: 0, stake: null, pool_id: null }).get(epoch)!; + for (const r of history ?? []) Object.assign(at(n(r.epoch)), { stake: n(r.amount), pool_id: r.pool_id ?? null }); + for (const r of rewards ?? []) at(n(r.epoch)).rewards = n(r.rewards); + return { ...sourceOf(info), epochs: [...byEpoch.values()].sort((a, b) => a.epoch - b.epoch) }; +} diff --git a/applications/viewer/src/lib/server/names/clean.ts b/applications/viewer/src/lib/server/names/clean.ts new file mode 100644 index 00000000..7288c00e --- /dev/null +++ b/applications/viewer/src/lib/server/names/clean.ts @@ -0,0 +1,16 @@ +/* + * Text from off-chain metadata is shown as a short label: control and bidirectional-override characters out (so a + * name can't reorder the text around it), whitespace collapsed, length capped. JSON-LD values can be plain strings + * or { "@value": "…" }. + */ + +// eslint-disable-next-line no-control-regex +const UNSAFE = /[\u0000-\u001f\u007f-\u009f​-‏‪-‮⁠-⁩]/g; + +export function cleanText(v: unknown, max: number): string | undefined { + if (v && typeof v === 'object' && '@value' in v) v = (v as { '@value': unknown })['@value']; + if (typeof v !== 'string') return undefined; + const s = v.replace(UNSAFE, ' ').replace(/\s+/g, ' ').trim(); + if (!s) return undefined; + return s.length > max ? `${s.slice(0, max - 1).trimEnd()}…` : s; +} diff --git a/applications/viewer/src/lib/server/names/docs.ts b/applications/viewer/src/lib/server/names/docs.ts new file mode 100644 index 00000000..e742ff70 --- /dev/null +++ b/applications/viewer/src/lib/server/names/docs.ts @@ -0,0 +1,91 @@ +import { blake2b256Hex } from '$lib/governance/blake2b'; +import { fetchAnchor } from '$lib/governance/anchor.server'; +import { safeGet } from '$lib/server/safe-fetch'; +import { cleanText } from './clean'; + +/* + * Names from documents their owners publish, referenced on chain by URL and hash: a DRep's CIP-119 profile + * (givenName), a governance action's CIP-108 document (title), a pool's CIP-6 metadata (ticker, name). A name is + * kept only when the document's BLAKE2b-256 matches the hash on chain, so a changed or swapped file shows nothing. + * All fetches go through safeGet (public addresses only, checked after DNS and on redirects, size and time capped). + */ + +/** One document to read: where it is, the hash on chain, and what was read from it. */ +export interface DocEntry { + url: string; + hash: string; + value?: T; + /** When reading it last failed (ms), to retry later rather than on every refresh. */ + failedAt?: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +const body = (j: Any) => (j && typeof j === 'object' ? (j.body ?? {}) : {}); + +export async function drepName(url: string, hash: string): Promise { + const r = await fetchAnchor(url); + if (r.hash !== hash.toLowerCase()) return null; + return cleanText(body(r.json).givenName, 60) ?? null; +} + +export async function actionTitle(url: string, hash: string): Promise { + const r = await fetchAnchor(url); + if (r.hash !== hash.toLowerCase()) return null; + return cleanText(body(r.json).title, 140) ?? null; +} + +export async function poolName(url: string, hash: string): Promise<{ ticker?: string; name?: string } | null> { + const res = await safeGet(new URL(url), { maxBytes: 4096, timeoutMs: 5000, maxRedirects: 2, accept: 'application/json, */*;q=0.1' }); + if (blake2b256Hex(res.body) !== hash.toLowerCase()) return null; + const j = JSON.parse(new TextDecoder().decode(res.body)); + const ticker = cleanText(j?.ticker, 9); + const name = cleanText(j?.name, 50); + return ticker || name ? { ticker, name } : null; +} + +/** + * Read the documents that need it: never read, or failed more than `retryMs` ago. `concurrency` at a time; + * `onProgress` runs every `every` documents (to save). Returns how many were read and how many gave a name. + */ +export async function fillDocs( + entries: Record>, + read: (url: string, hash: string) => Promise, + opts: { concurrency: number; retryMs: number; now?: () => number; every?: number; onProgress?: () => Promise } +): Promise<{ read: number; named: number }> { + const now = opts.now ?? Date.now; + const todo = Object.values(entries).filter((e) => e.value === undefined && (e.failedAt === undefined || now() - e.failedAt > opts.retryMs)); + let next = 0; + let named = 0; + let done = 0; + const worker = async () => { + while (next < todo.length) { + const e = todo[next++]; + try { + const v = await read(e.url, e.hash); + if (v == null) e.failedAt = now(); + else { + e.value = v; + delete e.failedAt; + named++; + } + } catch { + e.failedAt = now(); + } + if (++done % (opts.every ?? 200) === 0) await opts.onProgress?.(); + } + }; + await Promise.all(Array.from({ length: Math.min(opts.concurrency, todo.length) }, worker)); + return { read: todo.length, named }; +} + +/** Put the latest url/hash for each key; a changed document is read again. Keys not in `latest` are dropped. */ +export function syncDocs(entries: Record>, latest: Map): Record> { + const out: Record> = {}; + for (const [key, { url, hash }] of latest) { + const prev = entries[key]; + out[key] = prev && prev.url === url && prev.hash === hash ? prev : { url, hash }; + } + return out; +} diff --git a/applications/viewer/src/lib/server/names/index.ts b/applications/viewer/src/lib/server/names/index.ts new file mode 100644 index 00000000..eac92003 --- /dev/null +++ b/applications/viewer/src/lib/server/names/index.ts @@ -0,0 +1,240 @@ +import { env } from '$env/dynamic/private'; +import { api, tryApi } from '$lib/api/client'; +import { KEY } from '$lib/names/keys'; +import type { NamesQuery, NamesResult, PoolName, ScriptName } from '$lib/names/types'; +import { actionTitle, drepName, fillDocs, poolName, syncDocs, type DocEntry } from './docs'; +import { loadJson, saveJson } from './persist'; +import { REGISTRIES, crfaNames, mergeScriptNames, stricaNames, type RegistrySource } from './registries'; +import { untarGz } from './tar'; +import { TokenNames, type TokenEntry } from './tokens'; + +/* + * Names for ids, on Mainnet only. On any other network (the devkit, testnets), or when the store can't say which + * network it is, nothing is fetched and every lookup is empty. + * + * In the background, after the server starts: + * - dApp labels for script hashes from the Strica and Cardano Fans registries (daily); + * - governance action titles and DRep names from their anchor documents (every 6 hours); + * - pool tickers from pool metadata, the registrations list read from the newest down to what was seen last time + * (every 12 hours; the first run reads all of it, a few minutes); + * - token tickers and decimals only when a page asks (tokens.ts). + * Each kind is saved as a JSON file in NAMES_DIR, so after a restart the names are there at once and only what + * changed on chain is read again. A document that couldn't be read is tried again a day (pools: three days) later. + * + * NAMES=off turns all of it off. + */ + +const MAINNET_MAGIC = 764824073; +const HOUR = 60 * 60_000; + +export const NAMES = { + tickMs: 15 * 60_000, + every: { scripts: 24 * HOUR, actions: 6 * HOUR, dreps: 6 * HOUR, pools: 12 * HOUR }, + retry: { actions: 24 * HOUR, dreps: 24 * HOUR, pools: 72 * HOUR }, + concurrency: { actions: 4, dreps: 6, pools: 8 }, + maxPages: 1000 +} as const; + +type Docs = Record>; +interface Saved { + version: 1; + magic: number; + at: number; + data: T; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms).unref?.()); +const dir = () => env.NAMES_DIR || '.cache/names'; + +async function pages(path: string, params: Record = {}, stop?: (rows: T[]) => boolean): Promise { + const all: T[] = []; + for (let page = 1; page <= NAMES.maxPages; page++) { + const rows = await api(path, { params: { ...params, count: 100, page }, timeoutMs: 60_000 }); + if (!Array.isArray(rows)) break; + all.push(...rows); + if (rows.length < 100 || stop?.(rows)) break; + } + return all; +} + +async function archive(url: string): Promise { + const res = await fetch(url, { signal: AbortSignal.timeout(60_000) }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (Number(res.headers.get('content-length') ?? 0) > 20 * 1024 * 1024) throw new Error('archive too large'); + return new Uint8Array(await res.arrayBuffer()); +} + +class NameBook { + /** null until the store has said which network it is. */ + mainnet: boolean | null = null; + scripts: Saved> = { version: 1, magic: MAINNET_MAGIC, at: 0, data: {} }; + actions: Saved> = { version: 1, magic: MAINNET_MAGIC, at: 0, data: {} }; + dreps: Saved> = { version: 1, magic: MAINNET_MAGIC, at: 0, data: {} }; + pools: Saved<{ lastBlock: number; docs: Docs }> = { version: 1, magic: MAINNET_MAGIC, at: 0, data: { lastBlock: 0, docs: {} } }; + tokens = new TokenNames({ fetch, onChange: () => this.saveTokensSoon() }); + private started = false; + private tokenSave: ReturnType | null = null; + + start() { + if (this.started) return; + this.started = true; + if (env.NAMES === 'off') return; + void this.run(); + } + + private async run() { + while (this.mainnet === null) { + this.mainnet = await this.network(); + if (this.mainnet === null) await sleep(10 * 60_000); + } + if (!this.mainnet) { + console.info('[names] not Mainnet: no dApp, DRep, pool, governance or token names'); + return; + } + await this.load(); + for (;;) { + await this.refreshDue(); + await sleep(NAMES.tickMs); + } + } + + private async network(): Promise { + const g = (await tryApi('/genesis', { timeoutMs: 15_000 })) ?? (await tryApi('/blockfrost/genesis', { timeoutMs: 15_000 })); + const magic = Number(g?.network_magic); + return Number.isFinite(magic) && g?.network_magic != null ? magic === MAINNET_MAGIC : null; + } + + private async load() { + const fit = (s: Saved | null) => (s?.version === 1 && s.magic === MAINNET_MAGIC ? s : null); + this.scripts = fit(await loadJson>>(dir(), 'scripts')) ?? this.scripts; + this.actions = fit(await loadJson>>(dir(), 'actions')) ?? this.actions; + this.dreps = fit(await loadJson>>(dir(), 'dreps')) ?? this.dreps; + this.pools = fit(await loadJson(dir(), 'pools')) ?? this.pools; + const tokens = fit(await loadJson>>(dir(), 'tokens')); + for (const [u, e] of Object.entries(tokens?.data ?? {})) this.tokens.entries.set(u, e); + const named = (d: Docs) => Object.values(d).filter((e) => e.value !== undefined).length; + console.info( + `[names] loaded: ${Object.keys(this.scripts.data).length} script labels, ${named(this.dreps.data)} DRep names, ` + + `${named(this.pools.data.docs)} pool tickers, ${named(this.actions.data)} action titles, ${this.tokens.entries.size} tokens` + ); + } + + private async refreshDue() { + const now = Date.now(); + const tasks: [keyof typeof NAMES.every, () => Promise][] = [ + ['scripts', () => this.refreshScripts()], + ['actions', () => this.refreshActions()], + ['dreps', () => this.refreshDreps()], + ['pools', () => this.refreshPools()] + ]; + for (const [kind, task] of tasks) { + if (now - this[kind].at < NAMES.every[kind]) continue; + try { + await task(); + } catch (e) { + // The store or a registry is down: tried again on the next tick. + console.warn(`[names] ${kind}: ${(e as Error).message}`); + } + } + } + + private async refreshScripts() { + const kept = (source: RegistrySource) => new Map(Object.entries(this.scripts.data).filter(([, v]) => v.source === source)); + const read = async (i: number, parse: typeof stricaNames) => { + try { + return parse(untarGz(await archive(REGISTRIES[i].archive))); + } catch (e) { + console.warn(`[names] ${REGISTRIES[i].source} registry: ${(e as Error).message}`); + return null; + } + }; + const [strica, crfa] = await Promise.all([read(0, stricaNames), read(1, crfaNames)]); + if (!strica && !crfa) throw new Error('no registry could be read'); + this.scripts = { ...this.scripts, at: Date.now(), data: mergeScriptNames(strica ?? kept('strica'), crfa ?? kept('crfa')) }; + await saveJson(dir(), 'scripts', this.scripts); + console.info(`[names] scripts: ${Object.keys(this.scripts.data).length} labels (Strica ${strica?.size ?? 'kept'}, Cardano Fans ${crfa?.size ?? 'kept'})`); + } + + private async refreshActions() { + const rows = await pages('/governance/proposals'); + const latest = new Map(); + for (const r of rows) { + const key = KEY.actions(`${r.tx_hash}#${r.index}`); + if (key && r.anchor_url && r.anchor_hash) latest.set(key, { url: r.anchor_url, hash: r.anchor_hash }); + } + await this.fill('actions', syncDocs(this.actions.data, latest), actionTitle); + } + + private async refreshDreps() { + const rows = await pages('/blockfrost/governance/dreps'); + const latest = new Map(); + for (const r of rows) { + const key = KEY.dreps(r.drep_id); + if (key && r.metadata?.url && r.metadata?.hash) latest.set(key, { url: r.metadata.url, hash: r.metadata.hash }); + } + await this.fill('dreps', syncDocs(this.dreps.data, latest), drepName); + } + + /** Registrations newest first, down to the last block seen; each pool's newest registration wins. */ + private async refreshPools() { + const last = this.pools.data.lastBlock; + const rows = await pages('/pools/registrations', { order: 'desc' }, (rows) => rows.every((r) => Number(r.block_number) <= last)); + const latest = new Map(Object.entries(this.pools.data.docs).map(([k, e]) => [k, { url: e.url, hash: e.hash }])); + const seen = new Set(); + let top = last; + for (const r of rows) { + const key = KEY.pools(r.pool_id); + const block = Number(r.block_number); + if (!key || seen.has(key) || block <= last) continue; + seen.add(key); + top = Math.max(top, block); + if (r.metadata_url && r.metadata_hash) latest.set(key, { url: r.metadata_url, hash: r.metadata_hash }); + else latest.delete(key); + } + this.pools.data.lastBlock = top; + const docs = syncDocs(this.pools.data.docs, latest); + this.pools.data.docs = docs; + await this.fill('pools', docs, poolName); + } + + private async fill(kind: 'actions' | 'dreps' | 'pools', docs: Docs, read: (url: string, hash: string) => Promise) { + const saved = this[kind] as Saved; + if (kind === 'pools') this.pools.data.docs = docs as Docs; + else saved.data = docs; + const save = () => saveJson(dir(), kind, saved); + await save(); + const r = await fillDocs(docs, read, { concurrency: NAMES.concurrency[kind], retryMs: NAMES.retry[kind], onProgress: save }); + saved.at = Date.now(); + await save(); + const total = Object.keys(docs).length; + const named = Object.values(docs).filter((e) => e.value !== undefined).length; + console.info(`[names] ${kind}: ${named} of ${total} named (read ${r.read} documents now, ${r.named} gave a name)`); + } + + private saveTokensSoon() { + if (this.tokenSave) return; + this.tokenSave = setTimeout(() => { + this.tokenSave = null; + void saveJson(dir(), 'tokens', { version: 1, magic: MAINNET_MAGIC, at: Date.now(), data: Object.fromEntries(this.tokens.entries) }); + }, 30_000); + this.tokenSave.unref?.(); + } + + /** The names that are known for these keys (already normalised, see keys.ts). Empty off Mainnet. */ + async lookup(q: Required): Promise { + const enabled = env.NAMES === 'off' ? false : this.mainnet; + const out: NamesResult = { enabled, scripts: {}, dreps: {}, pools: {}, actions: {}, tokens: {} }; + if (!enabled) return out; + for (const k of q.scripts) if (this.scripts.data[k]) out.scripts[k] = this.scripts.data[k]; + for (const k of q.dreps) if (this.dreps.data[k]?.value) out.dreps[k] = this.dreps.data[k].value!; + for (const k of q.pools) if (this.pools.data.docs[k]?.value) out.pools[k] = this.pools.data.docs[k].value!; + for (const k of q.actions) if (this.actions.data[k]?.value) out.actions[k] = this.actions.data[k].value!; + if (q.tokens.length) out.tokens = await this.tokens.get(q.tokens); + return out; + } +} + +export const names = new NameBook(); diff --git a/applications/viewer/src/lib/server/names/names.test.ts b/applications/viewer/src/lib/server/names/names.test.ts new file mode 100644 index 00000000..09c7b8f2 --- /dev/null +++ b/applications/viewer/src/lib/server/names/names.test.ts @@ -0,0 +1,202 @@ +import { gzipSync } from 'node:zlib'; +import { describe, expect, it, vi } from 'vitest'; +import { cleanText } from './clean'; +import { fillDocs, syncDocs, type DocEntry } from './docs'; +import { crfaNames, crfaScriptHash, mergeScriptNames, stricaNames } from './registries'; +import { untarGz } from './tar'; +import { TokenNames, tokenFromRegistry } from './tokens'; + +/** A .tar.gz with these files, the way GitHub archives them (a top folder, a pax header for a long name). */ +function tarGz(files: Record): Uint8Array { + const blocks: Uint8Array[] = []; + const header = (name: string, size: number, type: string) => { + const h = new Uint8Array(512); + const put = (s: string, at: number) => h.set(new TextEncoder().encode(s), at); + put(name.slice(0, 99), 0); + put(size.toString(8).padStart(11, '0'), 124); + put(type, 156); + put('ustar\u000000', 257); + return h; + }; + const pad = (b: Uint8Array) => { + const out = new Uint8Array(Math.ceil(b.length / 512) * 512); + out.set(b); + return out; + }; + blocks.push(header('pax_global_header', 0, 'g')); + for (const [path, text] of Object.entries(files)) { + const data = new TextEncoder().encode(text); + if (path.length > 99) { + const rec = ` path=${path}\n`; + const len = String(rec.length + String(rec.length).length); + const pax = new TextEncoder().encode(`${len}${rec}`); + blocks.push(header('PaxHeader', pax.length, 'x'), pad(pax)); + } + blocks.push(header(path, data.length, '0'), pad(data)); + } + blocks.push(new Uint8Array(1024)); + const all = new Uint8Array(blocks.reduce((n, b) => n + b.length, 0)); + let off = 0; + for (const b of blocks) all.set(b, (off += b.length) - b.length); + return gzipSync(all); +} + +const h = (c: string) => c.repeat(56); + +describe('registries', () => { + const long = `repo-master/projects/${'x'.repeat(100)}.json`; + const strica = untarGz( + tarGz({ + 'repo-master/README.md': '# hi', + 'repo-master/projects/minswap.json': JSON.stringify({ + projectName: 'Minswap', + labelPrefix: 'Minswap', + category: 'DEX', + website: 'https://minswap.org', + contracts: [ + { name: 'Order Contract', version: 2, scriptHash: h('a') }, + { name: 'contract', version: 1, scriptHash: h('b') }, + { name: 'minswap batcher', version: 1, scriptHash: h('f') }, + { name: 'Bad', version: 1, scriptHash: 'xyz' } + ] + }), + [long]: JSON.stringify({ projectName: 'Long‮Name', labelPrefix: 'Long', contracts: [{ name: 'Pool', version: 1, scriptHash: h('c') }] }) + }) + ); + + it('reads a GitHub archive, long names included', () => { + expect(strica.map((f) => f.path)).toEqual(['repo-master/README.md', 'repo-master/projects/minswap.json', long]); + }); + + it('labels Strica contracts as prefix + name (+ version past 1)', () => { + const names = stricaNames(strica); + expect(names.get(h('a'))).toEqual({ label: 'Minswap Order Contract v2', project: 'Minswap', category: 'DEX', url: 'https://minswap.org', source: 'strica' }); + expect(names.get(h('b'))?.label).toBe('Minswap'); + expect(names.get(h('f'))?.label).toBe('minswap batcher'); + expect(names.get(h('c'))?.project).toBe('Long Name'); + expect(names.size).toBe(4); + }); + + it('labels CRFA scripts and minting policies, and Strica wins on overlap', () => { + const crfa = crfaNames( + untarGz( + tarGz({ + 'r-main/dApps/x.json': JSON.stringify({ + projectName: 'Indigo', + subCategory: 'SYNTHETICS', + link: 'http://insecure.example', + scripts: [ + { + name: 'CDP', + versions: [ + { version: 1, scriptHash: h('d') }, + { version: 2, scriptHash: h('a'), mintPolicyID: h('e') } + ] + } + ] + }) + }) + ) + ); + expect(crfa.get(h('d'))).toEqual({ label: 'Indigo CDP v1', project: 'Indigo', category: 'SYNTHETICS', url: undefined, source: 'crfa' }); + expect(crfa.get(h('e'))?.label).toBe('Indigo CDP v2'); + const merged = mergeScriptNames(stricaNames(strica), crfa); + expect(merged[h('a')].source).toBe('strica'); + expect(merged[h('d')].source).toBe('crfa'); + }); +}); + +describe('crfaScriptHash', () => { + it('takes the script hash out of a whole address body', () => { + expect(crfaScriptHash(h('a'))).toBe(h('a')); + expect(crfaScriptHash(h('a') + h('b'))).toBe(h('a')); + expect(crfaScriptHash('11' + h('a') + h('b'))).toBe(h('a')); + expect(crfaScriptHash('71' + h('a'))).toBe(h('a')); + expect(crfaScriptHash('xyz')).toBeNull(); + }); +}); + +describe('cleanText', () => { + it('keeps names short and plain', () => { + expect(cleanText({ '@value': ' Ada\n Lovelace ' }, 60)).toBe('Ada Lovelace'); + expect(cleanText('evil‮name', 60)).toBe('evil name'); + expect(cleanText('x'.repeat(10), 5)).toBe('xxxx…'); + expect(cleanText(42, 5)).toBeUndefined(); + expect(cleanText(' ', 5)).toBeUndefined(); + }); +}); + +describe('documents', () => { + it('reads new and long-failed documents, keeps names, notes failures', async () => { + let now = 1_000_000; + const docs: Record> = { + a: { url: 'https://a', hash: '1' }, + b: { url: 'https://b', hash: '2', value: 'kept' }, + c: { url: 'https://c', hash: '3', failedAt: now - 10 }, + d: { url: 'https://d', hash: '4', failedAt: now - 10_000 }, + e: { url: 'https://e', hash: '5' } + }; + const read = vi.fn(async (url: string): Promise => (url === 'https://e' ? Promise.reject(new Error('down')) : url === 'https://d' ? null : `name of ${url}`)); + const r = await fillDocs(docs, read, { concurrency: 2, retryMs: 1000, now: () => now }); + expect(read.mock.calls.map((c) => c[0]).sort()).toEqual(['https://a', 'https://d', 'https://e']); + expect(r).toEqual({ read: 3, named: 1 }); + expect(docs.a.value).toBe('name of https://a'); + expect(docs.b.value).toBe('kept'); + expect(docs.d.failedAt).toBe(now); + expect(docs.e.failedAt).toBe(now); + now += 1; + }); + + it('reads a document again when its url or hash changes, and drops ids no longer listed', () => { + const docs: Record> = { a: { url: 'u', hash: '1', value: 'A' }, b: { url: 'u', hash: '2', value: 'B' }, gone: { url: 'u', hash: '9', value: 'G' } }; + const out = syncDocs( + docs, + new Map([ + ['a', { url: 'u', hash: '1' }], + ['b', { url: 'u', hash: 'changed' }], + ['new', { url: 'n', hash: '3' }] + ]) + ); + expect(out).toEqual({ a: { url: 'u', hash: '1', value: 'A' }, b: { url: 'u', hash: 'changed' }, new: { url: 'n', hash: '3' } }); + }); +}); + +describe('token names', () => { + const unit = (c: string) => c.repeat(56) + '4d494e'; + const answer = (subjects: string[]) => + new Response(JSON.stringify({ subjects: subjects.map((s) => ({ subject: s, ticker: { value: 'MIN' }, name: { value: 'Minswap' }, decimals: { value: 6 } })) })); + + it('reads the registry fields', () => { + expect(tokenFromRegistry({ ticker: { value: 'MIN' }, decimals: { value: 6 } })).toEqual({ ticker: 'MIN', name: undefined, decimals: 6 }); + expect(tokenFromRegistry({ decimals: { value: 99 } })).toBeNull(); + }); + + it('asks the registry once per token, batches, and remembers unknown tokens', async () => { + let now = 0; + const fetch = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const { subjects } = JSON.parse(String(init?.body)); + return answer(subjects.filter((s: string) => s === unit('a'))); + }); + const t = new TokenNames({ fetch: fetch as unknown as typeof globalThis.fetch, now: () => now }); + const [first, second] = await Promise.all([t.get([unit('a'), unit('b')]), t.get([unit('a')])]); + expect(fetch).toHaveBeenCalledTimes(1); + expect(first).toEqual({ [unit('a')]: { ticker: 'MIN', name: 'Minswap', decimals: 6 } }); + expect(second).toEqual(first); + await t.get([unit('a'), unit('b')]); + expect(fetch).toHaveBeenCalledTimes(1); + now += 25 * 60 * 60_000; // a day later: the unknown one is asked again, the known one is still fresh + await t.get([unit('a'), unit('b')]); + expect(fetch).toHaveBeenCalledTimes(2); + expect(JSON.parse(String(fetch.mock.calls[1][1]?.body)).subjects).toEqual([unit('b')]); + }); + + it('gives up waiting on a slow registry and answers later', async () => { + let release: (r: Response) => void = () => {}; + const fetch = vi.fn(() => new Promise((r) => (release = r))); + const t = new TokenNames({ fetch: fetch as unknown as typeof globalThis.fetch }); + expect(await t.get([unit('a')], 10)).toEqual({}); + release(answer([unit('a')])); + await new Promise((r) => setTimeout(r, 0)); + expect(await t.get([unit('a')], 10)).toEqual({ [unit('a')]: { ticker: 'MIN', name: 'Minswap', decimals: 6 } }); + }); +}); diff --git a/applications/viewer/src/lib/server/names/persist.ts b/applications/viewer/src/lib/server/names/persist.ts new file mode 100644 index 00000000..2eff0862 --- /dev/null +++ b/applications/viewer/src/lib/server/names/persist.ts @@ -0,0 +1,31 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +/* + * The names are saved as JSON files (one per kind), so a restart has them at once and only refreshes what changed. + * Writes go to a temporary file first and are renamed into place. Where the folder can't be written (a read-only + * deployment), the names stay in memory only: one warning, then no more tries. + */ + +let readOnly = false; + +export async function loadJson(dir: string, name: string): Promise { + try { + return JSON.parse(await readFile(join(dir, `${name}.json`), 'utf8')) as T; + } catch { + return null; + } +} + +export async function saveJson(dir: string, name: string, value: unknown): Promise { + if (readOnly) return; + const file = join(dir, `${name}.json`); + try { + await mkdir(dir, { recursive: true }); + await writeFile(`${file}.tmp`, JSON.stringify(value)); + await rename(`${file}.tmp`, file); + } catch (e) { + readOnly = true; + console.warn(`[names] can't save to ${dir} (${(e as Error).message}); names are kept in memory only. Set NAMES_DIR to a writable folder.`); + } +} diff --git a/applications/viewer/src/lib/server/names/registries.ts b/applications/viewer/src/lib/server/names/registries.ts new file mode 100644 index 00000000..a5159a7f --- /dev/null +++ b/applications/viewer/src/lib/server/names/registries.ts @@ -0,0 +1,104 @@ +import type { ScriptName } from '$lib/names/types'; +import { cleanText } from './clean'; +import type { TarFile } from './tar'; + +/* + * dApp labels for script hashes, from two curated registries on GitHub: + * - Strica's Cardano Contracts Registry (the labels Cardanoscan shows): projects/.json, one entry per contract. + * - Cardano Fans' CRFA off-chain data registry: dApps/.json, scripts with versions (spending scripts and + * minting policies). + * Both are read from their repository archive (one download each). Strica's label wins when both list a hash. + */ + +export const REGISTRIES = [ + { source: 'strica', archive: 'https://codeload.github.com/StricaHQ/cardano-contracts-registry/tar.gz/refs/heads/master', dir: '/projects/' }, + { source: 'crfa', archive: 'https://codeload.github.com/Cardano-Fans/crfa-offchain-data-registry/tar.gz/refs/heads/main', dir: '/dApps/' } +] as const; + +export type RegistrySource = (typeof REGISTRIES)[number]['source']; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +const HASH = /^[0-9a-f]{56}$/; + +/** + * The script hash in a CRFA "scriptHash", which is sometimes the whole address body: payment script hash + stake + * credential (112 hex), with or without the address header byte (58 / 114). + */ +export function crfaScriptHash(v: unknown): string | null { + const h = String(v ?? '').toLowerCase(); + if (!/^[0-9a-f]+$/.test(h)) return null; + const body = h.length === 58 || h.length === 114 ? h.slice(2) : h; + return body.length === 56 || body.length === 112 ? body.slice(0, 56) : null; +} +const str = cleanText; +const web = (v: unknown): string | undefined => { + const s = str(v, 200); + return s && /^https:\/\//i.test(s) ? s : undefined; +}; + +function json(file: TarFile): Any | null { + try { + return JSON.parse(new TextDecoder().decode(file.data)); + } catch { + return null; + } +} + +/** Strica: label = labelPrefix + contract name, with the version when it is past 1. */ +export function stricaNames(files: TarFile[]): Map { + const out = new Map(); + for (const f of files) { + if (!f.path.includes('/projects/') || !f.path.endsWith('.json')) continue; + const p = json(f); + const project = str(p?.projectName, 40); + if (!project || !Array.isArray(p.contracts)) continue; + const prefix = str(p.labelPrefix, 24) ?? project; + for (const c of p.contracts) { + const hash = String(c?.scriptHash ?? '').toLowerCase(); + if (!HASH.test(hash)) continue; + const name = str(c.name, 60); + const version = Number(c.version); + // "contract" is the registry's word for an unnamed contract; a name that already starts with the prefix stands alone. + const label = !name || name.toLowerCase() === 'contract' ? prefix : name.toLowerCase().startsWith(prefix.toLowerCase()) ? name : `${prefix} ${name}`; + out.set(hash, { + label: version > 1 ? `${label} v${version}` : label, + project, + category: str(p.category, 20), + url: web(p.website), + source: 'strica' + }); + } + } + return out; +} + +/** CRFA: label = project + script name (+ version), for script hashes and minting policy ids. */ +export function crfaNames(files: TarFile[]): Map { + const out = new Map(); + for (const f of files) { + if (!f.path.includes('/dApps/') || !f.path.endsWith('.json')) continue; + const p = json(f); + const project = str(p?.projectName, 40); + if (!project || !Array.isArray(p.scripts)) continue; + for (const s of p.scripts) { + const name = str(s?.name, 60); + const versions = Array.isArray(s?.versions) ? s.versions : []; + const many = versions.length > 1; + for (const v of versions) { + const label = [project, name, many && v?.version != null ? `v${v.version}` : null].filter(Boolean).join(' '); + for (const hash of [crfaScriptHash(v?.scriptHash), crfaScriptHash(v?.mintPolicyID)]) { + if (!hash || out.has(hash)) continue; + out.set(hash, { label, project, category: str(p.subCategory ?? p.category, 20), url: web(p.link), source: 'crfa' }); + } + } + } + } + return out; +} + +/** One map from both registries; Strica's entries replace CRFA's for the same hash. */ +export function mergeScriptNames(strica: Map, crfa: Map): Record { + return Object.fromEntries([...crfa, ...strica]); +} diff --git a/applications/viewer/src/lib/server/names/tar.ts b/applications/viewer/src/lib/server/names/tar.ts new file mode 100644 index 00000000..0fa0041d --- /dev/null +++ b/applications/viewer/src/lib/server/names/tar.ts @@ -0,0 +1,59 @@ +import { gunzipSync } from 'node:zlib'; + +/* + * The files in a .tar.gz, enough for GitHub's repository archives: plain files, long names from pax headers ("x") + * and the ustar prefix field. Everything else (directories, links, the global pax header) is skipped. + */ + +export interface TarFile { + path: string; + data: Uint8Array; +} + +const text = (b: Uint8Array, start: number, len: number) => { + const s = new TextDecoder().decode(b.subarray(start, start + len)); + const nul = s.indexOf('\0'); + return nul < 0 ? s : s.slice(0, nul); +}; + +/** Values of a pax extended header (" key=value\n" records). */ +function paxValues(data: Uint8Array): Record { + const out: Record = {}; + const s = new TextDecoder().decode(data); + let i = 0; + while (i < s.length) { + const space = s.indexOf(' ', i); + const len = parseInt(s.slice(i, space), 10); + if (!(len > 0) || space < 0) break; + const record = s.slice(space + 1, i + len - 1); + const eq = record.indexOf('='); + if (eq > 0) out[record.slice(0, eq)] = record.slice(eq + 1); + i += len; + } + return out; +} + +export function untarGz(gz: Uint8Array, maxBytes = 32 * 1024 * 1024): TarFile[] { + const tar = new Uint8Array(gunzipSync(gz, { maxOutputLength: maxBytes })); + const files: TarFile[] = []; + let nextPath: string | null = null; + for (let off = 0; off + 512 <= tar.length; ) { + const header = tar.subarray(off, off + 512); + if (header.every((b) => b === 0)) break; + const size = parseInt(text(header, 124, 12).trim() || '0', 8); + const type = String.fromCharCode(header[156] || 48); + const data = tar.subarray(off + 512, off + 512 + size); + off += 512 + Math.ceil(size / 512) * 512; + if (type === 'x') { + nextPath = paxValues(data).path ?? null; + continue; + } + if (type === 'g') continue; + const prefix = text(header, 345, 155); + const name = text(header, 0, 100); + const path = nextPath ?? (prefix ? `${prefix}/${name}` : name); + nextPath = null; + if (type === '0' || type === '\0') files.push({ path, data }); + } + return files; +} diff --git a/applications/viewer/src/lib/server/names/tokens.ts b/applications/viewer/src/lib/server/names/tokens.ts new file mode 100644 index 00000000..b71b9660 --- /dev/null +++ b/applications/viewer/src/lib/server/names/tokens.ts @@ -0,0 +1,103 @@ +import type { TokenName } from '$lib/names/types'; +import { cleanText } from './clean'; + +/* + * Token tickers and decimals from the Cardano Foundation's token registry (CIP-26), looked up when a page shows a + * token, not in bulk: there are far more tokens than pages ever show. The batch endpoint of the v1 API answers + * only the asked properties (the v2 one always includes the logo, often tens of KB per token). Answers are kept for + * a week, "not registered" for a day; a token already being looked up isn't asked for twice. + */ + +export const TOKEN_REGISTRY = 'https://tokens.cardano.org/metadata/query'; + +const KNOWN_MS = 7 * 24 * 60 * 60_000; +const UNKNOWN_MS = 24 * 60 * 60_000; +const MAX_ENTRIES = 20_000; +const BATCH = 100; +/** Registry requests in flight at once, for every visitor together: more tokens wait for a later page view. */ +const MAX_LOADS = 2; + +export interface TokenEntry { + value: TokenName | null; + at: number; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/** One registry answer → the fields shown (null when it has none of them). */ +export function tokenFromRegistry(s: Any): TokenName | null { + const ticker = cleanText(s?.ticker?.value, 12); + const name = cleanText(s?.name?.value, 50); + const d = Number(s?.decimals?.value); + const decimals = Number.isInteger(d) && d >= 0 && d <= 19 ? d : undefined; + return ticker || name || decimals ? { ticker, name, decimals } : null; +} + +export class TokenNames { + entries = new Map(); + private pending = new Map>(); + private loads = 0; + + constructor(private opts: { fetch: typeof fetch; now?: () => number; timeoutMs?: number; onChange?: () => void } = { fetch }) {} + + private now() { + return (this.opts.now ?? Date.now)(); + } + + private fresh(e: TokenEntry | undefined) { + return !!e && this.now() - e.at < (e.value ? KNOWN_MS : UNKNOWN_MS); + } + + /** Names for these units: what is known now, after asking the registry for the rest (at most `waitMs`). */ + async get(units: string[], waitMs = 3000): Promise> { + const ask = units.filter((u) => !this.fresh(this.entries.get(u)) && !this.pending.has(u)); + for (let i = 0; i < ask.length && this.loads < MAX_LOADS; i += BATCH) void this.load(ask.slice(i, i + BATCH)); + const waiting = [...new Set(units.map((u) => this.pending.get(u)).filter(Boolean))]; + if (waiting.length) await Promise.race([Promise.allSettled(waiting), new Promise((r) => setTimeout(r, waitMs))]); + const out: Record = {}; + for (const u of units) { + const v = this.entries.get(u)?.value; + if (v) out[u] = v; + } + return out; + } + + private load(units: string[]): Promise { + this.loads++; + const p = (async () => { + try { + const res = await this.opts.fetch(TOKEN_REGISTRY, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify({ subjects: units, properties: ['name', 'ticker', 'decimals'] }), + signal: AbortSignal.timeout(this.opts.timeoutMs ?? 8000) + }); + if (!res.ok) return; + const j = await res.json(); + const found = new Map(); + for (const s of Array.isArray(j?.subjects) ? j.subjects : []) { + if (typeof s?.subject === 'string') found.set(s.subject.toLowerCase(), tokenFromRegistry(s)); + } + const at = this.now(); + for (const u of units) this.entries.set(u, { value: found.get(u) ?? null, at }); + this.trim(); + this.opts.onChange?.(); + } catch { + // The registry is down or slow: ask again on the next page that shows these tokens. + } finally { + this.loads--; + for (const u of units) this.pending.delete(u); + } + })(); + for (const u of units) this.pending.set(u, p); + return p; + } + + private trim() { + if (this.entries.size <= MAX_ENTRIES) return; + // "Not registered" goes first, then the oldest: a flood of made-up units must not push out the real names. + const oldest = [...this.entries].sort((a, b) => Number(!!a[1].value) - Number(!!b[1].value) || a[1].at - b[1].at).slice(0, this.entries.size - MAX_ENTRIES); + for (const [u] of oldest) this.entries.delete(u); + } +} diff --git a/applications/viewer/src/lib/server/preloads.test.ts b/applications/viewer/src/lib/server/preloads.test.ts new file mode 100644 index 00000000..c7a098ef --- /dev/null +++ b/applications/viewer/src/lib/server/preloads.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { injectIntoHead, linkHeaderToTags } from './preloads'; + +const header = + '<./_app/immutable/assets/0.css>; rel="preload"; as="style"; nopush, <./_app/immutable/entry/start.js>; rel="modulepreload"; nopush, <./fonts/a.woff2>; rel="preload"; as="font"; type="font/woff2"; crossorigin; nopush'; + +async function text(stream: ReadableStream) { + return new Response(stream).text(); +} +function streamOf(...chunks: string[]) { + const enc = new TextEncoder(); + return new ReadableStream({ + start(c) { + for (const ch of chunks) c.enqueue(enc.encode(ch)); + c.close(); + } + }); +} + +describe('preloads', () => { + it('turns SvelteKit’s Link header into tags', () => { + expect(linkHeaderToTags(header)).toBe( + '' + + '' + + '' + ); + expect(linkHeaderToTags('')).toBe(''); + }); + + it('inserts the tags before , also when the tag is split across chunks', async () => { + expect(await text(injectIntoHead(streamOf('xhi'), ''))).toBe('xhi'); + expect(await text(injectIntoHead(streamOf('', 'x', 'streamed'), ''))).toBe('xstreamed'); + expect(await text(injectIntoHead(streamOf('no head here'), ''))).toBe('no head here'); + }); +}); diff --git a/applications/viewer/src/lib/server/preloads.ts b/applications/viewer/src/lib/server/preloads.ts new file mode 100644 index 00000000..d1c763ba --- /dev/null +++ b/applications/viewer/src/lib/server/preloads.ts @@ -0,0 +1,53 @@ +/* + * Turn SvelteKit's `Link` preload header into tags for the page's (see hooks.server.ts): + * <./_app/a.js>; rel="modulepreload"; nopush, <./_app/b.css>; rel="preload"; as="style"; nopush + * becomes . + */ + +const escape = (s: string) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/ { + const m = entry.trim().match(/^<([^>]*)>\s*(.*)$/); + if (!m) return ''; + const attrs = [`href="${escape(m[1])}"`]; + for (const part of m[2].split(';')) { + const p = part.trim(); + if (!p || p === 'nopush') continue; + const kv = p.match(/^([a-z-]+)\s*=\s*"?([^"]*)"?$/i); + attrs.push(kv ? `${kv[1].toLowerCase()}="${escape(kv[2])}"` : escape(p.toLowerCase())); + } + return ``; + }) + .filter(Boolean) + .join(''); +} + +/** Insert `tags` right before the first of a streamed HTML body, passing everything else through as it comes. */ +export function injectIntoHead(body: ReadableStream, tags: string): ReadableStream { + let done = false; + let pending = ''; + const inject = new TransformStream({ + transform(chunk, controller) { + if (done) return controller.enqueue(chunk); + pending += chunk; + const at = pending.indexOf(''); + if (at >= 0) { + controller.enqueue(pending.slice(0, at) + tags + pending.slice(at)); + pending = ''; + done = true; + } else if (pending.length > 7) { + // Keep a tail that might be the start of a split "". + controller.enqueue(pending.slice(0, -7)); + pending = pending.slice(-7); + } + }, + flush(controller) { + if (pending) controller.enqueue(pending); + } + }); + return body.pipeThrough(new TextDecoderStream()).pipeThrough(inject).pipeThrough(new TextEncoderStream()); +} 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/server/stake-account.ts b/applications/viewer/src/lib/server/stake-account.ts new file mode 100644 index 00000000..13a6b3b3 --- /dev/null +++ b/applications/viewer/src/lib/server/stake-account.ts @@ -0,0 +1,28 @@ +import type { Slow } from '$lib/api/slow'; +import type { AnalyticsSource } from '$lib/analytics/types'; +import { analyticsStakeAccount } from './analytics-sections'; +import { GUARD, guardedApi, settle } from './heavy-guard'; + +export type StakeAccount = { + controlled_amount?: number; + withdrawable_amount?: number; + pool_id?: string | null; + /** Set when the account came from analytics (as of the last export) because /accounts was too slow. */ + analytics?: AnalyticsSource; +}; + +/** + * A stake account for a page section: /accounts/{stake} through the guard (it can take ~30 s for a busy account, so + * it streams, and a minute's answer is kept). When that is too slow and the store has analytics, what the account + * controls and its pool come from there instead. + */ +export function stakeAccountSection(stake: string, fetch: typeof globalThis.fetch): Promise> { + const path = `/accounts/${stake}`; + return settle(guardedApi(path, path, { fetch, timeoutMs: GUARD.timeoutMs.account, cacheMs: GUARD.accountCacheMs })).then(async (r) => { + if (r.ok || r.reason !== 'heavy') return r; + const fromAnalytics = await analyticsStakeAccount(stake, fetch).catch(() => null); + if (!fromAnalytics) return r; + const { exportedTo, live, ...account } = fromAnalytics; + return { ok: true, value: { ...account, analytics: { exportedTo, live } } }; + }); +} diff --git a/applications/viewer/src/lib/server/swr-cache.test.ts b/applications/viewer/src/lib/server/swr-cache.test.ts new file mode 100644 index 00000000..f2533b6a --- /dev/null +++ b/applications/viewer/src/lib/server/swr-cache.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest'; +import { SwrCache } from './swr-cache'; + +function setup(maxEntries = 10) { + let t = 0; + const cache = new SwrCache({ maxEntries, maxStaleMs: 1000, now: () => t }); + return { cache, advance: (ms: number) => (t += ms) }; +} +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe('SwrCache', () => { + it('serves a fresh answer without loading again', async () => { + const { cache, advance } = setup(); + let calls = 0; + const load = async () => ++calls; + expect(await cache.get('k', 100, load)).toBe(1); + advance(100); + expect(await cache.get('k', 100, load)).toBe(1); + expect(calls).toBe(1); + }); + + it('serves a stale answer at once and refreshes it in the background', async () => { + const { cache, advance } = setup(); + let calls = 0; + const load = async () => ++calls; + await cache.get('k', 100, load); + advance(101); + expect(await cache.get('k', 100, load)).toBe(1); // stale, at once + await tick(); + expect(calls).toBe(2); + expect(await cache.get('k', 100, load)).toBe(2); // the refreshed answer + }); + + it('loads again, and waits, once an answer is older than maxStaleMs', async () => { + const { cache, advance } = setup(); + let calls = 0; + await cache.get('k', 100, async () => ++calls); + advance(1001); + expect(await cache.get('k', 100, async () => ++calls)).toBe(2); + }); + + it('shares one load between concurrent callers', async () => { + const { cache } = setup(); + let calls = 0; + let release!: () => void; + const gate = new Promise((r) => (release = r)); + const load = async () => (calls++, await gate, 'v'); + const all = Promise.all([cache.get('k', 100, load), cache.get('k', 100, load), cache.get('k', 100, load)]); + release(); + expect(await all).toEqual(['v', 'v', 'v']); + expect(calls).toBe(1); + }); + + it('keeps the stale answer when a refresh fails', async () => { + const { cache, advance } = setup(); + await cache.get('k', 100, async () => 'old'); + advance(150); + expect(await cache.get('k', 100, async () => Promise.reject(new Error('down')))).toBe('old'); + await tick(); + expect(await cache.get('k', 100, async () => 'new')).toBe('old'); // still stale: refreshes again + await tick(); + expect(await cache.get('k', 100, async () => 'newer')).toBe('new'); + }); + + it('passes on a failed first load', async () => { + const { cache } = setup(); + await expect(cache.get('k', 100, async () => Promise.reject(new Error('down')))).rejects.toThrow('down'); + expect(await cache.get('k', 100, async () => 'ok')).toBe('ok'); + }); + + it('keeps at most maxEntries, dropping the least recently used', async () => { + const { cache } = setup(2); + let calls = 0; + const load = async () => ++calls; + await cache.get('a', 100, load); + await cache.get('b', 100, load); + await cache.get('a', 100, load); // a is now the most recently used + await cache.get('c', 100, load); // drops b + await cache.get('a', 100, load); + expect(calls).toBe(3); + await cache.get('b', 100, load); + expect(calls).toBe(4); + }); +}); diff --git a/applications/viewer/src/lib/server/swr-cache.ts b/applications/viewer/src/lib/server/swr-cache.ts new file mode 100644 index 00000000..c95e25e5 --- /dev/null +++ b/applications/viewer/src/lib/server/swr-cache.ts @@ -0,0 +1,46 @@ +/* + * A small in-memory cache that serves stale answers while it refreshes them ("stale while revalidate"), for results + * that change slowly, like charts over Yaci Store's daily exports. + * - Fresh (younger than `ttlMs`): returned as is. + * - Stale (older, but younger than `maxStaleMs`): returned at once, and refreshed in the background. + * - Missing or too old: loaded, and the caller waits. + * Concurrent loads of one key share a call. A failed refresh keeps the stale answer, until it is too old. + * At most `maxEntries` are kept; the least recently used go first, so expired entries never pile up. + */ +export class SwrCache { + private entries = new Map(); + private loading = new Map>(); + private now: () => number; + + constructor(private opts: { maxEntries: number; maxStaleMs: number; now?: () => number }) { + this.now = opts.now ?? Date.now; + } + + get(key: string, ttlMs: number, load: () => Promise): Promise { + const e = this.entries.get(key); + const age = e ? this.now() - e.at : Infinity; + if (e && age <= this.opts.maxStaleMs) { + this.touch(key, e); + // A failed refresh leaves the stale answer in place; the next request tries again. + if (age > ttlMs) this.refresh(key, load).catch(() => {}); + return Promise.resolve(e.value as T); + } + return this.refresh(key, load); + } + + private refresh(key: string, load: () => Promise): Promise { + const pending = this.loading.get(key); + if (pending) return pending as Promise; + const p = load() + .then((value) => (this.touch(key, { value, at: this.now() }), value)) + .finally(() => this.loading.delete(key)); + this.loading.set(key, p); + return p; + } + + private touch(key: string, e: { value: unknown; at: number }) { + this.entries.delete(key); + this.entries.set(key, e); + while (this.entries.size > this.opts.maxEntries) this.entries.delete(this.entries.keys().next().value!); + } +} 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..73c0a62f --- /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..8fbc60f5 --- /dev/null +++ b/applications/viewer/src/lib/shell/Topbar.svelte @@ -0,0 +1,35 @@ + + +
+
+ +
+ + + +
+ + + +
+
+
diff --git a/applications/viewer/src/lib/shell/nav.ts b/applications/viewer/src/lib/shell/nav.ts new file mode 100644 index 00000000..6cdb4665 --- /dev/null +++ b/applications/viewer/src/lib/shell/nav.ts @@ -0,0 +1,99 @@ +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 ShieldCheck from '@lucide/svelte/icons/shield-check'; +import FlaskConical from '@lucide/svelte/icons/flask-conical'; +import Gavel from '@lucide/svelte/icons/gavel'; +import Link2 from '@lucide/svelte/icons/link-2'; +import Timer from '@lucide/svelte/icons/timer'; +import UserPlus from '@lucide/svelte/icons/user-plus'; +import Activity from '@lucide/svelte/icons/activity'; + +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: 'Epochs', href: '/epochs', icon: Timer, keywords: 'protocol parameters params' }, + { label: 'Addresses', href: '/addresses', icon: Wallet, match: ['/stake'], keywords: 'wallet balance utxo' }, + { label: 'Assets', href: '/assets', icon: Coins, keywords: 'tokens nft policy mint' }, + { label: 'Script monitor', href: '/scripts/monitor', icon: Activity, keywords: 'script lab julc plutus live re-run debugger budget' }, + { label: 'Validator lab', href: '/scripts/lab', icon: FlaskConical, keywords: 'java julc compile validator test utxo' } + ] + }, + { + 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, keywords: 'delegated representatives' }, + { label: 'DRep activity', href: '/governance/drep-registrations', icon: UserPlus, match: ['/governance/drep-'], keywords: 'drep registrations updates retirements' }, + { label: 'Votes', href: '/governance/votes', icon: Vote }, + { label: 'Committee', href: '/governance/committee', icon: Gavel, keywords: 'cc constitutional' }, + { label: 'Constitution', href: '/governance/constitution', icon: Scale }, + { label: 'Guardrail check', href: '/governance/guardrail', icon: ShieldCheck, keywords: 'constitution policy script parameter change treasury' } + ] + } +]; + +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/staking/CertPage.svelte b/applications/viewer/src/lib/staking/CertPage.svelte new file mode 100644 index 00000000..420d84e0 --- /dev/null +++ b/applications/viewer/src/lib/staking/CertPage.svelte @@ -0,0 +1,108 @@ + + +{meta.title} · Yaci Viewer + + + {#snippet icon()}{/snippet} + {meta.text} + + + + +
+ {#if data.error} + + {:else} + `${r.tx_hash}-${r.cert_index}-${r.address ?? r.pool_id}`} + emptyTitle="No certificates yet" + emptyText="They appear here as transactions carrying them land on chain." + > + {#snippet cell(r, key)} + {#if key === 'subject'} + {#if isPool} + {@const id = r.pool_id_bech32 ?? poolBech32(r.pool_id)} + {shorten(id, 12, 8)} + {:else} + {shorten(r.address, 14, 8)} + {/if} + {:else if key === 'cred'} + {r.credential_type === 'SCRIPTHASH' ? 'Script' : 'Key'} + {:else if key === 'deposit'} + {#if r.deposit != null}{:else}—{/if} + {:else if key === 'pool'} + {@const id = poolBech32(r.pool_id)} + {shorten(id, 10, 6)} + {:else if key === 'pledge'} + + {:else if key === 'cost'} + + {:else if key === 'margin'} + {marginPct(r.margin)} + {:else if key === 'retire'} + {r.retirement_epoch} + {:else if key === 'epoch'} + {r.epoch} + {:else if key === 'tx'} + {#if isGenesisTx(r.tx_hash)}Genesis{:else}{shorten(r.tx_hash, 8, 6)}{/if} + {:else if key === 'block'} + {#if r.block_number >= 0}#{num(r.block_number)}{:else}—{/if} + {:else if key === 'age'} + + {/if} + {/snippet} + + + {/if} +
diff --git a/applications/viewer/src/lib/staking/certs.ts b/applications/viewer/src/lib/staking/certs.ts new file mode 100644 index 00000000..c605389c --- /dev/null +++ b/applications/viewer/src/lib/staking/certs.ts @@ -0,0 +1,54 @@ +import { api, paging } from '$lib/api/client'; + +/* The five certificate lists share one loader and one page; they differ in endpoint and columns. */ + +export type CertKind = 'stakekey-registrations' | 'stakekey-deregistrations' | 'delegations' | 'pool-registrations' | 'pool-retirements'; + +export const CERTS: Record = { + 'stakekey-registrations': { + label: 'Stake registrations', + title: 'Stake key registrations', + text: 'Stake addresses registered on chain, with the deposit paid.', + endpoint: '/stake/registrations' + }, + 'stakekey-deregistrations': { + label: 'Stake deregistrations', + title: 'Stake key deregistrations', + text: 'Stake addresses removed from the chain; the deposit goes back to the owner.', + endpoint: '/stake/deregistrations' + }, + delegations: { + label: 'Delegations', + title: 'Delegations', + text: 'Stake addresses delegating to a stake pool.', + endpoint: '/stake/delegations' + }, + 'pool-registrations': { + label: 'Pool registrations', + title: 'Pool registrations and updates', + text: 'New stake pools and changes to existing ones: pledge, cost, margin and owners.', + endpoint: '/pools/registrations' + }, + 'pool-retirements': { + label: 'Pool retirements', + title: 'Pool retirements', + text: 'Stake pools announcing the epoch they stop operating.', + endpoint: '/pools/retirements' + } +}; + +export const CERT_ORDER: CertKind[] = ['stakekey-registrations', 'stakekey-deregistrations', 'delegations', 'pool-registrations', 'pool-retirements']; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/** Yaci Store pages these lists 1-based (page 0 is treated as 1). */ +export async function loadCerts(kind: CertKind, url: URL, f: typeof fetch) { + const { page, count } = paging(url, 20); + try { + const rows = await api(CERTS[kind].endpoint, { fetch: f, params: { page, count }, timeoutMs: 25000 }); + return { kind, rows: (rows ?? []) as Any[], page, count, error: null as string | null }; + } catch (e) { + return { kind, rows: [] as Any[], page, count, error: (e as Error).message }; + } +} diff --git a/applications/viewer/src/lib/staking/distribution.ts b/applications/viewer/src/lib/staking/distribution.ts new file mode 100644 index 00000000..85c2a895 --- /dev/null +++ b/applications/viewer/src/lib/staking/distribution.ts @@ -0,0 +1,29 @@ +import { tryApi } from '$lib/api/client'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export interface PoolStake { + poolHash: string; + poolId: string; + stake: number; +} + +/* + * Stake per pool for an epoch. Yaci Store has no "all pools by stake" endpoint, so this takes the pools from the + * newest registrations and asks for each one's stake. On a devnet that is every pool; on a big network it is the + * recently registered ones, and `complete` says which. + */ +export async function poolDistribution(epoch: number, f: typeof fetch, limit = 40): Promise<{ pools: PoolStake[]; complete: boolean }> { + const regs = await tryApi('/pools/registrations', { fetch: f, params: { page: 1, count: limit }, timeoutMs: 15000 }); + if (!regs) return { pools: [], complete: false }; + const hashes = [...new Set(regs.map((r) => r.pool_id as string))]; + const stakes = await Promise.all( + hashes.map((h) => tryApi(`/epochs/${epoch}/pools/${h}/stake`, { fetch: f, timeoutMs: 8000 })) + ); + const pools = stakes + .filter((s): s is Any => !!s && Number(s.active_stake) > 0) + .map((s) => ({ poolHash: s.pool_hash, poolId: s.pool_id, stake: Number(s.active_stake) })) + .sort((a, b) => b.stake - a.stake); + return { pools, complete: regs.length < limit }; +} diff --git a/applications/viewer/src/lib/staking/pool.test.ts b/applications/viewer/src/lib/staking/pool.test.ts new file mode 100644 index 00000000..18deee6f --- /dev/null +++ b/applications/viewer/src/lib/staking/pool.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { isPoolId, marginPct, poolBech32, poolStatus } from './pool'; + +describe('pool helpers', () => { + it('converts a hex pool hash to bech32', () => { + expect(poolBech32('00beef8710427e328a29555283c74b202b40bec9a62630a9f03b1e18')).toBe('pool1qzlwlpcsgflr9z3f24fg836tyq45p0kf5cnrp20s8v0psp6tdkx'); + expect(poolBech32('pool1abc')).toBe('pool1abc'); + }); + + it('recognises pool ids', () => { + expect(isPoolId('pool1qzlwlpcsgflr9z3f24fg836tyq45p0kf5cnrp20s8v0psp6tdkx')).toBe(true); + expect(isPoolId('7301761068762f5900bde9eb7c1c15b09840285130f5b0f53606cc57')).toBe(true); + expect(isPoolId('pool1zzz')).toBe(false); + }); + + it('describes status and margin', () => { + expect(poolStatus(null, 10)).toBe('active'); + expect(poolStatus(12, 10)).toBe('retiring'); + expect(poolStatus(9, 10)).toBe('retired'); + expect(marginPct('0.02')).toBe('2%'); + expect(marginPct(0.0125)).toBe('1.25%'); + }); +}); diff --git a/applications/viewer/src/lib/staking/pool.ts b/applications/viewer/src/lib/staking/pool.ts new file mode 100644 index 00000000..f4031bff --- /dev/null +++ b/applications/viewer/src/lib/staking/pool.ts @@ -0,0 +1,33 @@ +import { bech32Encode } from '$lib/bech32'; + +/* Pool ids come as bech32 (pool1…) or as the 28-byte hex hash. Yaci Store accepts both. */ + +export function isPoolId(id: string): boolean { + return /^pool1[0-9a-z]{20,}$/.test(id) || /^[0-9a-f]{56}$/i.test(id); +} + +/** Hex pool hash → pool1… (returns the input when it isn't a hex hash). */ +export function poolBech32(id: string | null | undefined): string { + if (!id) return ''; + if (!/^[0-9a-f]{56}$/i.test(id)) return id; + const bytes = new Uint8Array(id.match(/../g)!.map((h) => parseInt(h, 16))); + return bech32Encode('pool', bytes); +} + +export type PoolStatus = 'active' | 'retiring' | 'retired'; + +export function poolStatus(retireEpoch: number | null | undefined, currentEpoch: number): PoolStatus { + if (retireEpoch == null) return 'active'; + return retireEpoch <= currentEpoch ? 'retired' : 'retiring'; +} + +/** Margin as "2%" from "0.02" or 0.02. */ +export function marginPct(m: string | number | null | undefined): string { + const v = Number(m ?? 0); + if (!Number.isFinite(v)) return '—'; + const pct = v * 100; + return `${pct % 1 === 0 ? pct.toFixed(0) : pct.toFixed(2).replace(/0+$/, '')}%`; +} + +/** Genesis certificates have no real transaction or block. */ +export const isGenesisTx = (tx: string | null | undefined) => !tx || tx === 'Genesis'; diff --git a/applications/viewer/src/lib/staking/poolMetadata.server.ts b/applications/viewer/src/lib/staking/poolMetadata.server.ts new file mode 100644 index 00000000..38bf6903 --- /dev/null +++ b/applications/viewer/src/lib/staking/poolMetadata.server.ts @@ -0,0 +1,34 @@ +import { safeGet } from '$lib/server/safe-fetch'; + +/* + * Pool metadata (CIP-6) is a small JSON document at a URL the pool operator chose, so it goes through safeGet (no + * private addresses, checked after DNS), with no redirects, a short timeout and a 4 KB cap. Only a few display + * fields are returned, and any failure just means no metadata. + */ + +export interface PoolMetadata { + name?: string; + ticker?: string; + description?: string; + homepage?: string; +} + +const clean = (v: unknown, max: number) => + typeof v === 'string' && v.trim() ? v.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, max) : undefined; + +export async function fetchPoolMetadata(raw: string | null | undefined): Promise { + if (!raw) return null; + try { + const res = await safeGet(new URL(raw), { maxBytes: 4096, timeoutMs: 3000, maxRedirects: 0 }); + const j = JSON.parse(new TextDecoder().decode(res.body)); + const homepage = clean(j.homepage, 200); + return { + name: clean(j.name, 80), + ticker: clean(j.ticker, 12), + description: clean(j.description, 300), + homepage: homepage && /^https?:\/\//i.test(homepage) ? homepage : undefined + }; + } catch { + return null; + } +} diff --git a/applications/viewer/src/lib/stores/balanceApi.ts b/applications/viewer/src/lib/stores/balanceApi.ts deleted file mode 100644 index 79723896..00000000 --- a/applications/viewer/src/lib/stores/balanceApi.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { writable } from 'svelte/store'; - -// 'auto' = not yet probed, 'balance' = /balance works, 'amounts' = fallback to /amounts -export const balanceApiMode = writable<'auto' | 'balance' | 'amounts'>('auto'); diff --git a/applications/viewer/src/lib/stores/health.ts b/applications/viewer/src/lib/stores/health.ts deleted file mode 100644 index eca82a6c..00000000 --- a/applications/viewer/src/lib/stores/health.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { writable } from 'svelte/store'; -import { env } from '$env/dynamic/public'; - -export type HealthStatus = 'unknown' | 'ok' | 'down'; - -export interface HealthState { - status: HealthStatus; - lastChecked?: number; - latencyMs?: number; - error?: string; -} - -export const health = writable({ status: 'unknown' }); - -let timer: any = null; -let inFlight = false; - -export async function checkHealth(): Promise { - if (inFlight) return; // dedupe - inFlight = true; - const started = Date.now(); - try { - const base = env.PUBLIC_INDEXER_CLIENT_BASE_URL || env.PUBLIC_INDEXER_BASE_URL; - if (!base) throw new Error('Missing PUBLIC_INDEXER_BASE_URL'); - const res = await fetch(`${base}/network`, { method: 'GET' }); - const latency = Date.now() - started; - if (res.ok) { - health.set({ status: 'ok', lastChecked: Date.now(), latencyMs: latency }); - } else { - health.set({ status: 'down', lastChecked: Date.now(), latencyMs: latency, error: `${res.status} ${res.statusText}` }); - } - } catch (e: any) { - const latency = Date.now() - started; - health.set({ status: 'down', lastChecked: Date.now(), latencyMs: latency, error: e?.message || 'Network error' }); - } finally { - inFlight = false; - } -} - -export function startHealthPolling(intervalMs = 15000) { - stopHealthPolling(); - const tick = async () => { - await checkHealth(); - timer = setTimeout(tick, intervalMs); - }; - // kick off quickly - tick(); -} - -export function stopHealthPolling() { - if (timer) { - clearTimeout(timer); - timer = null; - } -} - 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/stores/toast.ts b/applications/viewer/src/lib/stores/toast.ts deleted file mode 100644 index 1ec03f42..00000000 --- a/applications/viewer/src/lib/stores/toast.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { writable } from 'svelte/store'; - -export type ToastType = 'success' | 'info' | 'warning' | 'error'; - -export interface ToastItem { - id: number; - message: string; - type: ToastType; -} - -export const toasts = writable([]); - -let idCounter = 1; - -export function showToast(message: string, type: ToastType = 'success', timeout = 2000) { - const id = idCounter++; - const item: ToastItem = { id, message, type }; - toasts.update((list) => [...list, item]); - const timer = setTimeout(() => { - toasts.update((list) => list.filter((t) => t.id !== id)); - clearTimeout(timer); - }, timeout); -} - 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/CborTree.svelte b/applications/viewer/src/lib/tx/CborTree.svelte new file mode 100644 index 00000000..3d32126e --- /dev/null +++ b/applications/viewer/src/lib/tx/CborTree.svelte @@ -0,0 +1,125 @@ + + +
+
+ {#if expandable} + + {:else} + + {/if} + {#if label}{label}{/if} + {summary.kind} + {#if summary.value}{summary.value}{/if} + {item.start}–{item.end} +
+ {#if open && expandable} +
+ {#each children as c, i (i)}{/each} +
+ {/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..053723f1 --- /dev/null +++ b/applications/viewer/src/lib/tx/ScriptCard.svelte @@ -0,0 +1,85 @@ + + +
+
+ {#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} +
+ {#if children}{@render children()}{/if} +
diff --git a/applications/viewer/src/lib/tx/TxCborView.svelte b/applications/viewer/src/lib/tx/TxCborView.svelte new file mode 100644 index 00000000..20c993ad --- /dev/null +++ b/applications/viewer/src/lib/tx/TxCborView.svelte @@ -0,0 +1,63 @@ + + +
+
+ {#if t} + {#if t.hash === hash} + Hashes to this transaction’s id + {:else} + Hashes to {t.hash.slice(0, 12)}…, not this transaction + {/if} + {num(t.size)} bytes · {t.witnesses.vkeys.length} signature{t.witnesses.vkeys.length === 1 ? '' : 's'} · {t.witnesses.redeemers.length} redeemer{t.witnesses.redeemers.length === 1 ? '' : 's'} · {t.witnesses.scripts.length} witnessed script{t.witnesses.scripts.length === 1 ? '' : 's'}{t.isValid ? '' : ' · marked invalid'} + {:else} + Couldn’t decode: {decoded.error} + {/if} + + + + +
+ {#if t} +
+ +
+ {/if} +
+ Hex +
{cbor}
+
+
diff --git a/applications/viewer/src/lib/tx/UtxoCard.svelte b/applications/viewer/src/lib/tx/UtxoCard.svelte new file mode 100644 index 00000000..bdd47eb7 --- /dev/null +++ b/applications/viewer/src/lib/tx/UtxoCard.svelte @@ -0,0 +1,90 @@ + + +
(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/balance.test.ts b/applications/viewer/src/lib/tx/balance.test.ts new file mode 100644 index 00000000..fa33f0ee --- /dev/null +++ b/applications/viewer/src/lib/tx/balance.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; +import { balanceOfPages, mergeAmounts, utxoBalance } from './balance'; +import type { Utxo } from './types'; + +const POLICY = 'a0028f350aaabe0545fdcb56b039bfb08e4bb4d8c4d7c3c7d481c235'; +const utxo = (i: number, lovelace: number | string, assets: Record = {}): Utxo => ({ + tx_hash: i.toString(16).padStart(64, '0'), + output_index: 0, + address: 'addr_test1', + amount: [{ unit: 'lovelace', quantity: String(lovelace) }, ...Object.entries(assets).map(([unit, quantity]) => ({ unit, quantity }))] +}); +const page = (from: number, n: number) => Array.from({ length: n }, (_, i) => utxo(from + i, 1_000_000)); + +describe('mergeAmounts', () => { + it('adds up lovelace and native assets by unit, lovelace first', () => { + const merged = mergeAmounts([utxo(1, 2_000_000, { [POLICY + '01']: 5 }).amount, utxo(2, 3_000_000, { [POLICY + '01']: 7, [POLICY + '02']: 1 }).amount]); + expect(merged).toEqual([ + { unit: 'lovelace', quantity: '5000000' }, + { unit: POLICY + '01', quantity: '12' }, + { unit: POLICY + '02', quantity: '1' } + ]); + }); + + it('keeps quantities past 2^53 exact', () => { + const big = '9223372036854775807'; + expect(mergeAmounts([utxo(1, 1, { [POLICY]: big }).amount, utxo(2, 1, { [POLICY]: 1 }).amount])[1].quantity).toBe('9223372036854775808'); + }); + + it('handles no outputs', () => { + expect(mergeAmounts([])).toEqual([]); + }); +}); + +describe('balanceOfPages', () => { + it('is exact when the last page is short', () => { + const b = balanceOfPages([page(0, 100), page(100, 42)], 100); + expect(b).toMatchObject({ exact: true, utxos: 142 }); + expect(b.amounts[0].quantity).toBe('142000000'); + }); + + it('is a lower bound when the last page is full', () => { + expect(balanceOfPages([page(0, 100), page(100, 100)], 100)).toMatchObject({ exact: false, utxos: 200 }); + }); + + it('is exact for an empty address', () => { + expect(balanceOfPages([[]], 100)).toEqual({ amounts: [], exact: true, utxos: 0 }); + }); +}); + +describe('utxoBalance', () => { + it('stops at the first short page', async () => { + const asked: number[] = []; + const b = await utxoBalance(async (p) => (asked.push(p), p < 3 ? page(p * 100, 100) : page(300, 7)), { pageSize: 100, maxPages: 5 }); + expect(asked).toEqual([1, 2, 3]); + expect(b).toMatchObject({ exact: true, utxos: 207 }); + }); + + it('stops at maxPages and reports a lower bound', async () => { + const asked: number[] = []; + const b = await utxoBalance(async (p) => (asked.push(p), page(p * 100, 100)), { pageSize: 100, maxPages: 5 }); + expect(asked).toEqual([1, 2, 3, 4, 5]); + expect(b).toMatchObject({ exact: false, utxos: 500 }); + }); + + it('keeps the pages read when a later one fails', async () => { + const b = await utxoBalance( + async (p) => { + if (p === 3) throw new Error('timeout'); + return page(p * 100, 100); + }, + { pageSize: 100, maxPages: 5 } + ); + expect(b).toMatchObject({ exact: false, utxos: 200 }); + }); + + it('fails when the first page does', async () => { + await expect(utxoBalance(async () => Promise.reject(new Error('timeout')), { pageSize: 100, maxPages: 5 })).rejects.toThrow('timeout'); + }); + + it('uses a first page it is given and reads pages one at a time', async () => { + let inFlight = 0; + let most = 0; + const asked: number[] = []; + await utxoBalance( + async (p) => { + asked.push(p); + most = Math.max(most, ++inFlight); + await new Promise((r) => setTimeout(r, 1)); + inFlight--; + return p < 3 ? page(p * 100, 100) : []; + }, + { pageSize: 100, maxPages: 5, first: page(0, 100) } + ); + expect(asked).toEqual([2, 3]); + expect(most).toBe(1); + }); +}); diff --git a/applications/viewer/src/lib/tx/balance.ts b/applications/viewer/src/lib/tx/balance.ts new file mode 100644 index 00000000..2be710a7 --- /dev/null +++ b/applications/viewer/src/lib/tx/balance.ts @@ -0,0 +1,48 @@ +import type { Amount, Utxo } from './types'; + +/* + * An address's balance summed from its unspent outputs, for stores where /addresses/{a}/balance isn't available and + * /addresses/{a}/amounts is too slow for busy addresses. Pages through /utxos (newest first) up to a limit: when a + * page comes back short the sum is exact, otherwise it is a lower bound over the outputs read. + */ + +export interface UtxoBalance { + amounts: Amount[]; + /** Exact when every unspent output was read; otherwise a lower bound. */ + exact: boolean; + /** Unspent outputs summed. */ + utxos: number; +} + +/** Adds up amounts by unit, lovelace first, with quantities as strings (they can pass 2^53). */ +export function mergeAmounts(lists: Amount[][]): Amount[] { + const totals = new Map(); + for (const list of lists) for (const a of list) totals.set(a.unit, (totals.get(a.unit) ?? 0n) + BigInt(a.quantity)); + return [...totals] + .filter(([, q]) => q !== 0n) + .sort(([a], [b]) => (a === 'lovelace' ? -1 : b === 'lovelace' ? 1 : 0)) + .map(([unit, q]) => ({ unit, quantity: q.toString() })); +} + +/** The balance of the pages read so far: exact once a page holds fewer rows than the page size. */ +export function balanceOfPages(pages: Utxo[][], pageSize: number): UtxoBalance { + const rows = pages.flat(); + const last = pages[pages.length - 1]; + return { amounts: mergeAmounts(rows.map((u) => u.amount ?? [])), exact: !last || last.length < pageSize, utxos: rows.length }; +} + +/** + * Reads pages one after another (never in parallel, to spare the store) until a short page or `maxPages`. If a page + * after the first fails, the pages read so far give a lower bound. `first` is page 1 when the caller already has it. + */ +export async function utxoBalance(fetchPage: (page: number) => Promise, opts: { pageSize: number; maxPages: number; first?: Utxo[] }): Promise { + const pages: Utxo[][] = [opts.first ?? (await fetchPage(1))]; + while (pages.length < opts.maxPages && pages[pages.length - 1].length >= opts.pageSize) { + try { + pages.push(await fetchPage(pages.length + 1)); + } catch { + return { ...balanceOfPages(pages, opts.pageSize), exact: false }; + } + } + return balanceOfPages(pages, opts.pageSize); +} diff --git a/applications/viewer/src/lib/tx/cbor-item.ts b/applications/viewer/src/lib/tx/cbor-item.ts new file mode 100644 index 00000000..cc04e52a --- /dev/null +++ b/applications/viewer/src/lib/tx/cbor-item.ts @@ -0,0 +1,131 @@ +/* + * A general CBOR reader for Cardano transactions. Unlike $lib/cbor (Plutus data only), it reads every major type and + * keeps each item's byte range, so hashes can be taken over the exact bytes (transaction body, datums, scripts) and + * a CBOR view can show what each field encodes. + */ + +export type CborItem = + | { type: 'uint' | 'nint'; value: bigint; start: number; end: number } + | { type: 'bytes'; value: Uint8Array; start: number; end: number; indefinite?: boolean } + | { type: 'text'; value: string; start: number; end: number } + | { type: 'array'; items: CborItem[]; start: number; end: number; indefinite?: boolean } + | { type: 'map'; entries: { k: CborItem; v: CborItem }[]; start: number; end: number; indefinite?: boolean } + | { type: 'tag'; tag: number; item: CborItem; start: number; end: number } + | { type: 'simple'; value: boolean | null | undefined | number; start: number; end: number } + | { type: 'float'; value: number; start: number; end: number }; + +export const hexToBytes = (hex: string) => { + const clean = hex.trim().toLowerCase(); + if (!/^([0-9a-f]{2})*$/.test(clean)) throw new Error('Not hex'); + return new Uint8Array((clean.match(/../g) ?? []).map((h) => parseInt(h, 16))); +}; +export const bytesToHex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); + +class Reader { + pos = 0; + constructor(readonly b: Uint8Array) {} + byte() { + if (this.pos >= this.b.length) throw new Error(`Unexpected end of CBOR at byte ${this.pos}`); + return this.b[this.pos++]; + } + take(n: number) { + if (this.pos + n > this.b.length) throw new Error(`Unexpected end of CBOR at byte ${this.pos}`); + const out = this.b.subarray(this.pos, this.pos + n); + this.pos += n; + return out; + } + head() { + const ib = this.byte(); + const major = ib >> 5; + const info = ib & 0x1f; + if (info < 24) return { major, info, arg: BigInt(info) as bigint | null }; + if (info === 31) return { major, info, arg: null }; + 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} at byte ${this.pos - 1}`); + let v = 0n; + for (const x of this.take(n)) v = (v << 8n) | BigInt(x); + return { major, info, arg: v as bigint | null }; + } +} + +function halfFloat(h: number) { + const exp = (h >> 10) & 0x1f; + const mant = h & 0x3ff; + const v = exp === 0 ? mant * 2 ** -24 : exp === 31 ? (mant ? NaN : Infinity) : (mant + 1024) * 2 ** (exp - 25); + return h & 0x8000 ? -v : v; +} + +function read(r: Reader, depth: number): CborItem { + if (depth > 256) throw new Error('CBOR nested too deeply'); + const start = r.pos; + const { major, info, arg } = r.head(); + const count = (each: () => void) => { + if (arg === null) { + while (r.b[r.pos] !== 0xff) each(); + r.pos++; + } else for (let i = 0n; i < arg; i++) each(); + }; + switch (major) { + case 0: + return { type: 'uint', value: arg ?? 0n, start, end: r.pos }; + case 1: + return { type: 'nint', value: -1n - (arg ?? 0n), start, end: r.pos }; + case 2: + case 3: { + let bytes: Uint8Array; + if (arg !== null) bytes = r.take(Number(arg)).slice(); + else { + const parts: Uint8Array[] = []; + count(() => { + const h = r.head(); + if (h.major !== major || h.arg === null) throw new Error('Bad chunk in an indefinite string'); + parts.push(r.take(Number(h.arg))); + }); + bytes = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let o = 0; + for (const p of parts) (bytes.set(p, o), (o += p.length)); + } + return major === 2 ? { type: 'bytes', value: bytes, start, end: r.pos, ...(arg === null ? { indefinite: true } : {}) } : { type: 'text', value: new TextDecoder().decode(bytes), start, end: r.pos }; + } + case 4: { + const items: CborItem[] = []; + count(() => items.push(read(r, depth + 1))); + return { type: 'array', items, start, end: r.pos, ...(arg === null ? { indefinite: true } : {}) }; + } + case 5: { + const entries: { k: CborItem; v: CborItem }[] = []; + count(() => entries.push({ k: read(r, depth + 1), v: read(r, depth + 1) })); + return { type: 'map', entries, start, end: r.pos, ...(arg === null ? { indefinite: true } : {}) }; + } + case 6: { + const item = read(r, depth + 1); + return { type: 'tag', tag: Number(arg), item, start, end: r.pos }; + } + default: { + if (info === 20 || info === 21) return { type: 'simple', value: info === 21, start, end: r.pos }; + if (info === 22) return { type: 'simple', value: null, start, end: r.pos }; + if (info === 23) return { type: 'simple', value: undefined, start, end: r.pos }; + if (info === 25) return { type: 'float', value: halfFloat(Number(arg)), start, end: r.pos }; + if (info === 26 || info === 27) { + const dv = new DataView(new ArrayBuffer(8)); + if (info === 26) dv.setUint32(0, Number(arg)); + else dv.setBigUint64(0, arg ?? 0n); + return { type: 'float', value: info === 26 ? dv.getFloat32(0) : dv.getFloat64(0), start, end: r.pos }; + } + return { type: 'simple', value: Number(arg), start, end: r.pos }; + } + } +} + +/** Decode one CBOR item; `trailing` says how many bytes follow it. */ +export function decodeCbor(bytes: Uint8Array): { item: CborItem; trailing: number } { + const r = new Reader(bytes); + const item = read(r, 0); + return { item, trailing: bytes.length - r.pos }; +} + +/** The exact bytes of an item, as they appear in the input. */ +export const rawOf = (bytes: Uint8Array, item: CborItem) => bytes.subarray(item.start, item.end); + +/** Skip a set's tag 258 (Conway sets may be tagged). */ +export const untag = (item: CborItem | undefined, tag = 258) => (item?.type === 'tag' && item.tag === tag ? item.item : item); diff --git a/applications/viewer/src/lib/tx/decode.test.ts b/applications/viewer/src/lib/tx/decode.test.ts new file mode 100644 index 00000000..e3076e65 --- /dev/null +++ b/applications/viewer/src/lib/tx/decode.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import fx from '../julc/replay/fixtures/mainnet-tx-cbor.json'; +import { decodeCbor, hexToBytes } from './cbor-item'; +import { decodeTransaction } from './decode'; + +/* + * Mainnet transactions (fixtures/mainnet-tx-cbor.json, from Koios). Expected values were checked against Yaci Store: + * redeemer purposes and ex-units, script hashes, and the datum hashes of the inputs the witnessed datums belong to. + */ +const txs = fx.txs as Record; +const tx = (prefix: string) => { + const [hash, { cbor }] = Object.entries(txs).find(([h]) => h.startsWith(prefix))!; + return { hash, t: decodeTransaction(cbor) }; +}; + +describe('decodeTransaction', () => { + it('computes every fixture’s transaction id from its body bytes', () => { + for (const [hash, { cbor }] of Object.entries(txs)) expect(decodeTransaction(cbor).hash).toBe(hash); + }); + + it('reads a Conway batcher transaction: reference inputs, withdraw-zero, map-form redeemers', () => { + const { t } = tx('a678733d'); + expect(t.inputs).toHaveLength(3); + expect(t.referenceInputs).toHaveLength(4); + expect(t.withdrawals).toEqual([expect.objectContaining({ amount: 0n, credential: { script: true, hash: '1eae96baf29e27682ea3f815aba361a0c6059d45e4bfbe95bbd2f44a' } })]); + expect(t.withdrawals[0].address).toMatch(/^stake1/); + expect(t.witnesses.redeemers).toEqual([ + { tag: 'spend', index: 1, data: 'd87980', exUnits: { mem: 25305n, steps: 8177555n } }, + expect.objectContaining({ tag: 'spend', index: 0 }), + expect.objectContaining({ tag: 'reward', index: 0, exUnits: { mem: 1326013n, steps: 427333304n } }) + ]); + expect(t.isValid).toBe(true); + expect(t.metadata?.type).toBe('map'); + }); + + it('reads an Alonzo-era Plutus V1 transaction: legacy outputs, list-form redeemers, witnessed datums', () => { + const { t } = tx('a5554f4a'); + expect(t.witnesses.scripts).toEqual([expect.objectContaining({ language: 'V1', hash: expect.stringMatching(/^a813c752/) })]); + // The witnessed datum is the one the spent input carries (its data_hash in Yaci Store). + expect(t.witnesses.datums.map((d) => d.hash.slice(0, 8))).toEqual(['4492a520']); + expect(t.outputs[0].datum).toEqual({ kind: 'hash', hash: expect.stringMatching(/^4492a520/) }); + expect(t.outputs[0].address).toMatch(/^addr1w/); + expect(t.witnesses.redeemers).toEqual([expect.objectContaining({ tag: 'spend', index: 1, exUnits: { mem: 1110270n, steps: 454438838n } })]); + }); + + it('reads governance: committee votes, proposals with their guardrail, and Conway certificates', () => { + const vote = tx('12c22eb0').t; + expect(vote.votes.map((v) => [v.voter.kind, v.vote])).toEqual([ + ['committeeScript', 'yes'], + ['committeeScript', 'yes'] + ]); + expect(vote.witnesses.redeemers.map((r) => r.tag)).toEqual(['spend', 'vote']); + const change = tx('ab474223').t; + expect(change.proposals).toEqual([expect.objectContaining({ kind: 'parameterChange', policy: 'fa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64', deposit: 100000000000n })]); + expect(change.witnesses.scripts[0].hash).toBe('fa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64'); + const treasury = tx('6eddf4f1').t; + expect(treasury.proposals[0]).toMatchObject({ kind: 'treasuryWithdrawals', policy: 'fa24fb305126805cf2164c161d852a0e7330cf988f1fe558cf7d4a64' }); + expect(treasury.proposals[0].returnAddress).toMatch(/^stake1/); + expect(treasury.certificates).toEqual([expect.objectContaining({ kind: 'registration', deposit: 2000000n })]); + }); + + it('reads mints with negative quantities and minting redeemers', () => { + const { t } = tx('90692ee7'); + expect(t.mint).toHaveLength(1); + expect(t.witnesses.redeemers.find((r) => r.tag === 'mint')).toEqual(expect.objectContaining({ index: 0 })); + expect(t.witnesses.scripts.map((s) => s.language)).toEqual(['V1', 'V1', 'V1']); + }); + + it('decodes certificate kinds a script can witness', () => { + const h = '11'.repeat(28); + // A minimal body {0: [], 1: [], 2: 0, 4: certs}, no witnesses, valid, no auxiliary data. The certificates: vote + // delegation to abstain, stake and vote delegation (no confidence), DRep registration without an anchor. + const vd = `8309 8201581c${h} 8102`.replace(/ /g, ''); + const svd = `840a 8201581c${h} 581c${'22'.repeat(28)} 8103`.replace(/ /g, ''); + const reg = `8410 8201581c${h} 1a001e8480 f6`.replace(/ /g, ''); + const t = decodeTransaction(`84a400800180020004 83${vd}${svd}${reg} a0f5f6`.replace(/ /g, '')); + expect(t.certificates.map((c) => [c.kind, c.credential])).toEqual([ + ['voteDelegation', { script: true, hash: h }], + ['stakeVoteDelegation', { script: true, hash: h }], + ['drepRegistration', { script: true, hash: h }] + ]); + expect(t.certificates[0].drep).toEqual({ kind: 'abstain' }); + expect(t.certificates[1]).toMatchObject({ poolId: '22'.repeat(28), drep: { kind: 'noConfidence' } }); + expect(t.certificates[2]).toMatchObject({ deposit: 2000000n, anchor: null }); + }); + + it('explains input that isn’t a transaction', () => { + expect(() => decodeTransaction('zz')).toThrow(/hex/); + expect(() => decodeTransaction('d87980')).toThrow(/array/); + expect(() => decodeTransaction('8100')).toThrow(/body/); + }); +}); + +describe('decodeCbor', () => { + it('reads every major type with byte ranges', () => { + const { item, trailing } = decodeCbor(hexToBytes('a3016161029f0102ff03d8184201ff')); + expect(trailing).toBe(0); + if (item.type !== 'map') throw new Error('map'); + expect(item.entries.map((e) => e.v.type)).toEqual(['text', 'array', 'tag']); + expect(item.entries[1].v).toMatchObject({ type: 'array', indefinite: true, start: 5, end: 9 }); + expect(decodeCbor(hexToBytes('f93c00')).item).toMatchObject({ type: 'float', value: 1 }); + expect(decodeCbor(hexToBytes('3903e7')).item).toMatchObject({ type: 'nint', value: -1000n }); + }); +}); diff --git a/applications/viewer/src/lib/tx/decode.ts b/applications/viewer/src/lib/tx/decode.ts new file mode 100644 index 00000000..f997234c --- /dev/null +++ b/applications/viewer/src/lib/tx/decode.ts @@ -0,0 +1,518 @@ +import { bech32Encode } from '$lib/bech32'; +import { blake2b } from '$lib/governance/blake2b'; +import { bytesToHex, decodeCbor, rawOf, untag, type CborItem } from './cbor-item'; + +/* + * Decode a Cardano transaction (Alonzo to Conway encodings) from its CBOR: [body, witness set, is valid, auxiliary + * data]. Hashes come from the exact bytes: the transaction id from the body, datum and script hashes from the + * witnesses, so the result can be checked against the chain. Fields follow the ledger's CDDL keys. + */ + +export type Language = 'native' | 'V1' | 'V2' | 'V3'; +export type RedeemerTag = 'spend' | 'mint' | 'cert' | 'reward' | 'vote' | 'propose'; + +export interface TxRef { + txHash: string; + index: number; +} +export interface Asset { + policyId: string; + name: string; + quantity: bigint; +} +export interface TxValue { + lovelace: bigint; + assets: Asset[]; +} +export type TxDatum = { kind: 'none' } | { kind: 'hash'; hash: string } | { kind: 'inline'; cbor: string; hash: string }; +export interface Script { + language: Language; + /** Native: the script's CBOR. Plutus: the script bytes as witnessed (CBOR-wrapped flat), as julc takes them. */ + cbor: string; + hash: string; +} +export interface TxOutput { + address: string; + addressHex: string; + value: TxValue; + datum: TxDatum; + scriptRef: Script | null; +} +export interface Credential { + script: boolean; + hash: string; +} +export type DRep = { kind: 'key' | 'script'; hash: string } | { kind: 'abstain' | 'noConfidence' }; +export interface Anchor { + url: string; + hash: string; +} +export interface Certificate { + index: number; + kind: + | 'stakeRegistration' + | 'stakeDeregistration' + | 'stakeDelegation' + | 'poolRegistration' + | 'poolRetirement' + | 'registration' + | 'unregistration' + | 'voteDelegation' + | 'stakeVoteDelegation' + | 'stakeRegDelegation' + | 'voteRegDelegation' + | 'stakeVoteRegDelegation' + | 'authCommitteeHot' + | 'resignCommitteeCold' + | 'drepRegistration' + | 'drepUnregistration' + | 'drepUpdate' + | 'genesisDelegation' + | 'moveInstantaneousRewards' + | 'unknown'; + /** The stake, DRep or committee credential the certificate is about (the one a script would witness). */ + credential: Credential | null; + poolId?: string; + drep?: DRep; + deposit?: bigint; + epoch?: bigint; + hotCredential?: Credential; + anchor?: Anchor | null; + item: CborItem; +} +export interface Withdrawal { + rewardAccount: string; + address: string; + credential: Credential; + amount: bigint; +} +export type VoterKind = 'committeeKey' | 'committeeScript' | 'drepKey' | 'drepScript' | 'pool'; +export interface Vote { + voter: { kind: VoterKind; hash: string }; + action: TxRef; + vote: 'no' | 'yes' | 'abstain'; + anchor: Anchor | null; +} +export type GovActionKind = 'parameterChange' | 'hardFork' | 'treasuryWithdrawals' | 'noConfidence' | 'updateCommittee' | 'newConstitution' | 'info'; +export interface Proposal { + deposit: bigint; + rewardAccount: string; + returnAddress: string; + kind: GovActionKind; + /** The guardrail (policy) script of a parameter change or treasury withdrawal. */ + policy: string | null; + action: CborItem; + anchor: Anchor | null; +} +export interface Redeemer { + tag: RedeemerTag; + index: number; + data: string; + exUnits: { mem: bigint; steps: bigint }; +} +export interface DecodedTx { + hash: string; + size: number; + isValid: boolean; + inputs: TxRef[]; + referenceInputs: TxRef[]; + collateral: TxRef[]; + outputs: TxOutput[]; + collateralReturn: TxOutput | null; + totalCollateral: bigint | null; + fee: bigint; + ttl: bigint | null; + validityStart: bigint | null; + mint: Asset[]; + certificates: Certificate[]; + withdrawals: Withdrawal[]; + requiredSigners: string[]; + networkId: number | null; + auxiliaryDataHash: string | null; + scriptDataHash: string | null; + votes: Vote[]; + proposals: Proposal[]; + currentTreasury: bigint | null; + donation: bigint | null; + witnesses: { + vkeys: { vkey: string; signature: string; keyHash: string }[]; + bootstrap: number; + scripts: Script[]; + datums: { hash: string; cbor: string }[]; + redeemers: Redeemer[]; + }; + /** Transaction metadata (auxiliary data label map), if any. */ + metadata: CborItem | null; + /** The decoded CBOR, for a byte-level view. */ + root: CborItem; + bytes: Uint8Array; +} + +const TAGS: RedeemerTag[] = ['spend', 'mint', 'cert', 'reward', 'vote', 'propose']; +const LANG_TAG: Record = { native: 0, V1: 1, V2: 2, V3: 3 }; + +class TxDecodeError extends Error {} +const fail = (msg: string): never => { + throw new TxDecodeError(msg); +}; + +const hex = (b: Uint8Array) => bytesToHex(b); +const hash224 = (b: Uint8Array) => hex(blake2b(b, 28)); +const hash256 = (b: Uint8Array) => hex(blake2b(b, 32)); + +function uint(i: CborItem | undefined, what: string): bigint { + if (i?.type === 'uint') return i.value; + return fail(`${what}: expected an unsigned integer`); +} +function int(i: CborItem | undefined, what: string): bigint { + if (i?.type === 'uint' || i?.type === 'nint') return i.value; + return fail(`${what}: expected an integer`); +} +function bytes(i: CborItem | undefined, what: string): Uint8Array { + if (i?.type === 'bytes') return i.value; + return fail(`${what}: expected bytes`); +} +function arr(i: CborItem | undefined, what: string): CborItem[] { + const x = untag(i); + if (x?.type === 'array') return x.items; + return fail(`${what}: expected an array`); +} +function map(i: CborItem | undefined, what: string): { k: CborItem; v: CborItem }[] { + const x = untag(i); + if (x?.type === 'map') return x.entries; + return fail(`${what}: expected a map`); +} +/** Fields of a map keyed by small unsigned integers. */ +function fields(i: CborItem | undefined, what: string): Map { + return new Map(map(i, what).map((e) => [Number(uint(e.k, `${what} key`)), e.v])); +} + +// --- addresses --- + +const B58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'; +function base58(b: Uint8Array) { + let n = 0n; + for (const x of b) n = (n << 8n) | BigInt(x); + let out = ''; + while (n > 0n) { + out = B58[Number(n % 58n)] + out; + n /= 58n; + } + for (const x of b) { + if (x !== 0) break; + out = '1' + out; + } + return out; +} + +/** A Shelley address in bech32, a Byron address in base58. */ +export function addressText(b: Uint8Array): string { + const type = b[0] >> 4; + if (type === 8) return base58(b); + const testnet = (b[0] & 0x0f) === 0; + if (type === 14 || type === 15) return bech32Encode(testnet ? 'stake_test' : 'stake', b); + return bech32Encode(testnet ? 'addr_test' : 'addr', b); +} + +/** The payment credential of a Shelley address (null for Byron and reward addresses). */ +export function paymentCredential(addressHex: string): Credential | null { + const header = parseInt(addressHex.slice(0, 2), 16); + const type = header >> 4; + if (type > 7 || addressHex.length < 58) return null; + return { script: (type & 1) === 1, hash: addressHex.slice(2, 58) }; +} + +function rewardAccount(b: Uint8Array): { address: string; credential: Credential } { + return { address: addressText(b), credential: { script: (b[0] & 0x10) !== 0, hash: hex(b.subarray(1, 29)) } }; +} + +// --- values, outputs, scripts --- + +function multiAsset(i: CborItem, what: string, signed = false): Asset[] { + const out: Asset[] = []; + for (const p of map(i, what)) { + const policyId = hex(bytes(p.k, `${what} policy`)); + for (const a of map(p.v, `${what} assets`)) out.push({ policyId, name: hex(bytes(a.k, `${what} asset name`)), quantity: signed ? int(a.v, what) : uint(a.v, what) }); + } + return out; +} + +function value(i: CborItem | undefined): TxValue { + if (i?.type === 'uint') return { lovelace: i.value, assets: [] }; + const [coin, ma] = arr(i, 'value'); + return { lovelace: uint(coin, 'value coin'), assets: ma ? multiAsset(ma, 'value') : [] }; +} + +function script(language: Language, raw: Uint8Array): Script { + return { language, cbor: hex(raw), hash: hash224(new Uint8Array([LANG_TAG[language], ...raw])) }; +} + +/** A reference script: tag 24 around the CBOR of [0, native] | [1, v1 bytes] | [2, v2 bytes] | [3, v3 bytes]. */ +function scriptRef(i: CborItem): Script { + const wrapped = i.type === 'tag' && i.tag === 24 ? bytes(i.item, 'script ref') : fail('script ref: expected tag 24'); + const [lang, body] = arr(decodeCbor(wrapped).item, 'script ref'); + const l = Number(uint(lang, 'script ref language')); + if (l === 0) return script('native', rawOf(wrapped, body)); + return script((['V1', 'V2', 'V3'] as const)[l - 1] ?? fail(`script ref: unknown language ${l}`), bytes(body, 'script ref')); +} + +function output(i: CborItem): TxOutput { + let addr: Uint8Array; + let val: CborItem | undefined; + let datum: TxDatum = { kind: 'none' }; + let ref: Script | null = null; + if (i.type === 'array') { + // Legacy (Shelley to Alonzo): [address, value, ? datum hash] + addr = bytes(i.items[0], 'output address'); + val = i.items[1]; + if (i.items[2]) datum = { kind: 'hash', hash: hex(bytes(i.items[2], 'output datum hash')) }; + } else { + const f = fields(i, 'output'); + addr = bytes(f.get(0), 'output address'); + val = f.get(1); + const d = f.get(2); + if (d) { + const [kind, body] = arr(d, 'output datum'); + if (uint(kind, 'output datum kind') === 0n) datum = { kind: 'hash', hash: hex(bytes(body, 'output datum hash')) }; + else { + const inline = body.type === 'tag' && body.tag === 24 ? bytes(body.item, 'inline datum') : fail('inline datum: expected tag 24'); + datum = { kind: 'inline', cbor: hex(inline), hash: hash256(inline) }; + } + } + if (f.get(3)) ref = scriptRef(f.get(3)!); + } + return { address: addressText(addr), addressHex: hex(addr), value: value(val), datum, scriptRef: ref }; +} + +const refs = (i: CborItem | undefined, what: string): TxRef[] => + i ? arr(i, what).map((x) => { + const [h, ix] = arr(x, what); + return { txHash: hex(bytes(h, `${what} tx id`)), index: Number(uint(ix, `${what} index`)) }; + }) : []; + +// --- certificates, votes, proposals --- + +function credential(i: CborItem, what: string): Credential { + const [kind, h] = arr(i, what); + return { script: uint(kind, what) === 1n, hash: hex(bytes(h, what)) }; +} +function drep(i: CborItem): DRep { + const [kind, h] = arr(i, 'drep'); + const k = Number(uint(kind, 'drep')); + return k === 0 ? { kind: 'key', hash: hex(bytes(h, 'drep')) } : k === 1 ? { kind: 'script', hash: hex(bytes(h, 'drep')) } : { kind: k === 2 ? 'abstain' : 'noConfidence' }; +} +function anchor(i: CborItem | undefined): Anchor | null { + if (!i || i.type === 'simple') return null; + const [url, h] = arr(i, 'anchor'); + return { url: url.type === 'text' ? url.value : '', hash: hex(bytes(h, 'anchor hash')) }; +} + +function certificate(i: CborItem, index: number): Certificate { + const c = arr(i, 'certificate'); + const t = Number(uint(c[0], 'certificate type')); + const base = { index, item: i, credential: null as Credential | null }; + const cred = () => credential(c[1], 'certificate credential'); + switch (t) { + case 0: + return { ...base, kind: 'stakeRegistration', credential: cred() }; + case 1: + return { ...base, kind: 'stakeDeregistration', credential: cred() }; + case 2: + return { ...base, kind: 'stakeDelegation', credential: cred(), poolId: hex(bytes(c[2], 'pool id')) }; + case 3: + return { ...base, kind: 'poolRegistration', poolId: hex(bytes(c[1], 'pool id')) }; + case 4: + return { ...base, kind: 'poolRetirement', poolId: hex(bytes(c[1], 'pool id')), epoch: uint(c[2], 'epoch') }; + case 5: + return { ...base, kind: 'genesisDelegation' }; + case 6: + return { ...base, kind: 'moveInstantaneousRewards' }; + case 7: + return { ...base, kind: 'registration', credential: cred(), deposit: uint(c[2], 'deposit') }; + case 8: + return { ...base, kind: 'unregistration', credential: cred(), deposit: uint(c[2], 'deposit') }; + case 9: + return { ...base, kind: 'voteDelegation', credential: cred(), drep: drep(c[2]) }; + case 10: + return { ...base, kind: 'stakeVoteDelegation', credential: cred(), poolId: hex(bytes(c[2], 'pool id')), drep: drep(c[3]) }; + case 11: + return { ...base, kind: 'stakeRegDelegation', credential: cred(), poolId: hex(bytes(c[2], 'pool id')), deposit: uint(c[3], 'deposit') }; + case 12: + return { ...base, kind: 'voteRegDelegation', credential: cred(), drep: drep(c[2]), deposit: uint(c[3], 'deposit') }; + case 13: + return { ...base, kind: 'stakeVoteRegDelegation', credential: cred(), poolId: hex(bytes(c[2], 'pool id')), drep: drep(c[3]), deposit: uint(c[4], 'deposit') }; + case 14: + return { ...base, kind: 'authCommitteeHot', credential: cred(), hotCredential: credential(c[2], 'hot credential') }; + case 15: + return { ...base, kind: 'resignCommitteeCold', credential: cred(), anchor: anchor(c[2]) }; + case 16: + return { ...base, kind: 'drepRegistration', credential: cred(), deposit: uint(c[2], 'deposit'), anchor: anchor(c[3]) }; + case 17: + return { ...base, kind: 'drepUnregistration', credential: cred(), deposit: uint(c[2], 'deposit') }; + case 18: + return { ...base, kind: 'drepUpdate', credential: cred(), anchor: anchor(c[2]) }; + default: + return { ...base, kind: 'unknown' }; + } +} + +const VOTERS: VoterKind[] = ['committeeKey', 'committeeScript', 'drepKey', 'drepScript', 'pool']; +const VOTES = ['no', 'yes', 'abstain'] as const; + +function votes(i: CborItem | undefined): Vote[] { + if (!i) return []; + const out: Vote[] = []; + for (const v of map(i, 'votes')) { + const [kind, h] = arr(v.k, 'voter'); + const voter = { kind: VOTERS[Number(uint(kind, 'voter type'))] ?? fail('voter: unknown type'), hash: hex(bytes(h, 'voter')) }; + for (const a of map(v.v, 'voting procedures')) { + const [tx, ix] = arr(a.k, 'gov action id'); + const [vote, anc] = arr(a.v, 'voting procedure'); + out.push({ voter, action: { txHash: hex(bytes(tx, 'gov action tx')), index: Number(uint(ix, 'gov action index')) }, vote: VOTES[Number(uint(vote, 'vote'))] ?? fail('vote: unknown value'), anchor: anchor(anc) }); + } + } + return out; +} + +const ACTIONS: GovActionKind[] = ['parameterChange', 'hardFork', 'treasuryWithdrawals', 'noConfidence', 'updateCommittee', 'newConstitution', 'info']; + +function proposals(i: CborItem | undefined): Proposal[] { + if (!i) return []; + return arr(i, 'proposals').map((p) => { + const [deposit, account, action, anc] = arr(p, 'proposal'); + const a = arr(action, 'gov action'); + const kind = ACTIONS[Number(uint(a[0], 'gov action type'))] ?? fail('gov action: unknown type'); + const policyItem = kind === 'parameterChange' ? a[3] : kind === 'treasuryWithdrawals' ? a[2] : undefined; + const acct = bytes(account, 'reward account'); + return { + deposit: uint(deposit, 'deposit'), + rewardAccount: hex(acct), + returnAddress: addressText(acct), + kind, + policy: policyItem?.type === 'bytes' ? hex(policyItem.value) : null, + action, + anchor: anchor(anc) + }; + }); +} + +// --- witnesses --- + +/** Redeemers: a list [tag, index, data, ex units] (Alonzo, Babbage) or a map {[tag, index]: [data, ex units]} (Conway). */ +function redeemers(all: Uint8Array, i: CborItem | undefined): Redeemer[] { + if (!i) return []; + const one = (tag: CborItem, index: CborItem, data: CborItem, units: CborItem): Redeemer => { + const [mem, steps] = arr(units, 'ex units'); + return { tag: TAGS[Number(uint(tag, 'redeemer tag'))] ?? fail('redeemer: unknown tag'), index: Number(uint(index, 'redeemer index')), data: hex(rawOf(all, data)), exUnits: { mem: uint(mem, 'mem'), steps: uint(steps, 'steps') } }; + }; + const x = untag(i); + if (x?.type === 'map') + return x.entries.map((e) => { + const [tag, index] = arr(e.k, 'redeemer key'); + const [data, units] = arr(e.v, 'redeemer value'); + return one(tag, index, data, units); + }); + return arr(x, 'redeemers').map((r) => { + const [tag, index, data, units] = arr(r, 'redeemer'); + return one(tag, index, data, units); + }); +} + +function witnesses(all: Uint8Array, i: CborItem | undefined): DecodedTx['witnesses'] { + const f = i ? fields(i, 'witness set') : new Map(); + const list = (k: number) => (f.get(k) ? arr(f.get(k), `witness ${k}`) : []); + const scripts: Script[] = [ + ...list(1).map((s) => script('native', rawOf(all, s))), + ...list(3).map((s) => script('V1', bytes(s, 'plutus v1 script'))), + ...list(6).map((s) => script('V2', bytes(s, 'plutus v2 script'))), + ...list(7).map((s) => script('V3', bytes(s, 'plutus v3 script'))) + ]; + return { + vkeys: list(0).map((w) => { + const [vkey, sig] = arr(w, 'vkey witness'); + const k = bytes(vkey, 'vkey'); + return { vkey: hex(k), signature: hex(bytes(sig, 'signature')), keyHash: hash224(k) }; + }), + bootstrap: list(2).length, + scripts, + datums: list(4).map((d) => { + const raw = rawOf(all, d); + return { hash: hash256(raw), cbor: hex(raw) }; + }), + redeemers: redeemers(all, f.get(5)) + }; +} + +function metadataOf(i: CborItem | undefined): CborItem | null { + if (!i || i.type === 'simple') return null; + if (i.type === 'map') return i; // Shelley: the metadata map itself + if (i.type === 'array') return i.items[0] ?? null; // Shelley-MA: [metadata, scripts] + if (i.type === 'tag' && i.tag === 259) return fields(i.item, 'auxiliary data').get(0) ?? null; // Alonzo: {0: metadata, …} + return null; +} + +/** + * The stored CBOR hex when it is a whole transaction (a CBOR array: body, witnesses, …), else null. Yaci Store 2.0.x + * saves only the body (a map) even with store.transaction.save-cbor, which can neither be replayed nor shown as the + * transaction, so it counts as no CBOR. + */ +export function wholeTxHex(hex: string | null | undefined): string | null { + if (!hex || !/^([0-9a-f]{2})+$/i.test(hex)) return null; + const major = parseInt(hex.slice(0, 2), 16) >> 5; + return major === 4 ? hex : null; +} + +/** Decode a transaction from CBOR (hex or bytes). Throws with a readable message when it isn't one. */ +export function decodeTransaction(input: string | Uint8Array): DecodedTx { + const all = typeof input === 'string' ? new Uint8Array((input.trim().toLowerCase().match(/../g) ?? []).map((h) => parseInt(h, 16))) : input; + if (typeof input === 'string' && !/^([0-9a-f]{2})+$/i.test(input.trim())) fail('The transaction must be CBOR hex.'); + const { item: root, trailing } = decodeCbor(all); + if (trailing) fail(`${trailing} unexpected bytes after the transaction.`); + const parts = root.type === 'array' ? root.items : fail('A transaction is a CBOR array [body, witnesses, …].'); + if (parts.length < 3) fail('A transaction has a body, witnesses and validity (or auxiliary data).'); + { + const bodyItem = parts[0]; + const body = fields(bodyItem, 'transaction body'); + // Mary-era transactions are [body, witnesses, auxiliary data]; Alonzo on add is_valid as the third element. + const hasValidity = parts.length === 4; + const isValid = hasValidity ? parts[2].type === 'simple' && parts[2].value === true : true; + const aux = hasValidity ? parts[3] : parts[2]; + const opt = (k: number, f: (i: CborItem) => bigint) => (body.get(k) ? f(body.get(k)!) : null); + return { + hash: hash256(rawOf(all, bodyItem)), + size: all.length, + isValid, + inputs: refs(body.get(0), 'inputs'), + outputs: body.get(1) ? arr(body.get(1), 'outputs').map(output) : [], + fee: uint(body.get(2), 'fee'), + ttl: opt(3, (i) => uint(i, 'ttl')), + certificates: body.get(4) ? arr(body.get(4), 'certificates').map(certificate) : [], + withdrawals: body.get(5) + ? map(body.get(5), 'withdrawals').map((w) => { + const b = bytes(w.k, 'reward account'); + return { rewardAccount: hex(b), ...rewardAccount(b), amount: uint(w.v, 'withdrawal') }; + }) + : [], + auxiliaryDataHash: body.get(7) ? hex(bytes(body.get(7), 'auxiliary data hash')) : null, + validityStart: opt(8, (i) => uint(i, 'validity start')), + mint: body.get(9) ? multiAsset(body.get(9)!, 'mint', true) : [], + scriptDataHash: body.get(11) ? hex(bytes(body.get(11), 'script data hash')) : null, + collateral: refs(body.get(13), 'collateral'), + requiredSigners: body.get(14) ? arr(body.get(14), 'required signers').map((s) => hex(bytes(s, 'required signer'))) : [], + networkId: body.get(15) ? Number(uint(body.get(15), 'network id')) : null, + collateralReturn: body.get(16) ? output(body.get(16)!) : null, + totalCollateral: opt(17, (i) => uint(i, 'total collateral')), + referenceInputs: refs(body.get(18), 'reference inputs'), + votes: votes(body.get(19)), + proposals: proposals(body.get(20)), + currentTreasury: opt(21, (i) => uint(i, 'treasury')), + donation: opt(22, (i) => uint(i, 'donation')), + witnesses: witnesses(all, parts[1]), + metadata: metadataOf(aux), + root, + bytes: all + }; + } +} + +export { TxDecodeError }; 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/load.server.test.ts b/applications/viewer/src/lib/tx/load.server.test.ts new file mode 100644 index 00000000..e76b8e1d --- /dev/null +++ b/applications/viewer/src/lib/tx/load.server.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@sveltejs/kit', () => ({ error: (status: number, message: string) => Promise.reject({ status, message }) })); + +const { collateralReturn } = await import('./load.server'); +const { wholeTxHex } = await import('./decode'); + +describe('collateralReturn', () => { + it('turns the store collateral return into a UTxO like the outputs', () => { + const r = collateralReturn({ address: 'addr1x', amounts: [{ unit: 'lovelace', quantity: '4500000' }], dataHash: 'dh' }, 'ab'.repeat(32), 2); + expect(r).toMatchObject({ tx_hash: 'ab'.repeat(32), output_index: 2, address: 'addr1x', amount: [{ unit: 'lovelace', quantity: '4500000' }], data_hash: 'dh' }); + }); + + it('keeps a collateral return that is already shaped like a UTxO', () => { + const r = collateralReturn({ tx_hash: 'cd', output_index: 0, address: 'a', amount: [{ unit: 'lovelace', quantity: '1' }] }, 'ab', 5); + expect(r).toMatchObject({ tx_hash: 'cd', output_index: 0, amount: [{ unit: 'lovelace', quantity: '1' }] }); + }); +}); + +describe('wholeTxHex', () => { + it('keeps a whole transaction (a CBOR array) and drops a bare body (a map)', () => { + expect(wholeTxHex('84a400')).toBe('84a400'); + expect(wholeTxHex('a40081')).toBeNull(); + expect(wholeTxHex('zz')).toBeNull(); + expect(wholeTxHex(null)).toBeNull(); + }); +}); diff --git a/applications/viewer/src/lib/tx/load.server.ts b/applications/viewer/src/lib/tx/load.server.ts new file mode 100644 index 00000000..2d48d753 --- /dev/null +++ b/applications/viewer/src/lib/tx/load.server.ts @@ -0,0 +1,76 @@ +import { error } from '@sveltejs/kit'; +import { tryApi } from '$lib/api/client'; +import { must } from '$lib/api/load'; +import { wholeTxHex } from '$lib/tx/decode'; +import type { Utxo } from '$lib/tx/types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +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; +} + +/** Everything the transaction pages show: the transaction, its script runs, metadata, withdrawals, mints and block. */ +export async function loadTransaction(id: string, fetch: typeof globalThis.fetch) { + const hash = id.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hash)) error(404, 'No transaction with that hash on this chain.'); + const tx = await must(`/txs/${hash}`, { fetch }, 'No transaction with that hash on this chain.'); + const [scripts, metadata, withdrawals, mints, block, protocol, collateralUtxo] = 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 }), + // Yaci Store's /txs/{hash} always says `invalid: false`. A failed transaction's only output on chain is its + // collateral return, which the store keeps at index outputs.length marked is_collateral_return: proof it failed. + tx.collateral_return && !tx.invalid ? tryApi(`/utxos/${hash}/${tx.outputs?.length ?? 0}`, { fetch }) : null + ]); + if (collateralUtxo?.is_collateral_return === true) tx.invalid = true; + if (tx.collateral_return) tx.collateral_return = collateralReturn(tx.collateral_return, hash, tx.outputs?.length ?? 0); + return { + tx, + // The transaction's original CBOR, when the store saves it (store.transaction.save-cbor). Streamed: the page + // doesn't wait for it. + cbor: tryApi<{ cbor_hex?: string; cborHex?: string }>(`/txs/${hash}/cbor/hex`, { fetch }).then((r) => wholeTxHex(r?.cbor_hex ?? r?.cborHex)), + 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 } + }; +} + +/** + * Yaci Store sends the collateral return in its own shape (`amounts`, camelCase, no tx_hash or index), not as a UTxO + * like the inputs and outputs. It is the output after the regular ones. + */ +export function collateralReturn(r: Any, txHash: string, index: number): Utxo { + return { + ...r, + tx_hash: r.tx_hash ?? txHash, + output_index: r.output_index ?? index, + amount: r.amount ?? r.amounts ?? [], + data_hash: r.data_hash ?? r.dataHash ?? null, + inline_datum: r.inline_datum ?? r.inlineDatum ?? null, + reference_script_hash: r.reference_script_hash ?? r.referenceScriptHash ?? null + } as Utxo; +} 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/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/AnalyticsNote.svelte b/applications/viewer/src/lib/ui/AnalyticsNote.svelte new file mode 100644 index 00000000..d7c74b75 --- /dev/null +++ b/applications/viewer/src/lib/ui/AnalyticsNote.svelte @@ -0,0 +1,29 @@ + + +{#if compact} + + Analytics · {freshness} + +{:else} +
+ + + From Yaci Store analytics ({freshness}), because the regular list is too slow here on this store + (yaci-store#1269). + {#if children}{@render children()}{/if} + +
+{/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..f80ccb25 --- /dev/null +++ b/applications/viewer/src/lib/ui/AssetChip.svelte @@ -0,0 +1,36 @@ + + + + {info.name.slice(0, 1).toUpperCase()} + {info.name} + {#if info.label}{info.label}{/if} + {#if quantity != null} + {@const q = String(quantity)} + {q.startsWith('-') || q.startsWith('+') ? q[0] + amount(q.slice(1)) : '×' + amount(q)} + {/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/Chart.svelte b/applications/viewer/src/lib/ui/Chart.svelte new file mode 100644 index 00000000..2fd47f56 --- /dev/null +++ b/applications/viewer/src/lib/ui/Chart.svelte @@ -0,0 +1,102 @@ + + +{#if points.length} +
+
{format(max)}
+ (hover = null)} + > + + + + + + + + {#if kind === 'bar'} + {#each points as p, i (i)} + + {/each} + {:else} + + + {#if hover != null} + + {/if} + {/if} + +
+ {points[0].label} + {points[points.length - 1].label} +
+ {#if hover != null} +
+ {points[hover].label} + {format(points[hover].value)} +
+ {/if} +
+{/if} 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/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/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..f0dada59 --- /dev/null +++ b/applications/viewer/src/lib/ui/Pager.svelte @@ -0,0 +1,41 @@ + + + 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/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..9ca8c6f1 --- /dev/null +++ b/applications/viewer/src/lib/ui/PlutusView.svelte @@ -0,0 +1,53 @@ + + +
+
+ {label} +
+ {#each views as [id, text] (id)} + + {/each} +
+
+ {#if view === 'typed' && typed} +
+ +
Typed by the blueprint “{schema?.source}”.
+
+ {:else 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/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/SlowNotice.svelte b/applications/viewer/src/lib/ui/SlowNotice.svelte new file mode 100644 index 00000000..2a03007d --- /dev/null +++ b/applications/viewer/src/lib/ui/SlowNotice.svelte @@ -0,0 +1,29 @@ + + +
+
+
+

{reason === 'busy' ? 'Yaci Store is busy' : 'This list is too slow on this Yaci Store'}

+

+ {#if reason === 'busy'} + The viewer is already waiting on other slow requests. Try again in a minute. + {:else if bounded} + Yaci Store gave up after 15 seconds. A second try is often quicker, once the store has this address’s data at hand. + {:else} + Yaci Store didn’t answer in time. With a long history it can take hours + (yaci-store#1269), so the viewer won’t ask again for a while. + {/if} +

+
+ {#if children}{@render children()}{/if} +
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..2ea1d6af --- /dev/null +++ b/applications/viewer/src/lib/ui/Tabs.svelte @@ -0,0 +1,62 @@ + + + + +
+ {#each tabs as t (t.id)} + {@const on = active === t.id} + select(t.id)} + > + {t.label} + {#if t.count != null} + {t.count} + {/if} + {#if on}{/if} + + {/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..7958a247 --- /dev/null +++ b/applications/viewer/src/lib/ui/TimeAgo.svelte @@ -0,0 +1,13 @@ + + + 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/resolved.svelte.ts b/applications/viewer/src/lib/ui/resolved.svelte.ts new file mode 100644 index 00000000..1c4eee7c --- /dev/null +++ b/applications/viewer/src/lib/ui/resolved.svelte.ts @@ -0,0 +1,27 @@ +/* + * The current value of a load result that may be streamed (a promise) or already there: undefined while it streams. + * Follows the source when the page's data changes, ignoring promises that settle after they were replaced. + */ +export function resolved(source: () => T | Promise): { readonly value: T | undefined } { + const isPromise = (v: unknown): v is Promise => typeof (v as Promise)?.then === 'function'; + const initial = source(); + let value = $state(isPromise(initial) ? undefined : initial); + $effect(() => { + const s = source(); + if (!isPromise(s)) { + value = s; + return; + } + value = undefined; + let current = true; + s.then((v) => current && (value = v)); + return () => { + current = false; + }; + }); + return { + get value() { + return value; + } + }; +} 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/lib/util.ts b/applications/viewer/src/lib/util.ts deleted file mode 100644 index fa5d57e0..00000000 --- a/applications/viewer/src/lib/util.ts +++ /dev/null @@ -1,55 +0,0 @@ -export function formatAda(lovelace: number): string { - const ada = lovelace / 1_000_000; - return ada.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }); -} - -export function formatLovelace(lovelace: number | undefined): string { - if (lovelace === undefined) return '0 lovelace'; - return lovelace.toLocaleString() + ' lovelace'; -} - -// Function to truncate strings with a separator -export function truncate(fullStr: string | undefined | null, strLen: number, separator?: string): string { - if (!fullStr) return ''; // Handle null or undefined input - if (fullStr.length <= strLen) return fullStr; - - separator = separator || '...'; - - var sepLen = separator.length, - charsToShow = strLen - sepLen, - frontChars = Math.ceil(charsToShow / 2), - backChars = Math.floor(charsToShow / 2); - - return fullStr.substr(0, frontChars) + - separator + - fullStr.substr(fullStr.length - backChars); -} - -// Function to format timestamp into a readable date/time string -export function getDate(timestamp: number | undefined | null): string { - if (!timestamp) return ''; // Handle null or undefined input - const date = new Date(timestamp * 1000); - const options: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: false - }; - return date.toLocaleString(undefined, options); -} - -// Function to convert lovelace to Ada (potentially formatting) -export function lovelaceToAda(num: number | undefined | null, decimalPlaces?: number): string { - if (num === undefined || num === null) return decimalPlaces ? Number(0).toFixed(decimalPlaces) : '0'; - - const ada = num / 1_000_000; - - if (decimalPlaces !== undefined) { - return ada.toFixed(decimalPlaces); - } - // Return full precision if decimalPlaces is not specified - return ada.toString(); -} \ No newline at end of file diff --git a/applications/viewer/src/lib/utils/asset.ts b/applications/viewer/src/lib/utils/asset.ts deleted file mode 100644 index a6a8b987..00000000 --- a/applications/viewer/src/lib/utils/asset.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Parse a Cardano native asset unit string into its components. - * A unit is: policyId (56 hex chars) + assetNameHex (remaining hex chars). - */ -export function parseUnit(unit: string): { policyId: string; assetNameHex: string; assetNameUtf8: string } { - const policyId = unit.slice(0, 56); - const assetNameHex = unit.slice(56); - let assetNameUtf8 = ''; - try { - const bytes = new Uint8Array(assetNameHex.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || []); - assetNameUtf8 = new TextDecoder('utf-8', { fatal: true }).decode(bytes); - } catch { - assetNameUtf8 = assetNameHex; - } - return { policyId, assetNameHex, assetNameUtf8 }; -} - -/** - * Classify an asset identifier string. - * - 'fingerprint': starts with 'asset1' - * - 'policy': exactly 56 hex chars - * - 'unit': 57+ hex chars (policy + asset name) - * - 'unknown': anything else - */ -export function classifyAssetId(input: string): 'unit' | 'policy' | 'fingerprint' | 'unknown' { - const trimmed = input.trim(); - if (trimmed.startsWith('asset1')) return 'fingerprint'; - if (/^[0-9a-fA-F]+$/.test(trimmed)) { - if (trimmed.length === 56) return 'policy'; - if (trimmed.length > 56) return 'unit'; - } - return 'unknown'; -} diff --git a/applications/viewer/src/lib/utils/format.ts b/applications/viewer/src/lib/utils/format.ts deleted file mode 100644 index a3d529f8..00000000 --- a/applications/viewer/src/lib/utils/format.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Format an amount in lovelace to ADA with proper decimal places - * @param amount Amount in lovelace - * @returns Formatted amount in ADA - */ -export function formatAmount(amount: number | string | null | undefined): string { - if (amount === null || amount === undefined) return '0'; - - const num = typeof amount === 'string' ? parseInt(amount) : amount; - const ada = num / 1_000_000; - - // Format with commas and up to 6 decimal places - return ada.toLocaleString('en-US', { - minimumFractionDigits: 0, - maximumFractionDigits: 6 - }); -} \ No newline at end of file diff --git a/applications/viewer/src/lib/utils/hash.ts b/applications/viewer/src/lib/utils/hash.ts deleted file mode 100644 index 183b3b2b..00000000 --- a/applications/viewer/src/lib/utils/hash.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Truncate a hash or address to a shorter format - * @param address The full hash or address string - * @param startLength Number of characters to show at start (default: 8) - * @param endLength Number of characters to show at end (default: 8) - * @returns Truncated string with ellipsis - */ -export function truncateAddress(address: string | null | undefined, startLength = 8, endLength = 8): string { - if (!address) return ''; - if (address.length <= startLength + endLength) return address; - - return `${address.slice(0, startLength)}...${address.slice(-endLength)}`; -} \ No newline at end of file diff --git a/applications/viewer/src/lib/variables.ts b/applications/viewer/src/lib/variables.ts deleted file mode 100644 index f5fa4015..00000000 --- a/applications/viewer/src/lib/variables.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const variables = { - -}; 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/+layout.svelte b/applications/viewer/src/routes/+layout.svelte index 11b3ca06..bd6af027 100644 --- a/applications/viewer/src/routes/+layout.svelte +++ b/applications/viewer/src/routes/+layout.svelte @@ -1,39 +1,79 @@ - - -
- -
- - - {#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..baed1f9b --- /dev/null +++ b/applications/viewer/src/routes/+page.server.ts @@ -0,0 +1,27 @@ +import { tryApi } from '$lib/api/client'; +import { networkInsights } from '$lib/server/insights'; +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. Analytics charts stream in after. */ +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, + // Charts from Yaci Store analytics, where the store has it (null otherwise). Streamed, and cached on the server. + insights: networkInsights(fetch, epoch?.epoch != null ? Number(epoch.epoch) : null).catch(() => null) + }; +}; diff --git a/applications/viewer/src/routes/+page.svelte b/applications/viewer/src/routes/+page.svelte index e625013a..9e3ad2c2 100644 --- a/applications/viewer/src/routes/+page.svelte +++ b/applications/viewer/src/routes/+page.svelte @@ -1,101 +1,139 @@ - -
-
-
-
- - - -
-

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 insights.value}{/if} +{/if} 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/adapot/+page.server.ts b/applications/viewer/src/routes/adapot/+page.server.ts index c178e67a..a588ed7d 100644 --- a/applications/viewer/src/routes/adapot/+page.server.ts +++ b/applications/viewer/src/routes/adapot/+page.server.ts @@ -1,74 +1,7 @@ -import { error } from '@sveltejs/kit'; -import { env } from '$env/dynamic/public'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -interface AdaPotData { - epoch: number; - deposits_stake: number; - fees: number; - treasury: number; - reserves: number; - circulation: number; - distributed_rewards: number; - undistributed_rewards: number; - rewards_pot: number; - pool_rewards_pot: number; -} - -export const load: PageServerLoad = async () => { - try { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - console.log('INDEXER_BASE_URL:', INDEXER_BASE_URL); - - if (!INDEXER_BASE_URL) { - throw new Error('PUBLIC_INDEXER_BASE_URL environment variable is not set'); - } - - const url = `${INDEXER_BASE_URL}/adapot/list`; - console.log('Fetching URL:', url); - - const response = await fetch(url, { - headers: { - 'Accept': 'application/json' - } - }); - - console.log('Response status:', response.status); - console.log('Response headers:', Object.fromEntries(response.headers.entries())); - - if (!response.ok) { - const errorText = await response.text(); - console.error('Error response:', errorText); - throw new Error(`Failed to fetch latest AdaPot details: ${response.status} ${response.statusText}`); - } - - const data: AdaPotData = await response.json(); - console.log('Fetched data:', data); - - // Transform the data to match our frontend expectations - const transformedData = { - epoch: data.epoch, - totalAmount: data.rewards_pot, - currentEpoch: data.epoch, - details: { - depositsStake: data.deposits_stake, - fees: data.fees, - treasury: data.treasury, - reserves: data.reserves, - circulation: data.circulation, - distributedRewards: data.distributed_rewards, - undistributedRewards: data.undistributed_rewards, - poolRewardsPot: data.pool_rewards_pot - } - }; - - return { - adapot: transformedData - }; - } catch (e) { - console.error('Error in load function:', e); - return { - error: e instanceof Error ? e.message : 'Failed to fetch AdaPot details' - }; - } +/* /adapot is kept as an alias for the current ada pots page. */ +export const load: PageServerLoad = async ({ url }) => { + redirect(308, `/adapot/current${url.search}`); }; diff --git a/applications/viewer/src/routes/adapot/+page.svelte b/applications/viewer/src/routes/adapot/+page.svelte deleted file mode 100644 index 9ab49c02..00000000 --- a/applications/viewer/src/routes/adapot/+page.svelte +++ /dev/null @@ -1,250 +0,0 @@ - - -
- {#if $page.data.error} -
- - - - {$page.data.error} -
- {:else} -
- -
-
-

Current Epoch

-

{$page.data.epoch}

-
-
- - -
-
-

Treasury

-

- {formatAda($page.data.details.treasury)} -

-
-
- - -
-
-

Reserves

-

- {formatAda($page.data.details.reserves)} -

-
-
- - -
-
-

Circulation

-

- {formatAda($page.data.details.circulation)} -

-
-
- - -
-
-

Fees

-

- {formatAda($page.data.details.fees)} -

-
-
- - -
-
-

Distributed Rewards

-

- {formatAda($page.data.details.distributedRewards)} -

-
-
- - -
-
-

Undistributed Rewards

-

- {formatAda($page.data.details.undistributedRewards)} -

-
-
- - -
-
-

Pool Rewards Pot

-

- {formatAda($page.data.details.poolRewardsPot)} -

-
-
-
- - -
-
-

Search by Epoch

-
- - -
- - {#if searchError} -
- - - - {searchError} -
- {/if} - - {#if searchResult} -
-

Search Results for Epoch {searchResult.epoch}

-
- -
-
-

Treasury

-

- {formatAda(searchResult.details.treasury)} -

-
-
- - -
-
-

Reserves

-

- {formatAda(searchResult.details.reserves)} -

-
-
- - -
-
-

Circulation

-

- {formatAda(searchResult.details.circulation)} -

-
-
- - -
-
-

Fees

-

- {formatAda(searchResult.details.fees)} -

-
-
- - -
-
-

Distributed Rewards

-

- {formatAda(searchResult.details.distributedRewards)} -

-
-
- - -
-
-

Undistributed Rewards

-

- {formatAda(searchResult.details.undistributedRewards)} -

-
-
- - -
-
-

Pool Rewards Pot

-

- {formatAda(searchResult.details.poolRewardsPot)} -

-
-
-
-
- {/if} -
-
- {/if} -
\ No newline at end of file diff --git a/applications/viewer/src/routes/adapot/components/SearchBar.svelte b/applications/viewer/src/routes/adapot/components/SearchBar.svelte deleted file mode 100644 index a87fbf26..00000000 --- a/applications/viewer/src/routes/adapot/components/SearchBar.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -
-
- -
- - -
- - -
- - -
-
- -
- - -
-
\ No newline at end of file diff --git a/applications/viewer/src/routes/adapot/current/+page.server.ts b/applications/viewer/src/routes/adapot/current/+page.server.ts index 1c7e17c5..a01b3f58 100644 --- a/applications/viewer/src/routes/adapot/current/+page.server.ts +++ b/applications/viewer/src/routes/adapot/current/+page.server.ts @@ -1,28 +1,25 @@ +import { error } from '@sveltejs/kit'; +import { ApiError, api, tryApi } from '$lib/api/client'; +import type { AdaPot } from '$lib/epochs/pots'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async () => { - try { - const baseUrl = env.PUBLIC_INDEXER_BASE_URL; - const response = await fetch(`${baseUrl}/adapot`); +/* + * The ada pots for the latest epoch, or for ?epoch=N, plus the last 30 epochs for the trend charts. + * /adapot/list pages are 1-based. A fresh devnet has no pots until its first epoch boundary. + */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const raw = url.searchParams.get('epoch'); + const epoch = raw != null && raw !== '' ? Number(raw) : null; + if (epoch != null && (!Number.isInteger(epoch) || epoch < 0)) error(404, 'Epochs are whole numbers, starting at 0.'); - if (response.status === 404) { - return { - adapot: null, - error: 'No AdaPot data available yet. AdaPot is calculated at epoch boundaries — wait for the first epoch to complete.' - }; - } + const [pot, history] = await Promise.all([ + api(epoch != null ? `/adapot/epochs/${epoch}` : '/adapot', { fetch }).then( + (p) => ({ pot: p, error: null as string | null }), + (e) => ({ pot: null, error: e instanceof ApiError && e.status === 404 ? null : (e as Error).message }) + ), + tryApi('/adapot/list', { fetch, params: { page: 1, count: 30 } }) + ]); - if (!response.ok) { - return { adapot: null, error: 'Failed to fetch current AdaPot' }; - } - - const data = await response.json(); - return { adapot: data, error: null }; - } catch (e) { - return { - adapot: null, - error: e instanceof Error ? e.message : 'An error occurred while loading AdaPot' - }; - } + const latest = history?.[0]?.epoch ?? pot.pot?.epoch ?? null; + return { pot: pot.pot, error: pot.error, requested: epoch, latest, history: history ?? [] }; }; diff --git a/applications/viewer/src/routes/adapot/current/+page.svelte b/applications/viewer/src/routes/adapot/current/+page.svelte index 49b845e1..a277d99c 100644 --- a/applications/viewer/src/routes/adapot/current/+page.svelte +++ b/applications/viewer/src/routes/adapot/current/+page.svelte @@ -1,193 +1,103 @@ -
-
- - - -

Current AdaPot Details

-
+Ada pots · Yaci Viewer - {#if error} -
- - {error} -
- {/if} + + {#snippet icon()}{/snippet} + Where all ADA sits at an epoch boundary: treasury, reserves, circulation and the rewards pot. + {#snippet actions()} + {#if pot} + {#if isLatest}Latest{/if} + Epoch {pot.epoch - 1} + Epoch {pot.epoch + 1} + {/if} + History + {/snippet} + - {#if adapot} -
- -
-
-
- - - -

Current Epoch

-
-

{adapot.epoch}

-
-
+{#if data.error} +
+{:else if !pot} +
+ + {#if data.requested != null && data.latest != null}Show the latest{/if} + +
+{:else} + + {#snippet actions()}Epoch details{/snippet} +
+ {#each bar as b (b.label)} +
+ {/each} +
+
+ {#each bar as b (b.label)} + {b.label} {percent(b.value, MAX, 2)} + {/each} +
+
- -
-
-
- - - -

Treasury

-
-
-

- {formatAda(adapot.details.treasury)} ADA -

-
-
-
+
+ {#each POTS as p (p.key)} + {#if pot[p.key] != null} +
+
{p.label}
+
+
{p.hint}
+
+ {/if} + {/each} +
- -
-
-
- - - -

Reserves

-
-
-

- {formatAda(adapot.details.reserves)} ADA -

-
-
-
- - -
-
-
- - - -

Circulation

-
-
-

- {formatAda(adapot.details.circulation)} ADA -

-
-
-
- - -
-
-
- - - -

Fees

-
-
-

- {formatAda(adapot.details.fees)} ADA -

-
-
-
- - -
-
-
- - - -

Total Rewards Pot

-
-
-

- {formatAda(adapot.totalAmount)} ADA -

-
-
-
- - -
-
-
- - - -

Distributed Rewards

-
-
-

- {formatAda(adapot.details.distributedRewards)} ADA -

-
-
-
- - -
-
-
- - - -

Undistributed Rewards

-
-
-

- {formatAda(adapot.details.undistributedRewards)} ADA -

-
-
-
- - -
-
-
- - - -

Pool Rewards Pot

-
-
-

- {formatAda(adapot.details.poolRewardsPot)} ADA -

-
-
-
-
- {:else} -
No AdaPot data available
- {/if} -
\ No newline at end of file + {#if data.history.length > 1} + + {#snippet icon()}{/snippet} +
+ + + + +
+
+ {/if} +{/if} diff --git a/applications/viewer/src/routes/adapot/list/+page.server.ts b/applications/viewer/src/routes/adapot/list/+page.server.ts index 10e891c1..3fbbb5de 100644 --- a/applications/viewer/src/routes/adapot/list/+page.server.ts +++ b/applications/viewer/src/routes/adapot/list/+page.server.ts @@ -1,78 +1,15 @@ +import { error } from '@sveltejs/kit'; +import { api, paging } from '$lib/api/client'; +import type { AdaPot } from '$lib/epochs/pots'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; - -interface ApiResponse { - epoch: number; - rewards_pot: number | null; - deposits_stake: number | null; - fees: number | null; - treasury: number | null; - reserves: number | null; - circulation: number | null; - distributed_rewards: number | null; - undistributed_rewards: number | null; - pool_rewards_pot: number | null; -} - -function numberOrZero(value: number | null | undefined): number { - return value ?? 0; -} - -function mapAdapot(item: ApiResponse) { - return { - epoch: item.epoch, - totalAmount: numberOrZero(item.rewards_pot), - currentEpoch: item.epoch, - details: { - depositsStake: numberOrZero(item.deposits_stake), - fees: numberOrZero(item.fees), - treasury: numberOrZero(item.treasury), - reserves: numberOrZero(item.reserves), - circulation: numberOrZero(item.circulation), - distributedRewards: numberOrZero(item.distributed_rewards), - undistributedRewards: numberOrZero(item.undistributed_rewards), - poolRewardsPot: numberOrZero(item.pool_rewards_pot) - } - }; -} +/* Ada pots by epoch, newest first. /adapot/list pages are 1-based. */ export const load: PageServerLoad = async ({ url, fetch }) => { - const page = url.searchParams.get('page') || '1'; - const count = url.searchParams.get('count') || '15'; - - try { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - if (!INDEXER_BASE_URL) { - throw new Error('PUBLIC_INDEXER_BASE_URL environment variable is not set'); - } - - const params = new URLSearchParams({ page, count }); - const response = await fetch(`${INDEXER_BASE_URL}/adapot/list?${params.toString()}`); - - if (response.status === 404) { - return { adapots: [], page, count }; - } - if (!response.ok) { - throw new Error(`Failed to fetch AdaPot list (Status: ${response.status})`); - } - - const adapots: ApiResponse[] = await response.json(); - if (!Array.isArray(adapots)) { - throw new Error('Invalid data format received from server'); - } - - return { - adapots: adapots.map(mapAdapot), - page, - count - }; - } catch (error) { - console.error('Error loading AdaPots:', error); - return { - adapots: [], - page, - count, - error: error instanceof Error ? error.message : 'An error occurred while loading AdaPots' - }; - } + const { page, count } = paging(url, 20, 100); + try { + const rows = await api('/adapot/list', { fetch, params: { page, count } }); + return { rows: rows ?? [], page, count }; + } catch (e) { + error(503, (e as Error).message); + } }; diff --git a/applications/viewer/src/routes/adapot/list/+page.svelte b/applications/viewer/src/routes/adapot/list/+page.svelte index 55d969d6..287c7558 100644 --- a/applications/viewer/src/routes/adapot/list/+page.svelte +++ b/applications/viewer/src/routes/adapot/list/+page.svelte @@ -1,158 +1,73 @@ -
-

AdaPot History

- - {#if error} - - {:else if adapots.length === 0} -
-

No AdaPot data available for this page.

-
- {:else} +Ada pot history · Yaci Viewer -
- - - - - - - - - - - - - - - - {#each adapots as adapot} - - - - - - - - - - - - {/each} - -
EpochTreasuryReservesCirculationFeesTotal RewardsDistributedUndistributedPool Rewards
- {adapot.epoch} - -
- {formatAda(adapot.details.treasury)} -
-
-
- {formatAda(adapot.details.reserves)} -
-
-
- {formatAda(adapot.details.circulation)} -
-
-
- {formatAda(adapot.details.fees)} -
-
-
- {formatAda(adapot.totalAmount)} -
-
-
- {formatAda(adapot.details.distributedRewards)} -
-
-
- {formatAda(adapot.details.undistributedRewards)} -
-
-
- {formatAda(adapot.details.poolRewardsPot)} -
-
-
+ + {#snippet icon()}{/snippet} + Treasury, reserves and rewards at each epoch boundary. + {#snippet actions()}Latest pots {/snippet} + - -
-
- - Page {currentPage} - -
-
- - {/if} -
+{#if data.rows.length > 1} + +
+ + +
+
+{/if} - \ No newline at end of file +
+ r.epoch} + rowHref={(r) => `/adapot/current?epoch=${r.epoch}`} + emptyTitle="No ada pots yet" + emptyText="Ada pots are calculated at epoch boundaries. On a new devnet, wait for the first epoch to finish." + > + {#snippet cell(r, key)} + {#if key === 'epoch'} + {num(r.epoch)} + {:else} + {@const v = r[key as keyof AdaPot]} + {@const i = data.rows.indexOf(r)} + {@const d = key === 'treasury' || key === 'reserves' ? delta(i, key) : null} + {v != null ? `₳${adaCompact(v)}` : '—'} + {#if d}
{d > 0 ? '+' : '−'}₳{adaCompact(Math.abs(d))}
{/if} + {/if} + {/snippet} +
+ +
diff --git a/applications/viewer/src/routes/adapot/store.ts b/applications/viewer/src/routes/adapot/store.ts deleted file mode 100644 index 2350d6aa..00000000 --- a/applications/viewer/src/routes/adapot/store.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { writable } from 'svelte/store'; - -interface AdaPotState { - totalAmount: number; - currentEpoch: number; - lastUpdated: string; - history: any[]; - loading: boolean; - error: string | null; -} - -export const adapotStore = writable({ - totalAmount: 0, - currentEpoch: 0, - lastUpdated: '', - history: [], - loading: false, - error: null -}); - -export async function fetchAdaPotData(): Promise { - try { - adapotStore.update(state => ({ ...state, loading: true })); - - // TODO: Implement API calls to: - // 1. Get total AdaPot amount - // 2. Get current epoch - // 3. Get last updated timestamp - // 4. Get history data - - adapotStore.update(state => ({ ...state, loading: false })); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - adapotStore.update(state => ({ - ...state, - loading: false, - error: errorMessage - })); - } -} - -export async function fetchAdaPotHistory(startEpoch: number, endEpoch: number): Promise { - try { - // TODO: Implement API call to get AdaPot history for a range of epochs - return []; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`Failed to fetch AdaPot history: ${errorMessage}`); - } -} \ No newline at end of file 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..63072d06 100644 --- a/applications/viewer/src/routes/addresses/[address]/+page.server.ts +++ b/applications/viewer/src/routes/addresses/[address]/+page.server.ts @@ -1,146 +1,148 @@ import { error, redirect } from '@sveltejs/kit'; +import { paging, tryApi, type ApiOptions } from '$lib/api/client'; +import type { Slow } from '$lib/api/slow'; +import { stakeAddressOf } from '$lib/bech32'; +import { BlockfrostAbsent, bfApi, blockfrostPresent } from '$lib/server/blockfrost'; +import { GUARD, guardEnabled, guardedApi, indexerGuard, settle } from '$lib/server/heavy-guard'; +import { utxoBalance, type UtxoBalance } from '$lib/tx/balance'; +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'; - -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; -} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +const TABS = ['utxos', 'tokens', 'txs'] as const; + +type Row = Utxo & { block_height?: number; block_time?: number }; +type Account = { controlled_amount?: number; withdrawable_amount?: number; pool_id?: string | null }; +/** null amounts: the balance couldn't be worked out. */ +type Balance = Omit & { amounts: Amount[] | null; utxos?: number }; + +/* + * Busy addresses on a large Yaci Store can take hours for /amounts, /transactions and /accounts/{stake}, and the store + * keeps running a query after the viewer gives up (bloxbean/yaci-store#1269). So only /balance (quick, even as a 503) + * holds up the page; the rest stream in. The balance comes from /balance when the store aggregates balances, otherwise + * from the address's UTxOs: summed when one page holds them all, otherwise the exact balance from the store's + * Blockfrost-compatible API, and only when that fails the sum of a few pages (a lower bound); never /amounts while the + * guard is on. Where the store has the Blockfrost-compatible API, the UTxO pages and the transactions come from it too: + * the store stops its queries after 15 s, so a busy address leaves nothing running. 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}`); + // Shelley (bech32) or Byron (base58) only: anything else could reshape the store paths built from it. + if (!/^(addr|addr_test)1[02-9ac-hj-np-z]+$/.test(address) && !/^[1-9A-HJ-NP-Za-km-z]{20,}$/.test(address)) error(404, 'That is not an address.'); + + const guarded = guardEnabled(); + const txKey = `/addresses/${address}/transactions`; + const utxosPath = `/addresses/${address}/utxos`; + // The page opens on the UTxOs: quick even for busy addresses, where the transaction list can take minutes. + const asked = url.searchParams.get('tab') ?? ''; + const tab = (TABS as readonly string[]).includes(asked) ? (asked as (typeof TABS)[number]) : 'utxos'; + const { page, count } = paging(url, 20); + const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; + const utxoListKey = `${utxosPath} page ${page} of ${count} ${order}`; + // The first page shares txKey, so a busy address skips its tab; a deeper page, slow only because of its offset, is + // skipped on its own instead of marking the whole tab for every visitor. + const txListKey = page === 1 && order === 'desc' ? txKey : `${txKey} page ${page} of ${count} ${order}`; + + // "Try again" on a list or balance the store was too slow for. Offered only where the calls go to the + // Blockfrost-compatible API, whose queries the store stops itself after 15 s; the native ones keep running. + const canRetry = guarded && blockfrostPresent(); + if (canRetry && url.searchParams.has('retry')) { + const balancePages = Array.from({ length: GUARD.balancePages }, (_, i) => `${utxosPath} balance page ${i + 1}`); + for (const k of [txKey, txListKey, utxoListKey, `/blockfrost/addresses/${address}`, ...balancePages]) indexerGuard.forget(k); + } + // The transaction list timed out for this address recently: its tab says so instead of asking again. + const heavy = guarded && indexerGuard.isHeavy(txListKey); + const stake = stakeAddressOf(address); + const timeouts = GUARD.timeoutMs; + + /** A guarded call to the Blockfrost-compatible endpoint, or to the native one where the store has none. */ + const either = (key: string, bfPath: string, nativePath: string, opts: ApiOptions & { cacheMs?: number; heavyMs?: number }) => + bfApi(key, bfPath, { ...opts, timeoutMs: timeouts.blockfrost }).catch((e) => (e instanceof BlockfrostAbsent ? guardedApi(key, nativePath, opts) : Promise.reject(e))); + + const bfKey = `/blockfrost/addresses/${address}`; + /** The exact balance from the Blockfrost-compatible API; null where the store has none or it failed. */ + const bfBalance = () => + bfApi<{ amount?: Amount[] } | null>(bfKey, `/addresses/${address}`, { fetch, timeoutMs: timeouts.blockfrost, cacheMs: GUARD.balanceCacheMs }) + .then((r) => (Array.isArray(r?.amount) ? r.amount : null)) + .catch(() => null); + + /** + * The balance now from /balance, or `later` from the UTxOs (see above). Each page goes through the guard and is kept + * for a minute: a deep page can be the slow one, and then later visits stop before it. + */ + const balance = async (): Promise<{ now: Balance } | { later: Promise }> => { + const b = await tryApi(`/addresses/${address}/balance`, { fetch, timeoutMs: timeouts.balance }); + if (b?.amounts) return { now: { amounts: b.amounts, exact: true } }; + if (!guarded) { + const amounts = (await bfBalance()) ?? (await tryApi(`/addresses/${address}/amounts`, { fetch })); + return { now: { amounts, exact: amounts !== null } }; + } + const pageParams = (p: number) => ({ page: p, count: GUARD.pageSize, order: 'desc' }); + const utxoPage = (p: number) => + either(`${utxosPath} balance page ${p}`, `/addresses/${address}/utxos`, utxosPath, { + fetch, + params: pageParams(p), + timeoutMs: timeouts.utxos, + cacheMs: GUARD.balanceCacheMs, + heavyMs: GUARD.utxoHeavyMs + }).then((r) => r ?? []); + const summed = async (): Promise => { + let first: Utxo[] | null = null; + try { + first = await utxoPage(1); + } catch { + // The first page failed: the Blockfrost-compatible balance is the only other source. + } + // One page holds them all: their sum is the exact balance. + if (first && first.length < GUARD.pageSize) return utxoBalance(utxoPage, { pageSize: GUARD.pageSize, maxPages: 1, first }); + // More than a page: the exact balance, where the store can give it; else the sum of a few pages (a lower bound + // past GUARD.balancePages, or after a page fails). One after the other, so a busy address gets one slow query. + const exact = await bfBalance(); + if (exact) return { amounts: exact, exact: true }; + if (!first) return { amounts: null, exact: false }; + // A later page failing still leaves a lower bound over the pages read. + return utxoBalance(utxoPage, { pageSize: GUARD.pageSize, maxPages: GUARD.balancePages, first }); + }; + return { later: summed() }; + }; + + // The open tab's list, streamed: even UTxOs can be slow when the store is busy, mostly on deep pages, so a UTxO page + // that timed out is skipped for a couple of minutes (that page only). + const utxoList = + tab === 'utxos' + ? settle( + either(utxoListKey, `/addresses/${address}/utxos`, utxosPath, { + fetch, + params: { page, count, order }, + timeoutMs: timeouts.utxos, + heavyMs: GUARD.utxoHeavyMs + }).then((r) => r ?? []) + ) + : null; + // The Blockfrost-compatible list when the store has it (the store stops it after 15 s), else the native one. Both + // share txListKey, so a timeout on either skips the tab (or that deep page) for a while. + const txs = () => either(txListKey, `/addresses/${address}/transactions`, txKey, { fetch, params: { page, count, order }, timeoutMs: timeouts.transactions }); + const txList: Slow | Promise> | null = + tab !== 'txs' ? null : heavy ? { ok: false, reason: 'heavy', message: 'Skipped: this address timed out recently' } : settle(txs().then((r) => r ?? [])); + + // The stake account can take ~30 s for a busy one: always streamed, and kept for a minute. + const account = stake ? settle(guardedApi(`/accounts/${stake}`, `/accounts/${stake}`, { fetch, timeoutMs: timeouts.account, cacheMs: GUARD.accountCacheMs })) : null; + + const amounts = await balance(); + + return { + address, + stake, + account, + balance: 'now' in amounts ? amounts.now : amounts.later, + tab, + page, + count, + order, + canRetry, + list: utxoList ?? txList ?? ({ ok: true, value: [] } as Slow) + }; }; diff --git a/applications/viewer/src/routes/addresses/[address]/+page.svelte b/applications/viewer/src/routes/addresses/[address]/+page.svelte index 570ef041..4dee113e 100644 --- a/applications/viewer/src/routes/addresses/[address]/+page.svelte +++ b/applications/viewer/src/routes/addresses/[address]/+page.svelte @@ -1,397 +1,225 @@ -
-
- -
-
-

- 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 balance.value === undefined} + {:else if !balance.value.amounts}— + {:else}{#if !balance.value.exact}≥ {/if}{/if} +
+ {#if balance.value?.amounts} +
{balance.value.exact ? '' : '≥ '}{num(lovelace)} lovelace
+ {/if} +
+ {#if balance.value === undefined}Adding up unspent outputs… + {:else if !balance.value.amounts}Yaci Store didn’t return a balance for this address. + {:else}{tokens.length ? `${!balance.value.exact ? 'At least ' : ''}${num(tokens.length)} native token${tokens.length === 1 ? '' : 's'}` : 'No native tokens'}{#if balance.value.utxos != null}{' · '}{num(balance.value.utxos)}{balance.value.exact ? '' : '+'} UTxO{balance.value.utxos === 1 ? '' : 's'}{/if}{/if} +
+ {#if partialNote}
{partialNote}
{/if} +
+
+
Stake account
+ {#if data.stake} + {shorten(data.stake, 12, 8)} + {#if accountResult.value === undefined} +
Loading the stake account…
+ {:else if accountResult.value && !accountResult.value.ok} +
{accountResult.value.reason === 'error' ? 'Couldn’t load the stake account' : 'The stake account is too slow to load right now'}
+ {:else if account?.controlled_amount != null} +
controls
+ {/if} + {:else} +
{info.era === 'byron' ? 'Byron addresses cannot stake.' : 'This address has no stake part.'}
+ {/if} +
+
+
Delegation
+ {#if account?.pool_id} + {shorten(account.pool_id, 12, 8)} +
delegated stake pool
+ {:else if data.stake && accountResult.value === undefined} +
+ {:else} +
{data.stake && accountResult.value?.ok ? 'Not delegated' : '—'}
+ {/if} +
- +
+
+
+ +
+ {#if data.tab !== 'tokens'} + + {:else} + + {/if} +
+ + {#if data.tab !== 'tokens' && list.value === undefined} + + {:else if data.tab !== 'tokens' && list.value && !list.value.ok} + {#if list.value.reason === 'error'} + + {:else} + + {#if data.canRetry}Try again{/if} + {#if data.tab === 'txs'}Show UTxOs instead{/if} + + {/if} + {: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 partialNote}
Tokens in the newest {num(balance.value?.utxos ?? 0)} unspent outputs only.
{/if} + {#if balance.value === undefined} + + {:else if !tokens.length} + + {:else} + + {/if} + {/if} +
- + diff --git a/applications/viewer/src/routes/api/adapot/[epoch]/+server.ts b/applications/viewer/src/routes/api/adapot/[epoch]/+server.ts deleted file mode 100644 index 2661ba8b..00000000 --- a/applications/viewer/src/routes/api/adapot/[epoch]/+server.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { env } from '$env/dynamic/public'; -import type { RequestHandler } from './$types'; - -interface AdaPotData { - epoch: number; - deposits_stake: number; - fees: number; - treasury: number; - reserves: number; - circulation: number; - distributed_rewards: number; - undistributed_rewards: number; - rewards_pot: number; - pool_rewards_pot: number; -} - -export const GET: RequestHandler = async ({ params }) => { - try { - const { epoch } = params; - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - console.log('INDEXER_BASE_URL:', INDEXER_BASE_URL); - - if (!INDEXER_BASE_URL) { - throw new Error('PUBLIC_INDEXER_BASE_URL environment variable is not set'); - } - - const url = `${INDEXER_BASE_URL}/adapot/epochs/${epoch}`; - console.log('Fetching URL:', url); - - const response = await fetch(url, { - headers: { - 'Accept': 'application/json' - } - }); - - console.log('Response status:', response.status); - console.log('Response headers:', Object.fromEntries(response.headers.entries())); - - if (!response.ok) { - const errorText = await response.text(); - console.error('Error response:', errorText); - throw new Error(`Failed to fetch AdaPot details for the specified epoch: ${response.status} ${response.statusText}`); - } - - const data: AdaPotData = await response.json(); - console.log('Fetched data:', data); - - // Transform the data to match our frontend expectations - const transformedData = { - epoch: data.epoch, - totalAmount: data.rewards_pot, - lastUpdated: new Date().toISOString(), - currentEpoch: data.epoch, - details: { - depositsStake: data.deposits_stake, - fees: data.fees, - treasury: data.treasury, - reserves: data.reserves, - circulation: data.circulation, - distributedRewards: data.distributed_rewards, - undistributedRewards: data.undistributed_rewards, - poolRewardsPot: data.pool_rewards_pot - } - }; - - return json(transformedData); - } catch (error) { - console.error('Error in GET handler:', error); - return json( - { error: error instanceof Error ? error.message : 'Failed to fetch AdaPot details' }, - { status: 500 } - ); - } -}; \ No newline at end of file diff --git a/applications/viewer/src/routes/api/adapot/list/+server.ts b/applications/viewer/src/routes/api/adapot/list/+server.ts deleted file mode 100644 index 38415c91..00000000 --- a/applications/viewer/src/routes/api/adapot/list/+server.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { json } from '@sveltejs/kit'; -import { env } from '$env/dynamic/public'; -import type { RequestHandler } from './$types'; - -interface AdaPotData { - epoch: number; - rewards_pot: number; - deposits_stake: number; - fees: number; - treasury: number; - reserves: number; - circulation: number; - distributed_rewards: number; - undistributed_rewards: number; - pool_rewards_pot: number; -} - -export const GET: RequestHandler = async ({ url, fetch }) => { - try { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - console.log('INDEXER_BASE_URL:', INDEXER_BASE_URL); - - if (!INDEXER_BASE_URL) { - throw new Error('PUBLIC_INDEXER_BASE_URL environment variable is not set'); - } - - // Get pagination parameters - const page = Math.max(1, parseInt(url.searchParams.get('page') || '1')); - const count = Math.min(100, Math.max(1, parseInt(url.searchParams.get('count') || '20'))); - console.log('Pagination params:', { page, count }); - - // Calculate the starting epoch - const startEpoch = (page - 1) * count + 1; - const epochs = Array.from({ length: count }, (_, i) => startEpoch + i); - console.log('Fetching epochs:', epochs); - - // Fetch AdaPot details for each epoch - const adapots = await Promise.all( - epochs.map(async (epoch) => { - try { - const apiUrl = `${INDEXER_BASE_URL}/api/v1/adapot/epochs/${epoch}`; - console.log('Fetching:', apiUrl); - const response = await fetch(apiUrl); - if (!response.ok) { - console.log(`Failed to fetch epoch ${epoch}:`, response.status, response.statusText); - return null; - } - const data: AdaPotData = await response.json(); - console.log(`Successfully fetched epoch ${epoch}:`, data); - return data; - } catch (error) { - console.error(`Error fetching epoch ${epoch}:`, error); - return null; - } - }) - ); - - // Filter out any failed requests - const validAdapots = adapots.filter((adapot): adapot is NonNullable => adapot !== null); - console.log('Valid adapots:', validAdapots.length); - - if (validAdapots.length === 0) { - console.log('No valid adapots found for the requested epochs'); - } - - return json(validAdapots); - } catch (error) { - console.error('Error in GET handler:', error); - return json( - { error: error instanceof Error ? error.message : 'Failed to fetch AdaPot list' }, - { status: 500 } - ); - } -}; \ No newline at end of file diff --git a/applications/viewer/src/routes/assets/+page.svelte b/applications/viewer/src/routes/assets/+page.svelte index d728f6a9..edce1e04 100644 --- a/applications/viewer/src/routes/assets/+page.svelte +++ b/applications/viewer/src/routes/assets/+page.svelte @@ -1,97 +1,35 @@ -
-
-

Asset Explorer

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

Supported formats

-
-
    -
  • Unit — Policy ID + hex asset name (57+ hex chars)
  • -
  • Policy ID — 56 hex characters
  • -
  • Fingerprint — Bech32 asset fingerprint (asset1...)
  • -
-
-
-
-
-
-
+Assets · Yaci Viewer + + +
+ {#each [{ t: 'Policy id', d: '56 hex characters. Shows every asset minted under it.' }, { t: 'Asset unit', d: 'Policy id followed by the asset name in hex.' }, { t: 'Fingerprint', d: 'The asset1… id from CIP-14, as wallets show it.' }] as tip (tip.t)} +
+
{tip.t}
+

{tip.d}

+
+ {/each} +
+
diff --git a/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.server.ts b/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.server.ts index 65a7ccef..ba5787d4 100644 --- a/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.server.ts +++ b/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.server.ts @@ -1,55 +1,24 @@ -import { error } from '@sveltejs/kit'; +import { error, redirect } from '@sveltejs/kit'; +import { tryApi } from '$lib/api/client'; +import { loadSupply } from '$lib/assets/load'; +import type { MintEvent } from '$lib/assets/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; - -export const load: PageServerLoad = async ({ params, url, fetch }) => { - const fingerprint = params.fingerprint; - const page = parseInt(url.searchParams.get('page') || '1'); - const count = parseInt(url.searchParams.get('count') || String(DEFAULT_COUNT)); - - if (!fingerprint || !fingerprint.startsWith('asset1')) { - throw error(400, 'Invalid fingerprint. Must start with "asset1".'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { - throw error(400, 'Invalid count'); - } - - const fetchSupply = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/fingerprint/${fingerprint}/supply`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return null; - }; - - const fetchHistory = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/fingerprint/${fingerprint}/history?page=${page - 1}&count=${count}`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return []; - }; - - const [supply, history] = await Promise.all([ - fetchSupply(), - fetchHistory() - ]); - - // Derive unit from the first history entry for linking to full detail - const unit = history.length > 0 ? history[0].unit : null; - - return { - fingerprint, - supply, - history, - unit, - currentPage: page, - count - }; +/* + * A fingerprint (CIP-14) names one asset. Its history gives the unit, so we send people to the asset page; if the + * store has no history for it, show what we know here. + */ +export const load: PageServerLoad = async ({ params, fetch }) => { + const fingerprint = params.fingerprint.trim().toLowerCase(); + if (!/^asset1[0-9a-z]{38}$/.test(fingerprint)) error(404, 'That is not an asset fingerprint (asset1…).'); + + const [history, supply] = await Promise.all([ + tryApi(`/assets/fingerprint/${fingerprint}/history`, { fetch, params: { page: 1, count: 1 }, timeoutMs: 15000 }), + loadSupply(`/assets/fingerprint/${fingerprint}/supply`, fetch) + ]); + const unit = history?.[0]?.unit; + if (unit) redirect(302, `/assets/unit/${unit}`); + if (supply.notFound) error(404, 'No asset with that fingerprint on this chain.'); + + return { fingerprint, supply }; }; diff --git a/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.svelte b/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.svelte index 3a7eaa68..18b20027 100644 --- a/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.svelte +++ b/applications/viewer/src/routes/assets/fingerprint/[fingerprint]/+page.svelte @@ -1,161 +1,26 @@ -
-
- -
-
-

- Asset Fingerprint -

-
- -
-
- -
-
-
- Total Supply -
-
- {supplyValue} -
-
-
-
- Full Details -
-
- {#if unit} - - View Asset Detail - - {:else} - No asset data available - {/if} -
-
-
-
- - -
- {#if loading} -
- -
- {/if} - -

Mint/Burn History

- - {#if history.length === 0} - - {:else} -
- - - - - - - - - - - - - {#each history as event (event.tx_hash + event.quantity)} - - - - - - - - - {/each} - -
Tx HashAssetTypeQuantityBlockTime
- - {truncate(event.tx_hash, 20, '...')} - - - - {decodeAssetName(event.unit)} - - - {#if event.mint_type === 'MINT'} - MINT - {:else} - BURN - {/if} - - {event.quantity} - - - {event.block_number} - - - {getDate(event.block_time)} -
-
- -
- - Page {currentPage} - -
- {/if} -
-
-
+{data.fingerprint} · Yaci Viewer + + + {#snippet icon()}{/snippet} + The store has no mint history for this asset, so its policy and name aren't known here. + + + +
+ + + {#if data.supply.value != null}{num(data.supply.value)}{:else}Not reported{/if} + +
+
diff --git a/applications/viewer/src/routes/assets/policy/[policy]/+page.server.ts b/applications/viewer/src/routes/assets/policy/[policy]/+page.server.ts index 6fdc29b5..e3272726 100644 --- a/applications/viewer/src/routes/assets/policy/[policy]/+page.server.ts +++ b/applications/viewer/src/routes/assets/policy/[policy]/+page.server.ts @@ -1,51 +1,21 @@ import { error } from '@sveltejs/kit'; +import { paging, tryApi } from '$lib/api/client'; +import { loadList, loadSupply } from '$lib/assets/load'; +import type { MintEvent } from '$lib/assets/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; +/* A minting policy: total supply across its assets, its script, and mint/burn history (1-based pages). */ export const load: PageServerLoad = async ({ params, url, fetch }) => { - const policy = params.policy; - const page = parseInt(url.searchParams.get('page') || '1'); - const count = parseInt(url.searchParams.get('count') || String(DEFAULT_COUNT)); - - if (!policy || !/^[0-9a-fA-F]{56}$/.test(policy)) { - throw error(400, 'Invalid policy ID. Must be exactly 56 hex characters.'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { - throw error(400, 'Invalid count'); - } - - const fetchSupply = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/policy/${policy}/supply`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return null; - }; - - const fetchHistory = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/policy/${policy}/history?page=${page - 1}&count=${count}`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return []; - }; + const policy = params.policy.trim().toLowerCase(); + if (!/^[0-9a-f]{56}$/.test(policy)) error(404, 'That is not a policy id (56 hex characters).'); + const { page, count } = paging(url, 20); - const [supply, history] = await Promise.all([ - fetchSupply(), - fetchHistory() - ]); + const [supply, history, script] = await Promise.all([ + loadSupply(`/assets/policy/${policy}/supply`, fetch), + loadList(`/assets/policy/${policy}/history`, fetch, { page, count }), + tryApi<{ type?: string; serialised_size?: number | null }>(`/scripts/${policy}`, { fetch }) + ]); + if (supply.notFound && !history.rows.length && !script) error(404, 'No assets under that policy on this chain.'); - return { - policy, - supply, - history, - currentPage: page, - count - }; + return { policy, supply, script, page, count, rows: history.rows, listError: history.error }; }; diff --git a/applications/viewer/src/routes/assets/policy/[policy]/+page.svelte b/applications/viewer/src/routes/assets/policy/[policy]/+page.svelte index 7a598a06..b147020d 100644 --- a/applications/viewer/src/routes/assets/policy/[policy]/+page.svelte +++ b/applications/viewer/src/routes/assets/policy/[policy]/+page.svelte @@ -1,153 +1,76 @@ -
-
- -
-
-

- Policy -

-
- -
-
+Policy {data.policy.slice(0, 10)}… · Yaci Viewer -
-
-
- Total Supply (all assets) -
-
- {supplyValue} -
-
-
-
- Mint/Burn Events -
-
- {history.length}{history.length === count ? '+' : ''} -
-
-
-
+ + {#snippet icon()}{/snippet} +
+ {data.policy} + +
+ {#snippet actions()} + + {#if kind}{kind}{/if} + {/snippet} +
- -
- {#if loading} -
- -
- {/if} - -

Mint/Burn History

- - {#if history.length === 0} - - {:else} -
- - - - - - - - - - - - - {#each history as event (event.tx_hash + event.unit + event.quantity)} - - - - - - - - - {/each} - -
Tx HashAssetTypeQuantityBlockTime
- - {truncate(event.tx_hash, 20, '...')} - - - - {decodeAssetName(event.unit)} - - - {#if event.mint_type === 'MINT'} - MINT - {:else} - BURN - {/if} - - {event.quantity} - - - {event.block_number} - - - {getDate(event.block_time)} -
-
+
+
+
+ +
+
Total supply
+
{data.supply.value != null ? num(data.supply.value) : '—'}
+
+ {data.supply.value != null ? 'units across every asset under this policy' : data.supply.error ? 'The store took too long to total this policy.' : 'Not reported'} +
+
+
+ + {#snippet actions()} + Policy script + {/snippet} + {#if units.length} +
+ {#each units.slice(0, 18) as u (u)}{/each} + {#if units.length > 18}+{units.length - 18} more{/if} +
+ {:else} +

No mint or burn events on this page of history.

+ {/if} +
+
-
- - Page {currentPage} - -
- {/if} -
-
+
+
+

Mint & burn history

+
+ {#if data.listError} + + {:else} + + + {/if}
diff --git a/applications/viewer/src/routes/assets/unit/[unit]/+page.server.ts b/applications/viewer/src/routes/assets/unit/[unit]/+page.server.ts index d7fa8a44..4855d4d2 100644 --- a/applications/viewer/src/routes/assets/unit/[unit]/+page.server.ts +++ b/applications/viewer/src/routes/assets/unit/[unit]/+page.server.ts @@ -1,86 +1,55 @@ import { error } from '@sveltejs/kit'; +import { paging, tryApi } from '$lib/api/client'; +import { loadList, loadSupply } from '$lib/assets/load'; +import type { MintEvent } from '$lib/assets/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; -export const load: PageServerLoad = async ({ params, url, fetch }) => { - const unit = params.unit; - 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') || 'history'; - - if (!unit || !/^[0-9a-fA-F]{57,}$/.test(unit)) { - throw error(400, 'Invalid asset unit. Must be 57+ hex characters (policy ID + asset name).'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { - throw error(400, 'Invalid count'); - } - - const fetchSupply = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/${unit}/supply`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return null; - }; - - const fetchHistory = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/${unit}/history?page=${page - 1}&count=${count}`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return []; - }; +const TABS = ['history', 'transactions', 'utxos', 'holders'] as const; +type Tab = (typeof TABS)[number]; - const fetchTransactions = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/${unit}/transactions?page=${page - 1}&count=${count}&order=${order}`); - if (res.ok) return await res.json(); - } catch { /* ignore */ } - return []; - }; - - const fetchUtxos = async () => { - try { - const res = await fetch(`${INDEXER_BASE_URL}/assets/${unit}/utxos?page=${page - 1}&count=${count}&order=${order}`); - if (res.ok) { - const data = await res.json(); - return data.map((utxo: any) => ({ - ...utxo, - amount: utxo.amount?.map((a: any) => ({ - asset_name: a.unit, - quantity: a.quantity, - unit: a.unit - })) || [] - })); - } - } catch { /* ignore */ } - return []; - }; - - const [supply, history, transactions, utxos] = await Promise.all([ - fetchSupply(), - fetchHistory(), - fetchTransactions(), - fetchUtxos() - ]); - - return { - unit, - supply, - history, - transactions, - utxos, - currentPage: page, - count, - order, - tab - }; +/* + * An asset: supply, plus one list for the open tab. Yaci Store pages are 1-based here (page 0 is treated as 1). + * Transactions, UTxOs and holders can be too slow on a big chain, so they time out into an inline error. + */ +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const unit = params.unit.trim().toLowerCase(); + if (!/^[0-9a-f]{56,120}$/.test(unit)) error(404, 'That is not an asset unit (policy id followed by the asset name in hex).'); + + const tabParam = url.searchParams.get('tab') ?? ''; + const tab: Tab = (TABS as readonly string[]).includes(tabParam) ? (tabParam as Tab) : 'history'; + const { page, count } = paging(url, 20); + + const path = { + history: `/assets/${unit}/history`, + transactions: `/assets/${unit}/transactions`, + utxos: `/assets/${unit}/utxos`, + holders: `/assets/${unit}/addresses` + }[tab]; + // /assets/{unit}/addresses is the one zero-based list in Yaci Store 2.0.x (AddressAssetController passes `page` on + // unchanged): send it page - 1, or page 1 would skip the first holders. + const listParams: Record = tab === 'holders' ? { page: page - 1, count, sort: 'desc' } : tab === 'history' ? { page, count } : { page, count, order: 'desc' }; + + const [supply, list, latest] = await Promise.all([ + loadSupply(`/assets/${unit}/supply`, fetch), + loadList(path, fetch, listParams, tab === 'history' ? 20000 : 12000), + // The newest event carries the fingerprint; the history tab already has it. + tab === 'history' ? null : tryApi(`/assets/${unit}/history`, { fetch, params: { page: 1, count: 1 } }) + ]); + + const first = (tab === 'history' ? list.rows[0] : latest?.[0]) as MintEvent | undefined; + if (supply.notFound && !first) error(404, 'No asset with that unit on this chain.'); + + return { + unit, + supply, + fingerprint: first?.fingerprint ?? null, + tab, + page, + count, + rows: list.rows, + listError: list.error + }; }; diff --git a/applications/viewer/src/routes/assets/unit/[unit]/+page.svelte b/applications/viewer/src/routes/assets/unit/[unit]/+page.svelte index 1a328361..ee572c30 100644 --- a/applications/viewer/src/routes/assets/unit/[unit]/+page.svelte +++ b/applications/viewer/src/routes/assets/unit/[unit]/+page.svelte @@ -1,359 +1,146 @@ -
-
- -
-
-

- Asset -

-
- {parsed.assetNameUtf8 || parsed.assetNameHex || '(empty name)'} -
-
- -
-
- -
-
-
- Policy ID -
-
- - - -
-
- -
-
- Fingerprint -
-
- {#if fingerprint} - - {:else} - — - {/if} -
-
- -
-
- Total Supply -
-
- {supplyValue} -
-
-
-
- - -
-
- - - - - -
-
- - -
- {#if loading} -
- -
- {/if} - - - {#if activeTabIndex === HISTORY_TAB} -
- {#if history.length === 0} - - {:else} -
- - - - - - - - - - - - {#each history as event (event.tx_hash + event.quantity)} - - - - - - - - {/each} - -
Tx HashBlockTypeQuantityTime
- - {truncate(event.tx_hash, 20, '...')} - - - - {event.block_number} - - - {#if event.mint_type === 'MINT'} - MINT - {:else} - BURN - {/if} - - {event.quantity} - - {getDate(event.block_time)} -
-
- -
- - 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_number || tx.block_height} - - - {getDate(tx.block_time)} -
-
- -
- - Page {currentPage} - -
- {/if} -
- {/if} - - - {#if activeTabIndex === UTXOS_TAB} -
- {#if utxos.length === 0} - - {:else} -
- - - - - - - - - - {#each utxos as utxo (utxo.tx_hash + '#' + utxo.output_index)} - - - - - - {/each} - -
Tx Hash # IndexAddressAmounts
- - {truncate(utxo.tx_hash, 20, '...')} - - #{utxo.output_index} - - - -
- -
-
-
- -
- - Page {currentPage} - -
- {/if} -
- {/if} -
-
+{info.name} · Asset · Yaci Viewer + + + {#snippet icon()}{/snippet} +
+ {data.unit} + +
+ {#if registered} +
+ {[registered.ticker, registered.name].filter(Boolean).join(' · ')}{#if registered.decimals}{' · '}{registered.decimals} decimals{/if} +
+ {/if} + {#snippet actions()} + {#if registered?.ticker}{registered.ticker}{/if} + {#if info.label}CIP-68 {info.label}{/if} + {#if supplyZero}Fully burned{/if} + {/snippet} +
+ +
+
+
+ +
+
Supply
+
+ {supplyText} +
+
{data.supply.value == null ? 'The store did not report a supply.' : decimals ? `in circulation (${registered?.ticker ?? 'token'}, ${decimals} decimals)` : 'units in circulation, before decimals'}
+
+
+
+
+ + {#if data.fingerprint}{:else}—{/if} + + {#if info.nameHex} + {info.name} + 0x{shorten(info.nameHex, 12, 6)} + {:else}Empty name{/if} + + View the minting policy +
+
- +
+ + {#if data.listError} + + {:else if data.tab === 'history'} + + + {:else if data.tab === 'transactions'} + r.tx_hash} + rowHref={(r) => `/transactions/${r.tx_hash}`} + emptyTitle="No transactions" + > + {#snippet cell(r, key)} + {#if key === 'tx'}{shorten(r.tx_hash, 14, 10)} + {:else if key === 'block'}{@const b = r.block_number ?? r.block_height}{#if b != null}#{num(b)}{:else}—{/if} + {:else}{/if} + {/snippet} + + + {:else if data.tab === 'utxos'} + r.tx_hash + '#' + r.output_index} + emptyTitle="No unspent outputs hold this asset" + > + {#snippet cell(r, key)} + {#if key === 'ref'}{shorten(r.tx_hash, 10, 6)}#{r.output_index} + {:else if key === 'address'} + {:else if key === 'ada'} + {:else} + {@const mine = assetsOf(r.amount).find((a) => a.unit === data.unit)} + {#if mine}{:else}—{/if} + {/if} + {/snippet} + + + {:else} + r.address} + rowHref={(r) => `/addresses/${r.address}`} + emptyTitle="No holders listed" + emptyText="This store doesn't report holders for this asset." + > + {#snippet cell(r, key)} + {#if key === 'address'} + {:else}{num(r.quantity)}{/if} + {/snippet} + + + {/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..994b82e5 100644 --- a/applications/viewer/src/routes/blocks/[number]/+page.server.ts +++ b/applications/viewer/src/routes/blocks/[number]/+page.server.ts @@ -1,27 +1,19 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { error } from '@sveltejs/kit'; +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(); + if (!/^(\d+|[0-9a-fA-F]{64})$/.test(id)) error(404, `No block ${id} on this chain.`); + 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..ce286e64 100644 --- a/applications/viewer/src/routes/blocks/[number]/+page.svelte +++ b/applications/viewer/src/routes/blocks/[number]/+page.svelte @@ -1,206 +1,128 @@ - -
-
- -
-
-
-

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/certificates/delegations/+page.server.ts b/applications/viewer/src/routes/certificates/delegations/+page.server.ts index 0cc47436..a0ad1c75 100644 --- a/applications/viewer/src/routes/certificates/delegations/+page.server.ts +++ b/applications/viewer/src/routes/certificates/delegations/+page.server.ts @@ -1,37 +1,4 @@ -import type { PageServerLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/staking/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({params, url}) => { - const page = url.searchParams.get('page') || '1'; - const count = url.searchParams.get('count') || '10'; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/stake/delegations?page=${page}&count=${count}`; - console.log('Fetching delegations from:', apiUrl); - - try { - const response = await fetch(apiUrl); - console.log('Response status:', response.status); - - const data = await response.json(); - console.log('Raw API response:', JSON.stringify(data, null, 2)); - - if (Array.isArray(data)) { - console.log('First delegation entry:', data[0]); - console.log('Total delegations:', data.length); - } - - return { - delegations: data, - page: page, - count: count - }; - } catch (error) { - console.error('Error fetching delegations:', error); - return { - delegations: [], - page: page, - count: count - }; - } -}; +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('delegations', url, fetch); diff --git a/applications/viewer/src/routes/certificates/delegations/+page.svelte b/applications/viewer/src/routes/certificates/delegations/+page.svelte index 94267d02..7d94755b 100644 --- a/applications/viewer/src/routes/certificates/delegations/+page.svelte +++ b/applications/viewer/src/routes/certificates/delegations/+page.svelte @@ -1,268 +1,7 @@ - -
- - {#if showToast} -
- {toastMessage} -
- {/if} - -

Delegations

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - {#each data.delegations as delegation} - - - - - - - {/each} - -
Stake AddressPool IDBlockTransaction Hash
-
- - {truncateAddress(delegation.address)} - - {#if delegation.address} - - {/if} -
-
-
- - {truncateHash(delegation.pool_id)} - - {#if delegation.pool_id} - - {/if} -
-
- - {delegation.block_number} - - -
- - {truncateHash(delegation.tx_hash)} - - {#if delegation.tx_hash} - - {/if} -
-
-
-
-
- - - -
-
-
- - + diff --git a/applications/viewer/src/routes/certificates/pool-registrations/+page.server.ts b/applications/viewer/src/routes/certificates/pool-registrations/+page.server.ts index c0c8adf9..1b390807 100644 --- a/applications/viewer/src/routes/certificates/pool-registrations/+page.server.ts +++ b/applications/viewer/src/routes/certificates/pool-registrations/+page.server.ts @@ -1,32 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/staking/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page') || "1"; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/pools/registrations?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const registrations = data; - console.log(data); - - if (res.ok) { - return { - registrations, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch pool registrations.' } - }; -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('pool-registrations', url, fetch); diff --git a/applications/viewer/src/routes/certificates/pool-registrations/+page.svelte b/applications/viewer/src/routes/certificates/pool-registrations/+page.svelte index 75f74be9..7d94755b 100644 --- a/applications/viewer/src/routes/certificates/pool-registrations/+page.svelte +++ b/applications/viewer/src/routes/certificates/pool-registrations/+page.svelte @@ -1,283 +1,7 @@ -
- - {#if showToast} -
- {toastMessage} -
- {/if} - -

Pool Registrations

- - {#if error} - - {/if} - -
-
- - - -
-
- -
- - - - - - - - - - - - - - {#if loading} - - - - {:else if data.registrations.length === 0} - - - - {:else} - {#each data.registrations as registration: PoolRegistration, index} - - - - - - - - - - {/each} - {/if} - -
PoolPledge (ADA)Cost (ADA)Margin (%)Owner(s)BlockTransaction Hash
-
- - - - -
-
- No pool registrations found -
-
- - {truncate(registration.pool_id_bech32, 35, "...")} - - -
-
- {lovelaceToAda(registration.pledge, 2)} - - {lovelaceToAda(registration.cost, 0)} - - {(registration.margin * 100).toFixed(2)} - - {#each registration.pool_owners as owner} -
- {truncate(owner, 20, "...")} - -
- {/each} -
- - {registration.block_number} - - -
- - {truncate(registration.tx_hash, 20)} - - -
-
-
- -
-
- - - -
-
-
- - + diff --git a/applications/viewer/src/routes/certificates/pool-retirements/+page.server.ts b/applications/viewer/src/routes/certificates/pool-retirements/+page.server.ts index 076989d3..cbc19cfc 100644 --- a/applications/viewer/src/routes/certificates/pool-retirements/+page.server.ts +++ b/applications/viewer/src/routes/certificates/pool-retirements/+page.server.ts @@ -1,32 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/staking/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page') || "1"; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/pools/retirements?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const retirements = data; - console.log(data); - - if (res.ok) { - return { - retirements, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch pool retirements.' } - }; -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('pool-retirements', url, fetch); diff --git a/applications/viewer/src/routes/certificates/pool-retirements/+page.svelte b/applications/viewer/src/routes/certificates/pool-retirements/+page.svelte index d3a0490e..7d94755b 100644 --- a/applications/viewer/src/routes/certificates/pool-retirements/+page.svelte +++ b/applications/viewer/src/routes/certificates/pool-retirements/+page.svelte @@ -1,133 +1,7 @@ -
-

Pool Retirements

-
-
- - - -
-
-
- - - - - - - - - - - - {#each data.retirements as retirement: PoolRetirement, index} - - - - - - - - {/each} - -
PoolEpochRetirement EpochBlockTransaction Hash
{truncate(retirement.pool_id_bech32, 35, "...")}{retirement.epoch}{retirement.retirement_epoch} - - {retirement.block_number} - - - - {truncate(retirement.tx_hash, 30)} - -
-
- -
-
- - - -
-
-
+ diff --git a/applications/viewer/src/routes/certificates/stake-deregistrations/+page.svelte b/applications/viewer/src/routes/certificates/stake-deregistrations/+page.svelte deleted file mode 100644 index 9450eb56..00000000 --- a/applications/viewer/src/routes/certificates/stake-deregistrations/+page.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -
- - {#if showToast} -
- {toastMessage} -
- {/if} - -

Stake Deregistrations

- -
- - - - - - - - - - {#each data.stakeDeregistrations as deregistration: StakeDeregistration, index} - - - - - - {/each} - -
AddressBlockTransaction Hash
-
- {truncate(deregistration.address, 20, "...")} - -
-
- - {deregistration.block_number} - - -
- - {truncate(deregistration.tx_hash, 20)} - - -
-
-
- -
- - \ No newline at end of file diff --git a/applications/viewer/src/routes/certificates/stake-registrations/+page.svelte b/applications/viewer/src/routes/certificates/stake-registrations/+page.svelte deleted file mode 100644 index 061b93db..00000000 --- a/applications/viewer/src/routes/certificates/stake-registrations/+page.svelte +++ /dev/null @@ -1,130 +0,0 @@ - - -
- - {#if showToast} -
- {toastMessage} -
- {/if} - -

Stake Registrations

- -
- - - - - - - - - - {#each data.stakeRegistrations as registration: StakeRegistration, index} - - - - - - {/each} - -
AddressBlockTransaction Hash
-
- {truncate(registration.address, 20, "...")} - -
-
- - {registration.block_number} - - -
- - {truncate(registration.tx_hash, 20)} - - -
-
-
- -
- - \ No newline at end of file diff --git a/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.server.ts b/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.server.ts index 7f1caa2c..46b23aad 100644 --- a/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.server.ts +++ b/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.server.ts @@ -1,32 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/staking/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page') || "1"; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/stake/deregistrations?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const deregistrations = data; - console.log(data); - - if (res.ok) { - return { - deregistrations, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch stake registrations.' } - }; -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('stakekey-deregistrations', url, fetch); diff --git a/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.svelte b/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.svelte index 2c61bd6c..7d94755b 100644 --- a/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.svelte +++ b/applications/viewer/src/routes/certificates/stakekey-deregistrations/+page.svelte @@ -1,133 +1,7 @@ - -
-

Stake Key De-registrations

-
-
- - - -
-
-
- - - - - - - - - - - {#each data.deregistrations as deregistration: StakeDeRegistration, index} - - - - - - - {/each} - -
AddressBlockTimeTransaction Hash
{deregistration.address} - - {deregistration.block_number} - - {getDate(deregistration.block_time)} - - {truncate(deregistration.tx_hash, 30, "...")} - -
-
-
-
- - - -
-
-
+ diff --git a/applications/viewer/src/routes/certificates/stakekey-registrations/+page.server.ts b/applications/viewer/src/routes/certificates/stakekey-registrations/+page.server.ts index 20b29886..3868ecb7 100644 --- a/applications/viewer/src/routes/certificates/stakekey-registrations/+page.server.ts +++ b/applications/viewer/src/routes/certificates/stakekey-registrations/+page.server.ts @@ -1,32 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/staking/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page') || "1"; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/stake/registrations?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const registrations = data; - console.log(data); - - if (res.ok) { - return { - registrations, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch stake registrations.' } - }; -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('stakekey-registrations', url, fetch); diff --git a/applications/viewer/src/routes/certificates/stakekey-registrations/+page.svelte b/applications/viewer/src/routes/certificates/stakekey-registrations/+page.svelte index 02ee2e19..7d94755b 100644 --- a/applications/viewer/src/routes/certificates/stakekey-registrations/+page.svelte +++ b/applications/viewer/src/routes/certificates/stakekey-registrations/+page.svelte @@ -1,152 +1,7 @@ - -
-

Stake Key Registrations

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - - {#each data.registrations as registration: StakeRegistration, index} - - - - - - - {/each} - -
AddressBlockTimeTransaction Hash
{registration.address} - - {registration.block_number} - - {getDate(registration.block_time)} - - {truncate(registration.tx_hash, 30, "...")} - -
-
-
-
- - - -
-
-
+ diff --git a/applications/viewer/src/routes/datums/[hash]/+page.server.ts b/applications/viewer/src/routes/datums/[hash]/+page.server.ts index 9a4e10f8..647ada4d 100644 --- a/applications/viewer/src/routes/datums/[hash]/+page.server.ts +++ b/applications/viewer/src/routes/datums/[hash]/+page.server.ts @@ -1,28 +1,17 @@ -import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; import { error } from '@sveltejs/kit'; +import { tryApi } from '$lib/api/client'; +import type { PageServerLoad } from './$types'; +/* A datum by hash: the store keeps its JSON and CBOR once a transaction includes it in its witnesses. */ export const load: PageServerLoad = async ({ params, fetch }) => { - const hash = params.hash; - - if (!/^[0-9a-fA-F]{64}$/.test(hash)) { - throw error(400, 'Invalid datum hash. Expected 64 hex characters.'); - } - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - const [datumJson, datumCbor] = await Promise.all([ - fetch(`${INDEXER_BASE_URL}/scripts/datum/${hash}`).then(r => r.ok ? r.json() : null), - fetch(`${INDEXER_BASE_URL}/scripts/datum/${hash}/cbor`).then(r => r.ok ? r.json() : null), - ]); + const hash = params.hash.trim().toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(hash)) error(404, 'That is not a datum hash (64 hex characters).'); - if (!datumJson && !datumCbor) { - throw error(404, 'Datum not found'); - } + const [json, cbor] = await Promise.all([ + tryApi<{ json_value?: unknown }>(`/scripts/datum/${hash}`, { fetch }), + tryApi<{ cbor?: string }>(`/scripts/datum/${hash}/cbor`, { fetch }) + ]); + if (!json && !cbor) error(404, 'No datum with that hash on this chain. Only datums included in a transaction are stored.'); - return { - hash, - datumJson, - datumCbor, - }; + return { hash, json: json?.json_value ?? null, cbor: cbor?.cbor ?? null }; }; diff --git a/applications/viewer/src/routes/datums/[hash]/+page.svelte b/applications/viewer/src/routes/datums/[hash]/+page.svelte index 5d96e627..9210572b 100644 --- a/applications/viewer/src/routes/datums/[hash]/+page.svelte +++ b/applications/viewer/src/routes/datums/[hash]/+page.svelte @@ -1,110 +1,26 @@ - -
-
- -
-
-

- Datum Hash -

-
- -
-
-
- - -
-
- - - -
-
- - -
- {#if activeTabIndex === JSON_TAB} -
- {#if datumJson} - - {:else} -
No JSON representation available.
- {/if} -
- {/if} +Datum {shorten(data.hash, 8, 6)} · Yaci Viewer - {#if activeTabIndex === CBOR_TAB} -
- {#if datumCbor && datumCbor.cbor} -
- -
-
-
{datumCbor.cbor}
-
- {:else} -
No CBOR representation available.
- {/if} -
- {/if} -
+ + {#snippet icon()}{/snippet} +
+ {data.hash} +
-
+ - +{#if data.cbor} + +{:else} + +{/if} diff --git a/applications/viewer/src/routes/epoch-stakes/+page.server.ts b/applications/viewer/src/routes/epoch-stakes/+page.server.ts index c3fb3a4f..8b32de80 100644 --- a/applications/viewer/src/routes/epoch-stakes/+page.server.ts +++ b/applications/viewer/src/routes/epoch-stakes/+page.server.ts @@ -1,28 +1,37 @@ +import { api, paging, tryApi } from '$lib/api/client'; +import { poolDistribution } from '$lib/staking/distribution'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async ({ fetch, url }) => { - const page = url.searchParams.get('page') || '1'; - const count = url.searchParams.get('count') || '15'; - const epoch = url.searchParams.get('epoch'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - try { - // Fetch current epoch from chain tip - const response = await fetch(`${env.PUBLIC_INDEXER_BASE_URL}/api/v1/chain/tip`); - if (!response.ok) throw new Error('Failed to fetch chain tip'); - const data = await response.json(); - - return { - currentEpoch: data.epoch, - page, - count - }; - } catch (error) { - console.error('Error loading current epoch:', error); - return { - currentEpoch: 0, - page, - count - }; - } -}; \ No newline at end of file +/* The stake snapshot for an epoch: total, by pool, and by account. The account list is 1-based. */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const latest = await tryApi('/epochs/latest', { fetch }); + const current = Number(latest?.epoch ?? 0); + const asked = parseInt(url.searchParams.get('epoch') ?? '', 10); + const epoch = Number.isFinite(asked) && asked >= 0 ? asked : current; + const { page, count } = paging(url, 20); + + const [totals, distribution, accounts] = await Promise.all([ + Promise.all([5, 4, 3, 2, 1, 0].map((i) => (epoch - i < 0 ? null : tryApi(`/epochs/${epoch - i}/total-stake`, { fetch, timeoutMs: 8000 })))), + poolDistribution(epoch, fetch), + api(`/epochs/${epoch}/stake`, { fetch, params: { page, count }, timeoutMs: 25000 }).then( + (r) => ({ rows: r ?? [], error: null as string | null }), + (e) => ({ rows: [] as Any[], error: (e as Error).message }) + ) + ]); + + const trend = totals.filter((t): t is Any => !!t).map((t) => ({ epoch: t.epoch as number, stake: Number(t.active_stake) })); + return { + epoch, + current, + page, + count, + total: trend.find((t) => t.epoch === epoch)?.stake ?? null, + trend, + distribution, + accounts: accounts.rows, + listError: accounts.error + }; +}; diff --git a/applications/viewer/src/routes/epoch-stakes/+page.svelte b/applications/viewer/src/routes/epoch-stakes/+page.svelte index b6ead57c..d0572a4c 100644 --- a/applications/viewer/src/routes/epoch-stakes/+page.svelte +++ b/applications/viewer/src/routes/epoch-stakes/+page.svelte @@ -1,250 +1,127 @@ -
-

Epoch Stakes

- - -
-
-
- -
- - -
-
-
-
- - {#if error} -
- {error} -
- {/if} - - - {#if totalStake !== null} -
-

Total Active Stake

-

- {formatAda(totalStake.active_stake)} ₳ -

-
- {/if} - - -
- {#if loading} -
- -
- {:else if stakes.length === 0} -
-

No stake data available for this epoch.

-
- {:else} - -
-
- - - -
-
-
- - - - - - - - - - - {#each stakes as stake} - - - - - - - {/each} - -
Stake AddressAmount (₳)Pool IDDelegation Epoch
- - {stake.address} - - {formatAda(stake.amount)} - - {stake.pool_id} - - {stake.delegation_epoch}
-
- - - {/if} -
-
\ No newline at end of file +Epoch stakes · epoch {data.epoch} · Yaci Viewer + + + {#snippet icon()}{/snippet} + The stake snapshot used for leader election and rewards{data.epoch === data.current ? ' in the current epoch' : ''}. + {#snippet actions()} + {data.epoch - 1} + {data.epoch + 1} + {/snippet} + + +
+ = 0 ? '+' : ''}${change.toFixed(2)}% vs epoch ${data.epoch - 1}` : 'total delegated to pools'} + trend={data.trend.map((t) => t.stake)} + > + {#snippet icon()}{/snippet} + + +
+ Look up in this epoch +
+ + +
+ {#if problem}{problem}{/if} +
+
+ +
+ + {#snippet icon()}{/snippet} + {#if top.length} + + {#if data.total && !data.distribution.complete} +

These pools hold {percent(covered, data.total, 2)} of the active stake.

+ {/if} + {:else} + + {/if} +
+ + + {#if data.listError} + + {:else} + r.address} + dense + emptyTitle="No stake in this epoch" + > + {#snippet cell(r, key)} + {#if key === 'address'}{shorten(r.address, 12, 6)} + {:else if key === 'pool'}{shorten(r.pool_id ?? r.pool_hash, 10, 6)} + {:else if key === 'since'}epoch {r.delegation_epoch ?? '—'} + {:else}{/if} + {/snippet} + + + {/if} + +
diff --git a/applications/viewer/src/routes/epoch-stakes/accounts/+page.server.ts b/applications/viewer/src/routes/epoch-stakes/accounts/+page.server.ts index 2b4a0c5b..b4d3e4b1 100644 --- a/applications/viewer/src/routes/epoch-stakes/accounts/+page.server.ts +++ b/applications/viewer/src/routes/epoch-stakes/accounts/+page.server.ts @@ -1,50 +1,22 @@ -import { error } from '@sveltejs/kit'; -import { env } from '$env/dynamic/public'; +import { tryApi } from '$lib/api/client'; +import type { PageServerLoad } from './$types'; -export const load = async ({ url }) => { - const address = url.searchParams.get('address'); - const epoch = url.searchParams.get('epoch'); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - if (!address || !epoch) { - return { - stakeData: null, - address: null, - epoch: null - }; - } +/* One stake account's stake in an epoch's snapshot, and a few epochs before it for context. */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const latest = await tryApi('/epochs/latest', { fetch }); + const current = Number(latest?.epoch ?? 0); + const asked = parseInt(url.searchParams.get('epoch') ?? '', 10); + const epoch = Number.isFinite(asked) && asked >= 0 ? asked : current; + const address = (url.searchParams.get('address') ?? '').trim(); + const valid = /^(stake|stake_test)1[0-9a-z]+$/.test(address); + if (!address || !valid) return { address: address || null, valid: !address || valid, epoch, current, stake: null, history: [] as Any[] }; - try { - const stakeUrl = `${env.PUBLIC_INDEXER_BASE_URL}/epochs/${epoch}/accounts/${address}/stake`; - console.log('Fetching stake data from:', stakeUrl); - - const response = await fetch(stakeUrl); - - if (!response.ok) { - console.error('Failed to fetch stake data:', { - status: response.status, - statusText: response.statusText, - url: response.url - }); - const errorText = await response.text(); - console.error('Error response:', errorText); - throw error(response.status, `Failed to fetch stake data: ${errorText}`); - } - - const data = await response.json(); - console.log('Stake data response:', data); - - return { - stakeData: data, - address, - epoch - }; - } catch (err) { - console.error('Error in load function for stake data:', err); - const message = err instanceof Error ? err.message : 'Failed to load stake data'; - if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') { - throw error(err.status, message); - } else { - throw error(500, message); - } - } -}; \ No newline at end of file + const history = await Promise.all( + [0, 1, 2, 3, 4, 5].map((i) => (epoch - i < 0 ? null : tryApi(`/epochs/${epoch - i}/accounts/${address}/stake`, { fetch, timeoutMs: 8000 }))) + ); + const rows = history.filter((h): h is Any => !!h); + return { address, valid, epoch, current, stake: rows.find((r) => r.epoch === epoch) ?? null, history: rows }; +}; diff --git a/applications/viewer/src/routes/epoch-stakes/accounts/+page.svelte b/applications/viewer/src/routes/epoch-stakes/accounts/+page.svelte index 558db4dc..03eeb93f 100644 --- a/applications/viewer/src/routes/epoch-stakes/accounts/+page.svelte +++ b/applications/viewer/src/routes/epoch-stakes/accounts/+page.svelte @@ -1,176 +1,78 @@ -
- - {#if toastVisible} -
- {toastMessage} -
- {/if} +Account stake · epoch {data.epoch} · Yaci Viewer + + + {#snippet icon()}{/snippet} + {#if data.address && data.valid}{shorten(data.address, 14, 8)}{:else}A stake account's stake in one epoch's snapshot.{/if} + -
-

Search Stake Address

-
-
- - -
-
- -
- - -
-
-
-
+
+ + + +
+{#if problem || !data.valid}

{problem ?? 'That is not a stake address.'}

{/if} - {#if stakeData} -
-

Stake Address Details

-
-
-

Stake Address

-
-

- {truncateAddress(stakeData.address, 12, 12)} - -

- -
-
-
-

Epoch

-

{stakeData.epoch}

-
-
-

Amount

-

{formatAmount(stakeData.amount)} ₳

-
-
-

Delegation Epoch

-

{stakeData.delegation_epoch}

-
-
-

Pool ID

-
- - {truncateAddress(stakeData.pool_id, 12, 12)} - - - -
-
-
-
- {/if} -
\ No newline at end of file +{#if data.address && data.valid} + {#if data.stake} +
+ +
+ + {data.stake.epoch} + {#if poolId}{shorten(poolId, 12, 8)} {:else}—{/if} + epoch {data.stake.delegation_epoch ?? '—'} +
+ Rewards and withdrawals +
+ + {#if series.length > 1}
Number(s.amount))} width={320} height={56} />
{/if} +
    + {#each data.history as h (h.epoch)} +
  • + epoch {h.epoch} + +
  • + {/each} +
+
+
+ {:else} +
+ {/if} +{:else if !data.address} +
+{/if} diff --git a/applications/viewer/src/routes/epoch-stakes/pools/+page.server.ts b/applications/viewer/src/routes/epoch-stakes/pools/+page.server.ts index a9e74bc0..7d00a5ab 100644 --- a/applications/viewer/src/routes/epoch-stakes/pools/+page.server.ts +++ b/applications/viewer/src/routes/epoch-stakes/pools/+page.server.ts @@ -1,88 +1,40 @@ -import { error } from '@sveltejs/kit'; -import { env } from '$env/dynamic/public'; - -export const load = async ({ url }) => { - const poolId = url.searchParams.get('id'); - const epoch = url.searchParams.get('epoch'); - const page = Number(url.searchParams.get('page')) || 1; - const itemsPerPage = 15; - - if (!poolId || !epoch) { - // Return default structure if required params are missing - return { - stakes: [], - // totalItems: 0, // We don't have a reliable total count here - currentPage: 1, - itemsPerPage, - poolId: null, - epoch: null, - totalStake: 0 - }; - } - - try { - // Fetch Pool's Total Stake for the Epoch - let totalStake = 0; - try { - const totalStakeUrl = `${env.PUBLIC_INDEXER_BASE_URL}/epochs/${epoch}/pools/${poolId}/stake`; - console.log('Fetching pool total stake from:', totalStakeUrl); - const totalStakeResponse = await fetch(totalStakeUrl); - if (totalStakeResponse.ok) { - const stakeData = await totalStakeResponse.json(); - totalStake = stakeData.active_stake || 0; - console.log('Pool total stake response:', stakeData); - } else { - console.error('Failed to fetch pool total stake:', { - status: totalStakeResponse.status, - statusText: totalStakeResponse.statusText, - url: totalStakeUrl - }); - } - } catch (stakeErr) { - console.error('Error fetching pool total stake:', stakeErr); - } - - // Fetch Delegators for the current page - const delegatorsUrl = `${env.PUBLIC_INDEXER_BASE_URL}/epochs/${epoch}/pools/${poolId}/delegators?count=${itemsPerPage}&page=${page}`; - console.log('Fetching pool delegators from:', delegatorsUrl); - - const response = await fetch(delegatorsUrl); - - if (!response.ok) { - console.error('Failed to fetch pool delegators:', { - status: response.status, - statusText: response.statusText, - url: response.url - }); - const errorText = await response.text(); - console.error('Error response:', errorText); - // Throw error only if delegator fetch fails, as it's crucial - throw error(response.status, `Failed to fetch pool delegators data: ${errorText}`); - } - - const delegatorsData = await response.json(); - console.log('Pool delegators response:', delegatorsData); - - // Ensure delegatorsData is an array - const stakesArray = Array.isArray(delegatorsData) ? delegatorsData : []; - - return { - stakes: stakesArray, - // totalItems: stakesArray.length, // Keep client logic based on stakes.length < itemsPerPage - currentPage: page, - itemsPerPage, - poolId, - epoch, - totalStake // This is now from the separate /stake endpoint - }; - } catch (err) { - console.error('Error in load function for pool stakes:', err); - // Use a more specific error message if possible - const message = err instanceof Error ? err.message : 'Failed to load pool stake data'; - if (err && typeof err === 'object' && 'status' in err && typeof err.status === 'number') { - throw error(err.status, message); - } else { - throw error(500, message); - } - } -}; \ No newline at end of file +import { api, paging, tryApi } from '$lib/api/client'; +import { isPoolId } from '$lib/staking/pool'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* A pool's stake and delegators in an epoch's snapshot. Without ?id it is a lookup form. Delegators are 1-based. */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const latest = await tryApi('/epochs/latest', { fetch }); + const current = Number(latest?.epoch ?? 0); + const asked = parseInt(url.searchParams.get('epoch') ?? '', 10); + const epoch = Number.isFinite(asked) && asked >= 0 ? asked : current; + const id = (url.searchParams.get('id') ?? '').trim(); + const { page, count } = paging(url, 20); + + if (!id) return { id: null, valid: true, epoch, current, page, count, stake: null, total: null, rows: [] as Any[], listError: null }; + if (!isPoolId(id)) return { id, valid: false, epoch, current, page, count, stake: null, total: null, rows: [] as Any[], listError: null }; + + const [stake, total, rows] = await Promise.all([ + tryApi(`/epochs/${epoch}/pools/${id}/stake`, { fetch, timeoutMs: 10000 }), + tryApi(`/epochs/${epoch}/total-stake`, { fetch, timeoutMs: 10000 }), + api(`/epochs/${epoch}/pools/${id}/delegators`, { fetch, params: { page, count }, timeoutMs: 25000 }).then( + (r) => ({ rows: r ?? [], error: null as string | null }), + (e) => ({ rows: [] as Any[], error: (e as Error).message }) + ) + ]); + return { + id, + valid: true, + epoch, + current, + page, + count, + stake: stake ? { poolId: stake.pool_id as string, poolHash: stake.pool_hash as string, amount: Number(stake.active_stake) } : null, + total: total?.active_stake != null ? Number(total.active_stake) : null, + rows: rows.rows, + listError: rows.error + }; +}; diff --git a/applications/viewer/src/routes/epoch-stakes/pools/+page.svelte b/applications/viewer/src/routes/epoch-stakes/pools/+page.svelte index 0a589736..6c0659c0 100644 --- a/applications/viewer/src/routes/epoch-stakes/pools/+page.svelte +++ b/applications/viewer/src/routes/epoch-stakes/pools/+page.svelte @@ -1,236 +1,96 @@ - -
- {#if !hasRequiredParams} -
-

Search Pool Stakes

-
-
- - -
-
- -
- - -
-
-
-
- {:else} -
-

Pool Epoch Stakes

-
-
-

Pool ID

-
-

- {#if poolId} - {truncateAddress(poolId, 12, 12)} - - {/if} -

- -
-
-
-

Epoch

-

{epoch}

-
-
-

Total Stake

-

{formatAmount(totalStake)} ₳

-
-
+Pool stake · epoch {data.epoch} · Yaci Viewer + + + {#snippet icon()}{/snippet} + {#if data.id && data.valid}{shorten(poolId, 14, 8)} {:else}A pool's stake and delegators in one epoch's snapshot.{/if} + {#snippet actions()} + {#if data.id && data.valid} + {data.epoch - 1} + {data.epoch + 1} + {/if} + {/snippet} + - -
-
- - - -
-
+
+ + + +
+{#if problem || !data.valid}

{problem ?? 'That is not a pool id.'}

{/if} - -
- - - - - - - - - {#if stakes.length === 0} - - - - {:else} - {#each stakes as stake} - - - - - {/each} - {/if} - -
Stake AddressAmount (₳)
No stakes found
-
- {#if stake.address} - - {truncateAddress(stake.address, 12, 12)} - - - - {:else} - No stake address - {/if} -
-
{formatAmount(stake.amount)}
-
-
- {/if} -
+{#if data.id && data.valid} +
+
+
Active stake
+
{#if data.stake}{:else}—{/if}
+ {#if data.stake && data.total}
{percent(data.stake.amount, data.total, 3)} of all active stake
{/if} +
+ +
+
Pool details
+
Pledge, fees, owners, relays and blocks
+
+ +
+
-{#if toastVisible} -
- {toastMessage} -
-{/if} \ No newline at end of file +
+

Delegators

+ {#if data.listError} + + {:else} + r.address} + emptyTitle="No delegators in this epoch" + > + {#snippet cell(r, key)} + {#if key === 'address'}{shorten(r.address, 14, 8)} + {:else if key === 'since'}epoch {r.delegation_epoch ?? '—'} + {:else if key === 'amount'} + {:else}{data.stake ? percent(Number(r.amount), data.stake.amount, 2) : '—'}{/if} + {/snippet} + + + {/if} +
+{:else if !data.id} +
+{/if} diff --git a/applications/viewer/src/routes/epochs/+page.server.ts b/applications/viewer/src/routes/epochs/+page.server.ts new file mode 100644 index 00000000..7a4ebe6f --- /dev/null +++ b/applications/viewer/src/routes/epochs/+page.server.ts @@ -0,0 +1,46 @@ +import { error } from '@sveltejs/kit'; +import { paging, tryApi } from '$lib/api/client'; +import { activeStake, blockCount, fromAggregate, latestEpoch, uniformBounds, type EpochRow } from '$lib/epochs/epochs'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* + * Epochs, newest first. Uses the store's epoch aggregates when it has them (pages are 1-based); a fresh devnet has + * none, so the list is built from the current epoch's boundaries and per-epoch block counts instead. + */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const { page, count } = paging(url, 20, 50); + const [latest, agg] = await Promise.all([latestEpoch(fetch), tryApi('/epochs', { fetch, params: { page, count } })]); + + let rows: EpochRow[] = []; + let totalPages: number | null = null; + const aggregated = !!agg?.epochs?.length; + + if (aggregated) { + rows = agg.epochs.map((e: Any) => fromAggregate(e, latest)); + totalPages = agg.total_pages || null; + // Aggregates can lag the chain: make sure the current epoch is on page 1. + if (page === 1 && latest && !rows.some((r) => r.number === latest.epoch)) { + rows.unshift({ number: latest.epoch, start: latest.start, end: latest.end, exact: true, blocks: null, txs: null, output: null, fees: null, activeStake: null }); + } + } else if (latest) { + const top = latest.epoch - (page - 1) * count; + for (let n = top; n > top - count && n >= 0; n--) { + rows.push({ number: n, ...uniformBounds(latest, n), exact: true, blocks: null, txs: null, output: null, fees: null, activeStake: null }); + } + totalPages = Math.ceil((latest.epoch + 1) / count); + const counts = await Promise.all(rows.map((r) => blockCount(fetch, r.number))); + rows.forEach((r, i) => (r.blocks = counts[i])); + } else { + error(503, 'Yaci Store did not return epoch information.'); + } + + // Active stake is a separate, slow lookup (missing for a chain's first epochs), so it streams in after the table. + const stakes = Promise.all(rows.map((r) => (r.activeStake ? r.activeStake : activeStake(fetch, r.number, 8000)))).then((v) => + Object.fromEntries(rows.map((r, i) => [r.number, v[i]])) + ); + + return { rows, page, count, totalPages, latest, aggregated, stakes }; +}; diff --git a/applications/viewer/src/routes/epochs/+page.svelte b/applications/viewer/src/routes/epochs/+page.svelte new file mode 100644 index 00000000..44cdf32c --- /dev/null +++ b/applications/viewer/src/routes/epochs/+page.svelte @@ -0,0 +1,130 @@ + + +Epochs · Yaci Viewer + + + {#snippet icon()}{/snippet} + {#if current}Epoch {num(current.epoch)} is running · {duration(current.end - current.start)} per epoch{/if} + + +{#if current && data.page === 1} +
+ + + {(currentProgress * 100).toFixed(1)}% + +
+
Current epoch
+
{num(current.epoch)}
+
Ends in {duration(current.end - nowSec)}
+
{dateTime(current.end)}
+
+ +
+ + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + +
+{/if} + +
+ r.number} rowHref={(r) => `/epochs/${r.number}`} emptyTitle="No epochs yet"> + {#snippet cell(r, key)} + {#if key === 'epoch'} + {@const p = progressOf(r)} + + + + + {num(r.number)} + {#if p != null}current{/if} + + {#if p != null} + + {/if} + + + {:else if key === 'time'} + {#if r.start}{dateTime(r.start)}{:else}—{/if} + {:else if key === 'blocks'} + {#if r.blocks != null} +
+ {num(r.blocks)} + +
+ {:else}—{/if} + {:else if key === 'txs'} + {#if r.txs != null} +
+ {num(r.txs)} + +
+ {:else}—{/if} + {:else if key === 'output'} + {#if r.output != null}₳{adaCompact(r.output)}{:else}—{/if} + {:else if key === 'fees'} + {#if r.fees != null}₳{adaCompact(r.fees)}{:else}—{/if} + {:else if key === 'stake'} + {#await data.stakes} + + {:then stakes} + {#if stakes?.[r.number]}₳{adaCompact(stakes[r.number])}{:else}—{/if} + {/await} + {/if} + {/snippet} +
+ +
diff --git a/applications/viewer/src/routes/epochs/[epoch]/+page.server.ts b/applications/viewer/src/routes/epochs/[epoch]/+page.server.ts new file mode 100644 index 00000000..8c392eb4 --- /dev/null +++ b/applications/viewer/src/routes/epochs/[epoch]/+page.server.ts @@ -0,0 +1,65 @@ +import { error } from '@sveltejs/kit'; +import { paging, tryApi } from '$lib/api/client'; +import { activeStake, fromAggregate, latestEpoch, uniformBounds, type EpochRow } from '$lib/epochs/epochs'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* + * One epoch: its numbers, ada pots, and either its blocks (1-based pages) or its protocol parameters, depending on + * the tab. Works without epoch aggregates by falling back to the current epoch's boundaries and the block count. + */ +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const n = Number(params.epoch); + if (!Number.isInteger(n) || n < 0) error(404, 'Epochs are whole numbers, starting at 0.'); + + const tab = url.searchParams.get('tab') === 'params' ? 'params' : 'blocks'; + const { page, count } = paging(url, 20); + + const latest = await latestEpoch(fetch); + if (latest && n > latest.epoch) error(404, `Epoch ${n} hasn't started yet. The current epoch is ${latest.epoch}.`); + + // The block list is streamed: early epochs on big chains can take Yaci Store a long time to list. + // It uses the global fetch so the timeout can abort it (SvelteKit's server fetch didn't honour the abort here). + const blockList = tryApi(`/blocks/epoch/${n}`, { params: tab === 'blocks' ? { page, count } : { page: 1, count: 1 }, timeoutMs: 8000 }); + + const [agg, network, pots, protocol] = await Promise.all([ + tryApi(`/epochs/${n}`, { fetch }), + tryApi(`/network/epochs/${n}`, { fetch }), + tryApi(`/adapot/epochs/${n}`, { fetch }), + tab === 'params' ? tryApi>(`/epochs/${n}/parameters`, { fetch }) : null + ]); + + // Without aggregates (a fresh devnet) the block count comes from the list, which is quick there. + const listed = agg ? null : await blockList; + if (!latest && !agg && !listed?.total) error(404, `No data for epoch ${n} on this chain.`); + + const epoch: EpochRow = agg + ? fromAggregate(agg, latest) + : { number: n, ...(latest ? uniformBounds(latest, n) : { start: null, end: null }), exact: !!latest, blocks: listed?.total ?? null, txs: null, output: null, fees: null, activeStake: null }; + const isCurrent = latest?.epoch === n; + if (isCurrent && latest) { + epoch.start = latest.start; + epoch.end = latest.end; + epoch.exact = true; + } + + const supply = network?.supply?.circulating != null ? network.supply : null; + const knownStake = network?.stake?.active ?? null; + + return { + epoch, + isCurrent, + latestEpoch: latest?.epoch ?? null, + tab, + page, + count, + blockList: blockList.then((b) => (b ? { blocks: (b.blocks ?? []) as Any[], totalPages: (b.total_pages as number) || null } : null)), + supply: supply as { circulating: number; treasury: number; reserves: number; max: number } | null, + pots: pots as Record | null, + protocol, + // Active stake: from the network snapshot when it has it, otherwise a slower lookup streamed in later. + activeStake: knownStake != null ? Promise.resolve(knownStake as number | string | null) : activeStake(fetch, n, 10000) + }; +}; diff --git a/applications/viewer/src/routes/epochs/[epoch]/+page.svelte b/applications/viewer/src/routes/epochs/[epoch]/+page.svelte new file mode 100644 index 00000000..7190d93a --- /dev/null +++ b/applications/viewer/src/routes/epochs/[epoch]/+page.svelte @@ -0,0 +1,164 @@ + + + +Epoch {num(e.number)} · Yaci Viewer + + + {#snippet icon()}{/snippet} + {#if e.start}{dateTime(e.start)}{#if e.end} → {dateTime(e.end)}{/if}{/if} + {#snippet actions()} + {#if data.isCurrent}Current{/if} + Prev + Next + {/snippet} + + +
+ +
+ + {#if data.isCurrent} +
+
{(progress * 100).toFixed(1)}%
+
done
+
+ {:else} +
Finished
+ {/if} +
+
+ + {#if e.start && e.end}{duration(data.isCurrent ? e.end - nowSec : e.end - e.start)}{:else}—{/if} + + {#if e.start}{:else}—{/if} + {e.blocks != null ? num(e.blocks) : '—'} + {e.txs != null ? num(e.txs) : '—'} + {#if e.output != null}₳{adaCompact(e.output)}{:else}—{/if} + {#if e.fees != null}{:else}—{/if} + + {#await data.activeStake} + + {:then stake} + {#if stake}₳{adaCompact(stake)}{:else}No snapshot{/if} + {/await} + + {#if data.supply} + ₳{adaCompact(data.supply.circulating)} + {/if} +
+
+
+ + + {#snippet icon()}{/snippet} + {#snippet actions()}Details {/snippet} + {#if potRows.length} +
+ {#each potRows as r (r.label)} +
{r.label}
+
₳{adaCompact(r.value)}
+ {/each} +
+ {:else} +

No ada pot snapshot for this epoch. Pots are calculated at each epoch boundary.

+ {/if} + Rewards for epoch {num(e.number)} +
+
+ +
+ + {#if data.tab === 'params'} +
+ {#if data.protocol}{:else}{/if} +
+ {:else} + {#await data.blockList} + + {:then list} + {#if !list} + + {:else} + b.number} + rowHref={(b) => `/blocks/${b.number}`} + emptyTitle="No blocks in this epoch" + > + {#snippet cell(b, key)} + {#if key === 'number'} + + + #{num(b.number)} + + {:else if key === 'age'} + {:else if key === 'slot'}{num(b.slot)} + {:else if key === 'txs'}{b.tx_count} + {: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}{/if} + {/snippet} + + + {/if} + {/await} + {/if} +
diff --git a/applications/viewer/src/routes/governance/+page.server.ts b/applications/viewer/src/routes/governance/+page.server.ts index dd94fb57..fea5b84c 100644 --- a/applications/viewer/src/routes/governance/+page.server.ts +++ b/applications/viewer/src/routes/governance/+page.server.ts @@ -1,51 +1,38 @@ +import { tryApi } from '$lib/api/client'; +import { loadCommittee, loadConstitution } from '$lib/governance/data'; +import { toProposal } from '$lib/governance/labels'; +import { drepPower } from '$lib/server/insights'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async () => { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - const [proposalsRes, committeeRes, constitutionRes, epochRes] = await Promise.allSettled([ - fetch(`${INDEXER_BASE_URL}/governance-state/proposals?page=1&count=5&order=desc`), - fetch(`${INDEXER_BASE_URL}/governance/committees/current`), - fetch(`${INDEXER_BASE_URL}/governance/constitution`), - fetch(`${INDEXER_BASE_URL}/epochs/latest`) - ]); - - const proposals = proposalsRes.status === 'fulfilled' && proposalsRes.value.ok - ? await proposalsRes.value.json() - : []; - - const committee = committeeRes.status === 'fulfilled' && committeeRes.value.ok - ? await committeeRes.value.json() - : null; - - const constitution = constitutionRes.status === 'fulfilled' && constitutionRes.value.ok - ? await constitutionRes.value.json() - : null; - - const epochData = epochRes.status === 'fulfilled' && epochRes.value.ok - ? await epochRes.value.json() - : null; - - const currentEpoch = epochData?.epoch ?? 0; - - // Compute active committee member count: deduplicate by hash, keep only active (not expired) - let activeCommitteeCount = 0; - if (committee?.members) { - const uniqueHashes = new Set(); - for (const member of committee.members) { - if (!uniqueHashes.has(member.hash) && member.expired_epoch >= currentEpoch) { - uniqueHashes.add(member.hash); - } - } - activeCommitteeCount = uniqueHashes.size; - } - - return { - proposals, - committee, - constitution, - activeCommitteeCount, - currentEpoch - }; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* Governance at a glance. Every section degrades on its own: a devnet often has no stats, constitution or votes. */ +export const load: PageServerLoad = async ({ fetch }) => { + const [stats, proposals, active, committee, constitution, special, votes] = await Promise.all([ + tryApi('/governance-state/stats', { fetch }), + tryApi('/governance-state/proposals', { fetch, params: { page: 1, count: 6, order: 'desc' } }), + tryApi('/governance-state/proposals/current', { fetch }), + loadCommittee(fetch), + loadConstitution(fetch), + tryApi('/governance-state/dreps/special', { fetch }), + tryApi('/governance/votes', { fetch, params: { page: 1, count: 6, order: 'desc' } }) + ]); + return { + stats: stats as { + epoch?: number; + drep_stats?: { total_dreps: number; active_dreps: number; inactive_dreps: number; retired_dreps: number; total_voting_power: number }; + proposal_stats?: { active_proposals: number; ratified_proposals: number; expired_proposals: number }; + committee_stats?: { total_members: number; active_members: number; expired_members: number }; + vote_stats?: { total_votes_current_epoch: number; drep_votes: number; spo_votes: number; committee_votes: number }; + } | null, + proposals: (proposals ?? []).map(toProposal), + activeCount: active?.length ?? null, + committee, + constitution, + special: (special ?? []) as { drep_type: string; voting_power: number }[], + votes: (votes ?? []) as Any[], + // Who holds the voting power, from Yaci Store analytics where the store has it (null otherwise). Streamed. + drepPower: drepPower(fetch, stats?.epoch != null ? Number(stats.epoch) : null).catch(() => null) + }; }; diff --git a/applications/viewer/src/routes/governance/+page.svelte b/applications/viewer/src/routes/governance/+page.svelte index 6015092b..569cb741 100644 --- a/applications/viewer/src/routes/governance/+page.svelte +++ b/applications/viewer/src/routes/governance/+page.svelte @@ -1,118 +1,152 @@ -
-

Governance Overview

+Governance · Yaci Viewer + + + {#snippet icon()}{/snippet} + Conway on-chain governance: proposals, DReps, the constitutional committee and the constitution{#if data.stats?.epoch != null} · epoch {data.stats.epoch}{/if}. + + +
+ + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + + + {#snippet icon()}{/snippet} + +
- -
- - -
-

Committee

- {#if data.committee} -

{data.activeCommitteeCount}

-

active members ({data.committee.threshold_numerator}/{data.committee.threshold_denominator} threshold)

- {:else} -

No data

- {/if} -
-
+ +
+ + {#snippet icon()}{/snippet} + {#if data.committee && data.committee.members.length} +
+ + {threshold ? `${threshold.numerator}/${threshold.denominator}` : '—'} + +
+

{activeMembers} of {data.committee.members.length} members active.

+

An action needs {threshold ? `${Math.round((threshold.numerator / threshold.denominator) * 100)}%` : 'a threshold'} of the committee's yes votes.

+ See members +
+
+ {:else} +

No committee on this chain{data.committee ? ' yet' : ', or the store does not index it'}.

+ {/if} +
- -
-
-

Recent Proposals

- View all → -
+ + {#snippet icon()}{/snippet} + {#if data.constitution} +
+
Document
+
+
Since epoch
{data.constitution.active_epoch ?? '—'}
+
Guardrail script
{#if data.constitution.script}{shorten(data.constitution.script, 8, 6)}{:else}None{/if}
+
+
+ {:else} +

No constitution has been enacted on this chain.

+ {/if} +
+
+
- {#if data.proposals && data.proposals.length > 0} -
- - - - - - - - - - - - {#each data.proposals as proposal} - - - - - - - - {/each} - -
Tx HashTypeStatusDeposit (ADA)Epoch
- - {truncateHash(proposal.tx_hash)} - - {formatGovActionType(proposal.gov_action?.type || '')}{proposal.status}{formatAda(proposal.deposit)}{proposal.epoch}
-
- {:else} - - {/if} -
+
+ + {#snippet icon()}{/snippet} + {#snippet actions()}All votes {/snippet} + {#if data.votes.length} + + {:else} + + {/if} + - - +
+ {#each links as l (l.href)} + + + + {l.label} + {l.text} + + + + {/each} +
+ +{#if power.value}{/if} + +{#if noConfidence != null} +

Also delegated: ₳{adaCompact(noConfidence)} to "always no confidence".

+{/if} diff --git a/applications/viewer/src/routes/governance/anchor/+page.server.ts b/applications/viewer/src/routes/governance/anchor/+page.server.ts index 39062203..760088e8 100644 --- a/applications/viewer/src/routes/governance/anchor/+page.server.ts +++ b/applications/viewer/src/routes/governance/anchor/+page.server.ts @@ -1,54 +1,18 @@ +import { AnchorError, fetchAnchor } from '$lib/governance/anchor.server'; import type { PageServerLoad } from './$types'; +/* + * Fetches the document behind an on-chain anchor. All the safety rules (allowed schemes and ports, no private or + * local addresses, checked again after DNS and on redirects, size and time limits) live in anchor.server.ts. + */ export const load: PageServerLoad = async ({ url }) => { - const anchorUrlParam = url.searchParams.get('url'); - - if (!anchorUrlParam) { - // Return a specific error structure that the page can handle - return { anchorMetadata: null, error: 'Missing anchor URL parameter' }; - } - - let decodedUrl: string; - try { - decodedUrl = decodeURIComponent(anchorUrlParam); - } catch (e) { - console.error('Error decoding anchor URL:', e); - return { anchorMetadata: null, error: 'Invalid anchor URL encoding.' }; - } - - console.log(`Fetching anchor metadata from: ${decodedUrl}`); - - try { - const response = await fetch(decodedUrl, { - headers: { - 'Accept': 'application/ld+json, application/json' - } - }); - - if (!response.ok) { - throw new Error(`Failed to fetch anchor data (Status: ${response.status} ${response.statusText}) from ${decodedUrl}`); - } - - const data = await response.json(); - - // Structure the data for the page, including the raw JSON - const anchorMetadata = { - ...data, // Spread the fetched JSON data - rawJson: JSON.stringify(data, null, 2) // Add raw JSON string - }; - - return { - anchorMetadata: anchorMetadata, - error: null - }; - - } catch (e) { - console.error('Error fetching or parsing anchor data:', e); - const errorMessage = e instanceof Error ? e.message : 'An unknown error occurred while processing the anchor URL.'; - // Ensure consistent return structure on error - return { - anchorMetadata: null, - error: errorMessage - }; - } -}; \ No newline at end of file + const target = url.searchParams.get('url') ?? ''; + const expected = (url.searchParams.get('hash') ?? '').toLowerCase(); + if (!target) return { target, expected, result: null, error: 'No anchor URL given.' }; + try { + const result = await fetchAnchor(target); + return { target, expected, result, error: null }; + } catch (e) { + return { target, expected, result: null, error: e instanceof AnchorError ? e.message : 'Could not fetch the document.' }; + } +}; diff --git a/applications/viewer/src/routes/governance/anchor/+page.svelte b/applications/viewer/src/routes/governance/anchor/+page.svelte index aca38c4d..050aa624 100644 --- a/applications/viewer/src/routes/governance/anchor/+page.svelte +++ b/applications/viewer/src/routes/governance/anchor/+page.svelte @@ -1,464 +1,56 @@ -
-
-

Governance Anchor Metadata

- View Raw JSON -
- - {#if error} -
- {error} -
- {:else if anchorMetadata} -
- -
-
-

{sanitizeText(anchorMetadata.body?.title || 'Untitled')}

- -
- {#if anchorMetadata.authors} -
-

Authors

-

{formatAuthors(anchorMetadata.authors)}

-
- {/if} - -
- {#if anchorMetadata.created} -

Created: {formatDate(anchorMetadata.created)}

- {/if} - {#if anchorMetadata['@language']} -

Language: {sanitizeText(anchorMetadata['@language'])}

- {/if} -
-
- - {#if anchorMetadata.abstract} -
-

Abstract

-

{sanitizeText(anchorMetadata.abstract)}

-
- {/if} -
-
- - -
- {#if anchorMetadata.motivation} -
-
-

Motivation

-
-

{sanitizeText(anchorMetadata.motivation)}

-
-
-
- {/if} - - {#if anchorMetadata.rationale} -
-
-

Rationale

-
-

{sanitizeText(anchorMetadata.rationale)}

-
-
-
- {/if} - - {#if anchorMetadata.body} -
-
-

Body

-
- - {#if anchorMetadata.body.title} -
-

{sanitizeText(anchorMetadata.body.title)}

-
- {/if} - - {#if anchorMetadata.body.abstract} -
-
Abstract
-

{sanitizeText(anchorMetadata.body.abstract)}

-
- {/if} - - {#if anchorMetadata.body.motivation} -
-
Motivation
-

{sanitizeText(anchorMetadata.body.motivation)}

-
- {/if} - - {#if anchorMetadata.body.rationale} -
-
Rationale
-

{sanitizeText(anchorMetadata.body.rationale)}

-
- {/if} - - {#if anchorMetadata.body.comment} -
-
Comment
-

{sanitizeText(anchorMetadata.body.comment)}

-
- {/if} - - {#if anchorMetadata.body.dRepName} -
-
DRep Name
-

{sanitizeText(anchorMetadata.body.dRepName['@value'] || anchorMetadata.body.dRepName)}

-
- {/if} - - {#if anchorMetadata.body.email} -
-
Email
-

{sanitizeText(anchorMetadata.body.email['@value'] || anchorMetadata.body.email)}

-
- {/if} - - {#if anchorMetadata.body.bio} -
-
Bio
-

{sanitizeText(anchorMetadata.body.bio['@value'] || anchorMetadata.body.bio)}

-
- {/if} - - - {#if anchorMetadata.body.references && anchorMetadata.body.references.length > 0} -
-
References
-
    - {#each anchorMetadata.body.references as ref} - {#if ref.uri} - {@const sanitizedUrl = sanitizeUrl(ref.uri)} - {#if sanitizedUrl} -
  • - {sanitizeText(ref.label || ref.title || ref.uri)} - - - -
  • - {/if} - {/if} - {/each} -
-
- {/if} - - - {#if anchorMetadata.body.externalUpdates && anchorMetadata.body.externalUpdates.length > 0} -
-
External Updates
-
    - {#each anchorMetadata.body.externalUpdates as update} - {#if update.uri} - {@const sanitizedUrl = sanitizeUrl(update.uri)} - {#if sanitizedUrl} -
  • - {sanitizeText(update.title || update.uri)} - - - -
  • - {/if} - {/if} - {/each} -
-
- {/if} - - - {#each Object.entries(anchorMetadata.body) as [key, value]} - {#if !['title', 'abstract', 'motivation', 'rationale', 'comment', 'dRepName', 'email', 'bio', 'references', 'externalUpdates'].includes(key)} -
-
{sanitizeText(key.charAt(0).toUpperCase() + key.slice(1))}
- {#if typeof value === 'object' && value !== null} - {#if Array.isArray(value)} -
    - {#each value as item} -
  • - {#if typeof item === 'object' && item !== null} - {JSON.stringify(sanitizeObject(item))} - {:else} - {sanitizeText(String(item))} - {/if} -
  • - {/each} -
- {:else} -
{JSON.stringify(sanitizeObject(value), null, 2)}
- {/if} - {:else} -

{sanitizeText(String(value))}

- {/if} -
- {/if} - {/each} -
-
-
- {/if} -
- - -
- {#if anchorMetadata.references && anchorMetadata.references.length > 0} -
-
-

References

-
    - {#each anchorMetadata.references as ref} - {#if ref.url} - {@const sanitizedUrl = sanitizeUrl(ref.url)} - {#if sanitizedUrl} -
  • - {sanitizeText(ref.title || ref.url)} - - - -
  • - {/if} - {/if} - {/each} -
-
-
- {/if} - - {#if anchorMetadata.externalUpdates && anchorMetadata.externalUpdates.length > 0} -
-
-

External Updates

-
    - {#each anchorMetadata.externalUpdates as update} - {#if update.url} - {@const sanitizedUrl = sanitizeUrl(update.url)} - {#if sanitizedUrl} -
  • - {sanitizeText(update.description || update.url)} - - - -
  • - {/if} - {/if} - {/each} -
-
-
- {/if} - - {#if anchorMetadata.discussions && anchorMetadata.discussions.length > 0} -
-
-

Discussions

-
    - {#each anchorMetadata.discussions as discussion} - {#if discussion.url} - {@const sanitizedUrl = sanitizeUrl(discussion.url)} - {#if sanitizedUrl} -
  • - {sanitizeText(discussion.title || discussion.url)} - - - -
  • - {/if} - {/if} - {/each} -
-
-
- {/if} -
- - -
-
-

Raw JSON-LD Data

-
-
-                            {@html formatJsonForDisplay(anchorMetadata.rawJson)}
-                        
-
-
-
-
- {:else} -
- -
- {/if} -
- - \ No newline at end of file +Anchor document · Yaci Viewer + + + {#snippet icon()}{/snippet} + {data.target || '—'} + {#snippet actions()} + {#if verified === true}Hash matches the chain{/if} + {#if verified === false}Hash does not match{/if} + {#if openHref}Open original {/if} + {/snippet} + + +{#if data.error} +
+{:else if data.result} + {#if verified === false} +
+ +
The document at this URL has changed since it was anchored on chain. Its BLAKE2b-256 hash is {data.result.hash}, the chain expects {data.expected}.
+
+ {/if} +
+ + + +
+ +
+
Hash (BLAKE2b-256)
{data.result.hash}
+ {#if data.expected}
Hash on chain
{data.expected}
{/if} + {#if data.result.fetchedFrom !== data.target}
Fetched from
{data.result.fetchedFrom}
{/if} +
+
+ +
+
+{/if} diff --git a/applications/viewer/src/routes/governance/committee/+page.server.ts b/applications/viewer/src/routes/governance/committee/+page.server.ts index 200188d0..4888a71f 100644 --- a/applications/viewer/src/routes/governance/committee/+page.server.ts +++ b/applications/viewer/src/routes/governance/committee/+page.server.ts @@ -1,36 +1,4 @@ +import { loadCommittee } from '$lib/governance/data'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async () => { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - try { - const [committeeRes, epochRes] = await Promise.allSettled([ - fetch(`${INDEXER_BASE_URL}/governance/committees/current`), - fetch(`${INDEXER_BASE_URL}/epochs/latest`) - ]); - - const committee = committeeRes.status === 'fulfilled' && committeeRes.value.ok - ? await committeeRes.value.json() - : null; - - const epochData = epochRes.status === 'fulfilled' && epochRes.value.ok - ? await epochRes.value.json() - : null; - - const currentEpoch = epochData?.epoch ?? 0; - - if (!committee) { - throw new Error('Failed to fetch committee data'); - } - - return { committee, currentEpoch }; - } catch (error) { - console.error('Error loading committee:', error); - return { - committee: null, - currentEpoch: 0, - error: error instanceof Error ? error.message : 'An error occurred while loading committee' - }; - } -}; +export const load: PageServerLoad = async ({ fetch }) => ({ committee: await loadCommittee(fetch) }); diff --git a/applications/viewer/src/routes/governance/committee/+page.svelte b/applications/viewer/src/routes/governance/committee/+page.svelte index 57c6b648..bd3d0534 100644 --- a/applications/viewer/src/routes/governance/committee/+page.svelte +++ b/applications/viewer/src/routes/governance/committee/+page.svelte @@ -1,85 +1,74 @@ -
-

Constitutional Committee

+Constitutional committee · Yaci Viewer - {#if data.error} -
- {data.error} -
- {/if} + + {#snippet icon()}{/snippet} + The committee checks that governance actions follow the constitution{#if c?.epoch != null} · current epoch {c.epoch}{/if}. + - {#if data.committee} - -
-
- Threshold: - {data.committee.threshold_numerator}/{data.committee.threshold_denominator} -
-
- Active Members: - {activeCount} -
-
+{#if !c || !c.members.length} +
+{:else} +
+ +
+ + {t ? `${t.numerator}/${t.denominator}` : '—'} + +

An action passes the committee when {t ? `${Math.round((t.numerator / t.denominator) * 100)}%` : 'the threshold'} of active members vote yes.

+
+
+ +
+
{c.members.length}
seats
+
{active}
active
+
{c.members.length - active}
term ended
+
+
+
- - {#if data.committee.members && data.committee.members.length > 0} -
- - - - - - - - - - - - {#each data.committee.members as member} - - - - - - - - {/each} - -
HashCredential TypeStart EpochExpiry EpochStatus
- - {member.cred_type || ''}{member.start_epoch}{member.expired_epoch} - {#if member.expired_epoch >= currentEpoch} - Active - {:else} - Expired - {/if} -
-
- {:else} - - {/if} - {:else if !data.error} - - {/if} -
+
+ m.hash} + emptyTitle="No members" + > + {#snippet cell(m, key)} + {#if key === 'member'} + {:else if key === 'status'}{m.active ? 'Active' : 'Term ended'} + {:else if key === 'type'}{credLabel(m.cred_type)} + {:else} + {@const p = term(m)} +
+ {m.start_epoch ?? '—'} +
{#if p != null}
{/if}
+ {m.expired_epoch ?? '—'} +
+ {/if} + {/snippet} +
+
+{/if} diff --git a/applications/viewer/src/routes/governance/constitution/+page.server.ts b/applications/viewer/src/routes/governance/constitution/+page.server.ts index 804fa6d2..571c4054 100644 --- a/applications/viewer/src/routes/governance/constitution/+page.server.ts +++ b/applications/viewer/src/routes/governance/constitution/+page.server.ts @@ -1,43 +1,4 @@ +import { loadConstitution } from '$lib/governance/data'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async ({ fetch }) => { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - try { - const response = await fetch(`${INDEXER_BASE_URL}/governance/constitution`); - if (response.ok) { - const constitution = await response.json(); - return { constitution }; - } - - if (response.status !== 404) { - throw new Error(`Failed to fetch constitution (Status: ${response.status})`); - } - - const liveResponse = await fetch(`${INDEXER_BASE_URL}/governance/live/constitution`); - if (liveResponse.status === 404) { - return { constitution: null }; - } - if (!liveResponse.ok) { - throw new Error(`Failed to fetch live constitution (Status: ${liveResponse.status})`); - } - - const liveConstitution = await liveResponse.json(); - return { - constitution: { - active_epoch: liveConstitution.active_epoch ?? liveConstitution.epoch, - anchor_url: liveConstitution.anchor_url, - anchor_hash: liveConstitution.anchor_hash, - script: liveConstitution.script, - slot: liveConstitution.slot - } - }; - } catch (error) { - console.error('Error loading constitution:', error); - return { - constitution: null, - error: error instanceof Error ? error.message : 'An error occurred while loading constitution' - }; - } -}; +export const load: PageServerLoad = async ({ fetch }) => ({ constitution: await loadConstitution(fetch) }); diff --git a/applications/viewer/src/routes/governance/constitution/+page.svelte b/applications/viewer/src/routes/governance/constitution/+page.svelte index e24d8bdc..567021dd 100644 --- a/applications/viewer/src/routes/governance/constitution/+page.svelte +++ b/applications/viewer/src/routes/governance/constitution/+page.svelte @@ -1,62 +1,46 @@ -
-

Constitution

+Constitution · Yaci Viewer - {#if data.error} -
- {data.error} -
- {/if} + + {#snippet icon()}{/snippet} + The document every governance action is measured against, and the guardrail script that enforces parts of it. + - {#if data.constitution} -
-
-
-
- Active Epoch: - {data.constitution.active_epoch} -
- -
- Anchor URL: - - {#if data.constitution.anchor_url} - - {data.constitution.anchor_url} - - {:else} - None - {/if} - -
- -
- Anchor Hash: - {#if data.constitution.anchor_hash} - - {:else} - None - {/if} -
- -
- Script: - {#if data.constitution.script} - - {:else} - None - {/if} -
-
-
-
- {:else if !data.error} - - {/if} -
+{#if !c} +
+{:else} +
+ + {#snippet icon()}{/snippet} +
+ {c.anchor_url ?? '—'} + + Epoch {c.active_epoch ?? '—'} +
+ {#if c.anchor_url} Read the constitution{/if} +
+ + {#snippet icon()}{/snippet} + {#if c.script} +

Parameter changes and treasury withdrawals must pass this script.

+ + {:else} +

No guardrail script. Proposals are not checked by a script.

+ {/if} +
+
+{/if} diff --git a/applications/viewer/src/routes/governance/drep-deregistrations/+page.server.ts b/applications/viewer/src/routes/governance/drep-deregistrations/+page.server.ts index 879f4b82..dd18b40c 100644 --- a/applications/viewer/src/routes/governance/drep-deregistrations/+page.server.ts +++ b/applications/viewer/src/routes/governance/drep-deregistrations/+page.server.ts @@ -1,34 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/governance/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 0; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/governance/dreps/deregistrations?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const deregistrations = data; - console.log(data); - - if (res.ok) { - return { - deregistrations, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch drep deregistrations.' } - }; - -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('deregistrations', url, fetch); diff --git a/applications/viewer/src/routes/governance/drep-deregistrations/+page.svelte b/applications/viewer/src/routes/governance/drep-deregistrations/+page.svelte index 32b9ce91..2dc83b68 100644 --- a/applications/viewer/src/routes/governance/drep-deregistrations/+page.svelte +++ b/applications/viewer/src/routes/governance/drep-deregistrations/+page.svelte @@ -1,149 +1,9 @@ +DRep retirements · Yaci Viewer -
-

DRep De-registrations

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - - {#each data.deregistrations as drep: DRepDeRegistration, index} - - - - - - - {/each} - -
DRep IdTypeBlockTxn Hash
- {drep.drep_id} - {drep.cred_type}{drep.block_number}{drep.tx_hash}
-
-
-
- - - -
-
-
+ diff --git a/applications/viewer/src/routes/governance/drep-registrations/+page.server.ts b/applications/viewer/src/routes/governance/drep-registrations/+page.server.ts index 5d6e1c31..db123836 100644 --- a/applications/viewer/src/routes/governance/drep-registrations/+page.server.ts +++ b/applications/viewer/src/routes/governance/drep-registrations/+page.server.ts @@ -1,34 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/governance/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 0; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/governance/dreps/registrations?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const registrations = data; - console.log(data); - - if (res.ok) { - return { - registrations, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch drep registrations.' } - }; - -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('registrations', url, fetch); diff --git a/applications/viewer/src/routes/governance/drep-registrations/+page.svelte b/applications/viewer/src/routes/governance/drep-registrations/+page.svelte index c26c8cd5..ca6a18fa 100644 --- a/applications/viewer/src/routes/governance/drep-registrations/+page.svelte +++ b/applications/viewer/src/routes/governance/drep-registrations/+page.svelte @@ -1,167 +1,9 @@ +DRep registrations · Yaci Viewer -
-

DRep Registrations

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - - {#each data.registrations as registration, index} - - - - - - - {/each} - -
DRep IdTypeBlockTxn Hash
- - {toggleDRep(registration); }}>{registration.drep_id} - - {registration.cred_type}{registration.block_number}{registration.tx_hash}
-
-
-
- - - -
-
-
- - + diff --git a/applications/viewer/src/routes/governance/drep-updates/+page.server.ts b/applications/viewer/src/routes/governance/drep-updates/+page.server.ts index 42cdbc69..0c68d906 100644 --- a/applications/viewer/src/routes/governance/drep-updates/+page.server.ts +++ b/applications/viewer/src/routes/governance/drep-updates/+page.server.ts @@ -1,34 +1,4 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { loadCerts } from '$lib/governance/certs'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 0; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/governance/dreps/updates?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const updates = data; - console.log(data); - - if (res.ok) { - return { - updates, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch drep updates.' } - }; - -} +export const load: PageServerLoad = ({ url, fetch }) => loadCerts('updates', url, fetch); diff --git a/applications/viewer/src/routes/governance/drep-updates/+page.svelte b/applications/viewer/src/routes/governance/drep-updates/+page.svelte index 24cb5800..a4d7ae2c 100644 --- a/applications/viewer/src/routes/governance/drep-updates/+page.svelte +++ b/applications/viewer/src/routes/governance/drep-updates/+page.svelte @@ -1,167 +1,9 @@ +DRep updates · Yaci Viewer -
-

DRep Updates

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - - {#each data.updates as drep, index} - - - - - - - {/each} - -
DRep IdTypeBlockTxn Hash
- - {toggleDRep(drep); }}>{drep.drep_id} - - {drep.cred_type}{drep.block_number}{drep.tx_hash}
-
-
-
- - - -
-
-
- - + diff --git a/applications/viewer/src/routes/governance/dreps/+page.server.ts b/applications/viewer/src/routes/governance/dreps/+page.server.ts index 03f0de09..cb38ae4e 100644 --- a/applications/viewer/src/routes/governance/dreps/+page.server.ts +++ b/applications/viewer/src/routes/governance/dreps/+page.server.ts @@ -1,35 +1,27 @@ +import { error } from '@sveltejs/kit'; +import { api, paging, tryApi } from '$lib/api/client'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async ({ url }) => { - const page = url.searchParams.get('page') || '1'; - const count = url.searchParams.get('count') || '10'; - const order = url.searchParams.get('order') || 'desc'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - try { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const response = await fetch(`${INDEXER_BASE_URL}/governance-state/dreps?page=${page}&count=${count}&order=${order}`); - - if (!response.ok) { - throw new Error(`Failed to fetch dreps (Status: ${response.status})`); - } - - const dreps = await response.json(); - - return { - dreps, - page, - count, - order - }; - } catch (error) { - console.error('Error loading dreps:', error); - return { - dreps: [], - page, - count, - order, - error: error instanceof Error ? error.message : 'An error occurred while loading dreps' - }; - } -}; \ No newline at end of file +/* Registered DReps (/governance-state/dreps, 1-based) and the two built-in voting options. */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const { page, count } = paging(url, 20); + const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; + try { + const [dreps, special] = await Promise.all([ + api('/governance-state/dreps', { fetch, params: { page, count, order } }), + tryApi('/governance-state/dreps/special', { fetch }) + ]); + return { + dreps: (dreps ?? []) as { drep_id: string; drep_hash: string; deposit: number; status: string; voting_power: number; registration_slot: number; drep_type: string }[], + special: (special ?? []) as { drep_type: string; voting_power: number }[], + page, + count, + order + }; + } catch (e) { + error(503, (e as Error).message); + } +}; diff --git a/applications/viewer/src/routes/governance/dreps/+page.svelte b/applications/viewer/src/routes/governance/dreps/+page.svelte index 88ea14f9..e2819ae1 100644 --- a/applications/viewer/src/routes/governance/dreps/+page.svelte +++ b/applications/viewer/src/routes/governance/dreps/+page.svelte @@ -1,214 +1,95 @@ -
- - {#if showToast} -
- {toastMessage} -
- {/if} - -

DReps

-
-
- - - -
-
-
- - - - - - - - - - - - - - {#each data.dreps as drep} - - - - - - - - - - {/each} - -
DRep IDDRep HashStatusDepositVoting PowerTypeRegistration Slot
- {#if drep.drep_id} -
- {drep.drep_id} - -
- {/if} -
- {#if drep.drep_hash} -
- {truncateHash(drep.drep_hash)} - -
- {/if} -
- {drep.status} - - {formatAda(drep.deposit)} - - {formatVotingPower(drep.voting_power)} - - {drep.drep_type || ''} - - {drep.registration_slot} -
-
- -
-
- - - -
-
-
\ No newline at end of file +DReps · Yaci Viewer + + + {#snippet icon()}{/snippet} + Delegated representatives vote on governance actions with the stake delegated to them. + {#snippet actions()} +
+ (problem = null)} aria-label="Find a DRep" /> + +
+ {/snippet} +
+ +{#if problem}

{problem}

{/if} + +{#if abstain != null || noConfidence != null} +
+
+
Always abstain
+
₳{adaCompact(abstain)}
+
stake that counts as abstaining on every action
+
+
+
Always no confidence
+
₳{adaCompact(noConfidence)}
+
stake that votes no confidence in the committee
+
+
+{/if} + +
+ d.drep_id} + rowHref={(d) => `/governance/dreps/${d.drep_id}`} + emptyTitle="No DReps registered" + emptyText="Register a DRep on your devnet and it appears here." + > + {#snippet cell(d, key)} + {#if key === 'drep'} + + + {shorten(d.drep_id, 14, 8)} + + + {:else if key === 'status'}{statusLabel(d.status)} + {:else if key === 'power'} + {:else if key === 'deposit'} + {:else if key === 'type'}{credLabel(d.drep_type)} + {:else if key === 'slot'}{num(d.registration_slot)}{/if} + {/snippet} + + +
diff --git a/applications/viewer/src/routes/governance/dreps/[drepId]/+page.server.ts b/applications/viewer/src/routes/governance/dreps/[drepId]/+page.server.ts index 8d562719..3fc6357a 100644 --- a/applications/viewer/src/routes/governance/dreps/[drepId]/+page.server.ts +++ b/applications/viewer/src/routes/governance/dreps/[drepId]/+page.server.ts @@ -1,30 +1,50 @@ +import { error, redirect } from '@sveltejs/kit'; +import { paging, tryApi } from '$lib/api/client'; +import { drepIdCandidates } from '$lib/governance/drep'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async ({ params, url }) => { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const drepId = params.drepId; - const page = url.searchParams.get('page') || '1'; - const count = url.searchParams.get('count') || '15'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - const [drepRes, delegationsRes] = await Promise.allSettled([ - fetch(`${INDEXER_BASE_URL}/governance-state/dreps/${drepId}`), - fetch(`${INDEXER_BASE_URL}/governance/delegation-votes/drep/${drepId}?page=${page}&count=${count}`) - ]); +/* + * A DRep by CIP-129 id (or a CIP-105 id / hex hash, which redirect to it). State from /governance-state/dreps; + * activity, metadata and votes from the Blockfrost-compatible endpoints when the store has them. Lists are 1-based. + */ +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const raw = params.drepId.trim(); + const candidates = drepIdCandidates(raw); + if (!candidates.length) error(404, 'That is not a DRep id.'); - const drep = drepRes.status === 'fulfilled' && drepRes.value.ok - ? await drepRes.value.json() - : null; + let id = candidates[0]; + let drep: Any = null; + for (const c of candidates) { + drep = await tryApi(`/governance-state/dreps/${c}`, { fetch }); + if (drep) { + id = c; + break; + } + } + if (!drep) error(404, 'No DRep with that id on this chain.'); + if (id !== raw) redirect(302, `/governance/dreps/${id}${url.search}`); - const delegations = delegationsRes.status === 'fulfilled' && delegationsRes.value.ok - ? await delegationsRes.value.json() - : []; + const tab = url.searchParams.get('tab') === 'votes' ? 'votes' : 'delegators'; + const { page, count } = paging(url, 20); + const [activity, metadata, rows] = await Promise.all([ + tryApi(`/blockfrost/governance/dreps/${id}`, { fetch }), + tryApi(`/blockfrost/governance/dreps/${id}/metadata`, { fetch }), + tab === 'votes' + ? tryApi(`/blockfrost/governance/dreps/${id}/votes`, { fetch, params: { page, count, order: 'desc' } }) + : tryApi(`/governance/delegation-votes/drep/${id}`, { fetch, params: { page, count, order: 'desc' } }) + ]); - return { - drepId, - drep, - delegations: Array.isArray(delegations) ? delegations : [], - page, - count - }; + return { + drep: drep as { drep_id: string; drep_hash: string; deposit: number; status: string; voting_power: number; registration_slot: number; drep_type: string }, + activity: activity as { active_epoch?: number; last_active_epoch?: number; expired?: boolean; retired?: boolean; has_script?: boolean } | null, + metadata: metadata as { url?: string; hash?: string } | null, + tab, + page, + count, + rows: rows ?? [], + rowsAvailable: rows !== null + }; }; diff --git a/applications/viewer/src/routes/governance/dreps/[drepId]/+page.svelte b/applications/viewer/src/routes/governance/dreps/[drepId]/+page.svelte index b5e0ec90..344b5a93 100644 --- a/applications/viewer/src/routes/governance/dreps/[drepId]/+page.svelte +++ b/applications/viewer/src/routes/governance/dreps/[drepId]/+page.svelte @@ -1,168 +1,108 @@ -
- - -

DRep Detail

- - -
- -
- - {#if data.drep} - -
-
-
-
- Status: - {data.drep.status} -
- -
- Type: - {data.drep.drep_type || ''} -
- -
- DRep Hash: - {#if data.drep.drep_hash} - - {:else} - None - {/if} -
- -
- Deposit: - {formatAda(data.drep.deposit)} ADA -
- -
- Voting Power: - {data.drep.voting_power !== null ? formatAda(data.drep.voting_power) + ' ADA' : ''} -
- -
- Registration Slot: - {data.drep.registration_slot} -
-
-
-
- - -

Delegations

- - {#if data.delegations.length > 0} -
-
- - - -
-
- -
- - - - - - - - - - - {#each data.delegations as delegation} - - - - - - - {/each} - -
AddressTx HashBlockSlot
- {#if delegation.address} - - - - {:else} - - - {/if} - - {#if delegation.tx_hash} - - {truncateHash(delegation.tx_hash)} - - {:else} - - - {/if} - - {#if delegation.block_number} - - {delegation.block_number} - - {:else} - - - {/if} - {delegation.slot ?? '-'}
-
- -
-
- - - -
-
- {:else} - - {/if} - {:else} -
- DRep not found or failed to load. -
- {/if} -
+DRep {shorten(d.drep_id, 10, 6)} · Yaci Viewer + + + {#snippet icon()}{/snippet} +
+ {d.drep_id} + +
+ {#snippet actions()} + + {statusLabel(d.status)} + {credLabel(d.drep_type)} + {/snippet} +
+ +
+
+
+
Voting power
+
+
stake delegated to this DRep
+
+
+
Deposit
+
+
returned when the DRep retires
+
+
+
Activity
+ {#if a} +
Last voted in epoch {a.last_active_epoch ?? '—'}
+
active since epoch {a.active_epoch ?? '—'}{a.expired ? ' · expired' : ''}{a.retired ? ' · retired' : ''}
+ {:else} +
Registered at slot {num(d.registration_slot)}
+ {/if} +
+
+ + +
+ + {num(d.registration_slot)} + {#if data.metadata?.url}{:else}None published{/if} +
+
+ +
+ + {#if !data.rowsAvailable} + + {:else if data.tab === 'votes'} + v.tx_hash + v.cert_index} + rowHref={(v) => actionHref(v.proposal_tx_hash, v.proposal_cert_index)} + emptyTitle="No votes yet" + > + {#snippet cell(v, key)} + {#if key === 'vote'}{voteLabel(v.vote)} + {:else if key === 'action'}{shorten(v.proposal_id ?? v.proposal_tx_hash, 14, 8)} + + {:else}{shorten(v.tx_hash, 8, 6)}{/if} + {/snippet} + + + {:else} + r.tx_hash + r.cert_index} + emptyTitle="No delegations yet" + emptyText="Stake accounts that delegate their voting power to this DRep appear here." + > + {#snippet cell(r, key)} + {#if key === 'address'} + {:else if key === 'epoch'}{r.epoch ?? '—'} + {:else if key === 'tx'}{shorten(r.tx_hash, 8, 6)} + {:else}{/if} + {/snippet} + + + {/if} +
diff --git a/applications/viewer/src/routes/governance/govactions/+page.server.ts b/applications/viewer/src/routes/governance/govactions/+page.server.ts index a067938c..7d155f7b 100644 --- a/applications/viewer/src/routes/governance/govactions/+page.server.ts +++ b/applications/viewer/src/routes/governance/govactions/+page.server.ts @@ -1,34 +1,7 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { redirect } from '@sveltejs/kit'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 0; - const count = 15; - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const apiUrl = `${INDEXER_BASE_URL}/governance/proposals?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const govactions = data; - console.log(data); - - if (res.ok) { - return { - govactions, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch Gov Action Proposals.' } - }; - -} +/* The governance actions list now lives at /governance/proposals; keep old links working. */ +export const load: PageServerLoad = ({ url }) => { + redirect(301, `/governance/proposals${url.search}`); +}; diff --git a/applications/viewer/src/routes/governance/govactions/+page.svelte b/applications/viewer/src/routes/governance/govactions/+page.svelte deleted file mode 100644 index 4257f027..00000000 --- a/applications/viewer/src/routes/governance/govactions/+page.svelte +++ /dev/null @@ -1,180 +0,0 @@ - - - -
-

Proposals

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - - - {#each data.govactions as govaction: GovAction, index} - - - - - - - - - {/each} - -
Transaction HashIndexBlockAction TypeReturn AddressDetails
- - {truncate(govaction.tx_hash, 30, "...")} - - {govaction.index} - - {govaction.block_number} - - {govaction.type} - - - -
-
-
-
- - - -
-
-
diff --git a/applications/viewer/src/routes/governance/govactions/[actionid]/+page.server.ts b/applications/viewer/src/routes/governance/govactions/[actionid]/+page.server.ts index ed867759..bb340e09 100644 --- a/applications/viewer/src/routes/governance/govactions/[actionid]/+page.server.ts +++ b/applications/viewer/src/routes/governance/govactions/[actionid]/+page.server.ts @@ -1,32 +1,66 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { error } from '@sveltejs/kit'; +import { tryApi } from '$lib/api/client'; +import { toProposal } from '$lib/governance/labels'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params}) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - let splits = params.actionid.split('_'); - let index = parseInt(splits[1]); - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - const blockApiUrl = `${INDEXER_BASE_URL}/governance/proposals/${splits[0]}`; +const VOTE_PAGE = 100; +const VOTE_PAGES = 5; - const res = await fetch(blockApiUrl); - const govActions = await res.json(); +/* + * One governance action, by "_", "#" or its gov_action1… id. Status and voting + * stats come from /governance-state; type, details and time from /governance/proposals. Votes are read in pages + * of 100 (1-based), up to 500, for the tally. + */ +export const load: PageServerLoad = async ({ params, fetch }) => { + // SvelteKit has decoded the param once; older links encoded it twice. A stray `%` must not turn into a 500. + let id = params.actionid.trim(); + try { + id = decodeURIComponent(id).trim(); + } catch { + /* already decoded */ + } + let txHash: string; + let index: number; + let state: Any = null; - if (res.ok) { - let filterActionArr = govActions.filter((govAction) => govAction.index === index); - if (filterActionArr.length === 0) { - return { - status: 404, - body: { error: 'Can not fetch Gov Action Proposal.' } - }; - } else { - return { - govAction: filterActionArr[0] - }; - } - } + if (/^gov_action1[0-9a-z]+$/.test(id)) { + state = await tryApi(`/governance-state/proposals/gov-action-id/${id}`, { fetch }); + if (!state) error(404, 'No governance action with that id on this chain.'); + txHash = state.tx_hash; + index = Number(state.index); + } else { + const m = id.match(/^([0-9a-fA-F]{64})(?:[_#](\d+))?$/); + if (!m) error(404, 'That is not a governance action id. Use _ or gov_action1….'); + txHash = m[1].toLowerCase(); + index = Number(m[2] ?? 0); + } - return { - status: 404, - body: { error: 'Can not fetch block.' } - }; -} + const [stateRow, base, firstVotes] = await Promise.all([ + state ? Promise.resolve(state) : tryApi(`/governance-state/proposals/${txHash}/${index}`, { fetch }), + tryApi(`/governance/proposals/${txHash}`, { fetch }), + tryApi(`/governance/proposals/${txHash}/${index}/votes`, { fetch, params: { page: 1, count: VOTE_PAGE, order: 'desc' } }) + ]); + const baseRow = (base ?? []).find((p) => Number(p.index) === index) ?? null; + if (!stateRow && !baseRow) error(404, 'No governance action with that id on this chain.'); + + let votes = firstVotes ?? []; + if (votes.length === VOTE_PAGE) { + const more = await Promise.all( + Array.from({ length: VOTE_PAGES - 1 }, (_, i) => + tryApi(`/governance/proposals/${txHash}/${index}/votes`, { fetch, params: { page: i + 2, count: VOTE_PAGE, order: 'desc' } }) + ) + ); + for (const page of more) votes = votes.concat(page ?? []); + } + + const proposal = toProposal({ ...(baseRow ?? {}), ...(stateRow ?? {}), type: baseRow?.type ?? stateRow?.gov_action?.type, details: baseRow?.details }); + return { + proposal, + votes, + votesPartial: votes.length >= VOTE_PAGE * VOTE_PAGES, + votesAvailable: firstVotes !== null + }; +}; diff --git a/applications/viewer/src/routes/governance/govactions/[actionid]/+page.svelte b/applications/viewer/src/routes/governance/govactions/[actionid]/+page.svelte index 08b46fdc..8910f319 100644 --- a/applications/viewer/src/routes/governance/govactions/[actionid]/+page.svelte +++ b/applications/viewer/src/routes/governance/govactions/[actionid]/+page.svelte @@ -1,135 +1,129 @@ - -
-
-
-
-
- Gov Action Id -
-
-

- {govAction.tx_hash}#{govAction.index} -

-
-
-
-
- Return Address -
-
-

- -

-
-
-
-
- Deposit -
-
-

- {lovelaceToAda(govAction.deposit, 2)} Ada -

-
-
-
-
- Block -
-
-

- {govAction.block_number} -

-
-
-
-
- Slot -
-
-

- {govAction.slot} -

-
-
-
-
-
-
- Type -
-
-

- {govAction.type} -

-
-
-
-
- Anchor Url -
- -
-
-
- Anchor Hash -
-
-

- {govAction.anchor_hash} -

-
-
-
-
- Time -
-
-

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

-
-
-
-
-
-
-
-
- Details -
-
-
- -
+{actionLabel(p.type)} · Governance · Yaci Viewer + + + {#snippet icon()}{/snippet} + +
+ {p.gov_action_id ?? `${p.tx_hash}#${p.index}`} + +
+ {#snippet actions()} + {actionLabel(p.type)} + {#if p.status}{statusLabel(p.status)}{/if} + {/snippet} +
+ +
+ +
+ {shorten(p.tx_hash, 10, 6)}#{p.index} + {#if p.block_time}{:else}—{/if}{#if p.epoch != null} · epoch {p.epoch}{/if} + + {#if p.return_address}{:else}—{/if} + {#if p.block_number != null}#{num(p.block_number)}{:else}—{/if} + +
+
+ + + {#snippet icon()}{/snippet} + {#if data.votesAvailable} + + {:else} +

This store doesn't return votes for this action.

+ {/if} +
+
+ + + +{#if withdrawals.length} + + {#snippet icon()}{/snippet} + {#snippet actions()}{/snippet} +
    + {#each withdrawals as w (w.hex)} +
  • + {#if w.stake}{:else}{/if} + +
  • + {/each} +
+ {#if details.policyHash}

Guarded by script {shorten(details.policyHash, 8, 6)}

{/if} +
+{/if} + +
+ +
+
+
+ +

Votes cast · {data.votes.length}{data.votesPartial ? '+' : ''}

+
+
+ v.id ?? v.tx_hash + v.voter_hash} + emptyTitle="No votes yet" + emptyText="DReps, stake pools and the committee vote on actions while they are live." + > + {#snippet cell(v, key)} + {#if key === 'vote'}{voteLabel(v.vote)} + {:else if key === 'voter'} + {@const href = voterHref(v)} +
+ {VOTER_LABEL[voterGroup(v.voter_type)].replace(/s$/, '')} + {#if href}{shorten(v.drep_id ?? v.voter_hash, 12, 6)}{:else}{shorten(v.voter_hash, 12, 6)}{/if} + {#if voterGroup(v.voter_type) === 'drep'}{:else if voterGroup(v.voter_type) === 'spo'}{/if} +
+ {:else if key === 'rationale'} + {:else if key === 'age'}{/if} + {/snippet} +
+ {#if data.votes.length > shown} +
+ +
+ {/if}
diff --git a/applications/viewer/src/routes/governance/guardrail/+page.server.ts b/applications/viewer/src/routes/governance/guardrail/+page.server.ts new file mode 100644 index 00000000..3e911d90 --- /dev/null +++ b/applications/viewer/src/routes/governance/guardrail/+page.server.ts @@ -0,0 +1,82 @@ +import { tryApi } from '$lib/api/client'; +import { loadConstitution } from '$lib/governance/data'; +import { paramByName, paramChangeOf } from '$lib/julc/governance/action'; +import type { Draft } from '$lib/julc/governance/draft'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +export interface RecentProposal { + id: string; + type: string; + epoch: number | null; + policyHash: string | null; + summary: string; + draft: Draft; +} + +function draftOf(p: Any): Draft { + const id = `${p.tx_hash}_${p.index}`; + if (p.type === 'TREASURY_WITHDRAWALS_ACTION') + return { kind: 'treasury', values: {}, withdrawals: Object.entries(p.details?.withdrawals ?? {}).map(([address, amount]) => ({ address, amount: String(amount) })), from: id }; + const change = paramChangeOf(p.details?.protocolParamUpdate); + return { kind: 'param', values: change.values, costModels: change.costModels, withdrawals: [], from: id }; +} + +function summaryOf(p: Any): string { + if (p.type === 'TREASURY_WITHDRAWALS_ACTION') { + const total = Object.values(p.details?.withdrawals ?? {}).reduce((s, a) => s + BigInt(String(a)), 0n); + return `Withdraw ${(Number(total) / 1e6).toLocaleString('en-US', { maximumFractionDigits: 0 })} ADA`; + } + const names = Object.keys(p.details?.protocolParamUpdate ?? {}).filter((k) => k !== 'costModelsHash'); + return names.length ? names.map((n) => paramByName[n]?.label ?? (n === 'costModels' ? 'Cost models' : n)).join(', ') : 'Parameter change'; +} + +/* + * The guardrail check page. The draft comes from the URL (shareable): ?type=treasury, p.=, + * w=:, or ?from=_ to start from a proposal; ?script= checks against + * another script than the constitution's guardrail. + */ +export const load: PageServerLoad = async ({ fetch, url }) => { + const from = url.searchParams.get('from'); + const fromMatch = from?.match(/^([0-9a-f]{64})[_#](\d+)$/i); + const [constitution, params, paramProposals, treasuryProposals, fromRows] = await Promise.all([ + loadConstitution(fetch), + tryApi('/epochs/latest/parameters', { fetch }), + tryApi('/governance/proposals/gov-action-type/PARAMETER_CHANGE_ACTION', { fetch, params: { count: 12 } }), + tryApi('/governance/proposals/gov-action-type/TREASURY_WITHDRAWALS_ACTION', { fetch, params: { count: 8 } }), + fromMatch ? tryApi(`/governance/proposals/${fromMatch[1].toLowerCase()}`, { fetch }) : null + ]); + + const recent: RecentProposal[] = [...(paramProposals ?? []), ...(treasuryProposals ?? [])] + .sort((a, b) => (b.block_time ?? 0) - (a.block_time ?? 0)) + .map((p) => ({ id: `${p.tx_hash}_${p.index}`, type: p.type, epoch: p.epoch ?? null, policyHash: p.details?.policyHash ?? null, summary: summaryOf(p), draft: draftOf(p) })); + + const fromRow = fromMatch ? (fromRows ?? []).find((p) => Number(p.index) === Number(fromMatch[2])) : null; + let draft: Draft; + if (fromRow && (fromRow.type === 'PARAMETER_CHANGE_ACTION' || fromRow.type === 'TREASURY_WITHDRAWALS_ACTION')) draft = draftOf(fromRow); + else { + const values: Record = {}; + for (const [k, v] of url.searchParams) if (k.startsWith('p.') && paramByName[k.slice(2)]) values[k.slice(2)] = v; + const withdrawals = url.searchParams.getAll('w').map((w) => { + const i = w.lastIndexOf(':'); + return i > 0 ? { address: w.slice(0, i), amount: w.slice(i + 1) } : { address: w, amount: '' }; + }); + const kind = url.searchParams.get('type') === 'treasury' ? 'treasury' : 'param'; + draft = { kind, values, withdrawals, from: null }; + } + + // The script: ?script=, else the proposal's own guardrail, else the constitution's. + const wanted = url.searchParams.get('script')?.toLowerCase() ?? fromRow?.details?.policyHash ?? constitution?.script ?? null; + const hash = wanted && /^[0-9a-f]{56}$/.test(wanted) ? wanted : null; + const [scriptInfo, scriptBody] = hash ? await Promise.all([tryApi(`/scripts/${hash}`, { fetch }), tryApi<{ cbor?: string }>(`/scripts/${hash}/cbor`, { fetch })]) : [null, null]; + + return { + constitution, + params, + recent, + draft, + script: hash ? { hash, type: scriptInfo?.type ?? null, cbor: scriptBody?.cbor ?? null, constitutional: hash === constitution?.script } : null + }; +}; diff --git a/applications/viewer/src/routes/governance/guardrail/+page.svelte b/applications/viewer/src/routes/governance/guardrail/+page.svelte new file mode 100644 index 00000000..5e73b15d --- /dev/null +++ b/applications/viewer/src/routes/governance/guardrail/+page.svelte @@ -0,0 +1,16 @@ + + +Guardrail check · Governance · Yaci Viewer + + + {#snippet icon()}{/snippet} + Would the constitution’s guardrail script accept this proposal? Draft a parameter change or treasury withdrawal and check it in the browser before it is submitted. + + + diff --git a/applications/viewer/src/routes/governance/proposals/+page.server.ts b/applications/viewer/src/routes/governance/proposals/+page.server.ts index f75b94df..db4c3941 100644 --- a/applications/viewer/src/routes/governance/proposals/+page.server.ts +++ b/applications/viewer/src/routes/governance/proposals/+page.server.ts @@ -1,42 +1,28 @@ +import { error } from '@sveltejs/kit'; +import { api, paging } from '$lib/api/client'; +import { ACTION_LABEL, toProposal } from '$lib/governance/labels'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -import type { Proposal } from './types'; -export const load: PageServerLoad = async ({ url }) => { - const page = url.searchParams.get('page') || '1'; - const count = url.searchParams.get('count') || '10'; - const order = url.searchParams.get('order') || 'desc'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; - try { - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - if (!INDEXER_BASE_URL) { - throw new Error('PUBLIC_INDEXER_BASE_URL environment variable is not set'); - } - - const response = await fetch(`${INDEXER_BASE_URL}/governance-state/proposals?page=${page}&count=${count}&order=${order}`); - if (!response.ok) { - throw new Error(`Failed to fetch proposals (Status: ${response.status})`); - } - - const proposals: Proposal[] = await response.json(); - if (!Array.isArray(proposals)) { - throw new Error('Invalid data format received from server'); - } - - return { - proposals, - page, - count, - order - }; - } catch (error) { - console.error('Error loading proposals:', error); - return { - proposals: [], - page, - count, - order, - error: error instanceof Error ? error.message : 'An error occurred while loading proposals' - }; - } +/* + * All governance actions (with status), only the ones being voted on now, or one action type. + * /governance-state/proposals and /governance/proposals/gov-action-type are 1-based; /current is not paged. + */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const view = url.searchParams.get('view') === 'live' ? 'live' : 'all'; + const type = url.searchParams.get('type') ?? ''; + const validType = Object.hasOwn(ACTION_LABEL, type) ? type : ''; + const { page, count } = paging(url, 20); + const order = url.searchParams.get('order') === 'asc' ? 'asc' : 'desc'; + try { + let rows: Any[]; + if (view === 'live') rows = (await api('/governance-state/proposals/current', { fetch })) ?? []; + else if (validType) rows = (await api(`/governance/proposals/gov-action-type/${validType}`, { fetch, params: { page, count, order } })) ?? []; + else rows = (await api('/governance-state/proposals', { fetch, params: { page, count, order } })) ?? []; + return { view, type: validType, page, count, order, paged: view !== 'live', proposals: rows.map(toProposal) }; + } catch (e) { + error(503, (e as Error).message); + } }; diff --git a/applications/viewer/src/routes/governance/proposals/+page.svelte b/applications/viewer/src/routes/governance/proposals/+page.svelte index fc5b5ac3..ac7abf27 100644 --- a/applications/viewer/src/routes/governance/proposals/+page.svelte +++ b/applications/viewer/src/routes/governance/proposals/+page.svelte @@ -1,395 +1,45 @@ -
- - {#if showToast} -
-
- {toastMessage} -
-
- {/if} - -
-

Governance Action Proposals

-
- - {#if error} -
- {error} -
- {/if} - - {#if proposals.length === 0} -
-

No proposals available for this page.

-
- {:else} -
-
- - - -
-
-
- - - - - - - - - - - - - - - {#each proposals as proposal} - - - - - - - - - - - {/each} - -
Transaction HashTypeStatusDeposit (ADA)EpochBlockTimeDetails
- - {truncateHash(proposal.tx_hash)} - - {formatGovActionType(proposal.gov_action?.type || '')} - {proposal.status} - {formatDeposit(proposal.deposit)}{proposal.epoch} - - {proposal.block_number} - - {formatDate(proposal.block_time)} - -
-
- -
-
- - - -
-
- {/if} -
- - -{#if showModal} - -{/if} - - \ No newline at end of file +Proposals · Yaci Viewer + + + {#snippet icon()}{/snippet} + Governance actions submitted on chain, newest first. Open one to see its details and votes. + + +
+
+
+ +
+ {#if data.view === 'all'} + + {/if} +
+ + {#if data.paged}{/if} +
diff --git a/applications/viewer/src/routes/governance/proposals/types.ts b/applications/viewer/src/routes/governance/proposals/types.ts deleted file mode 100644 index 04dc8818..00000000 --- a/applications/viewer/src/routes/governance/proposals/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface GovAction { - type: string; - policyHash?: string; - withdrawals?: Record; - threshold?: { - numerator: number; - denominator: number; - }; - govActionId?: string | null; - membersForRemoval?: string[]; - newMembersAndTerms?: Record; -} - -export interface Proposal { - tx_hash: string; - index: number; - slot: number; - deposit: number; - return_address: string; - gov_action: GovAction; - anchor_url: string; - anchor_hash: string; - status: string; - epoch: number; - block_number: number; - block_time: number; -} \ No newline at end of file diff --git a/applications/viewer/src/routes/governance/votes/+page.server.ts b/applications/viewer/src/routes/governance/votes/+page.server.ts index e6d3464d..db24ff8c 100644 --- a/applications/viewer/src/routes/governance/votes/+page.server.ts +++ b/applications/viewer/src/routes/governance/votes/+page.server.ts @@ -1,34 +1,17 @@ -import type { PageLoad } from './$types' -import { env } from '$env/dynamic/public'; +import { error } from '@sveltejs/kit'; +import { api, paging } from '$lib/api/client'; +import type { PageServerLoad } from './$types'; -export const load: PageLoad = async ({params, url}) => { - let page = url.searchParams.get('page'); - if (!page) page = 0; - 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}/governance/votes?page=${page}&count=${count}`; - console.log(apiUrl); - - const res = await fetch(apiUrl); - const data = await res.json(); - - const votes = data; - console.log(data); - - if (res.ok) { - return { - votes, - total: 0, - total_pages: 0, - page: page, - count: count - } - } - - return { - status: 404, - body: { error: 'Can not fetch Voting Procedures.' } - }; - -} +/* Every vote on chain, newest first (/governance/votes, 1-based). */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const { page, count } = paging(url, 25); + try { + const votes = await api('/governance/votes', { fetch, params: { page, count, order: 'desc' } }); + return { votes: votes ?? [], page, count }; + } catch (e) { + error(503, (e as Error).message); + } +}; diff --git a/applications/viewer/src/routes/governance/votes/+page.svelte b/applications/viewer/src/routes/governance/votes/+page.svelte index 19d6641d..016ba06c 100644 --- a/applications/viewer/src/routes/governance/votes/+page.svelte +++ b/applications/viewer/src/routes/governance/votes/+page.svelte @@ -1,172 +1,57 @@ - -
-

Votes

-
-
- - - -
-
-
- {#if loading} -
- -
- {/if} - - - - - - - - - - - - - - - {#each data.votes as vote: Vote, index} - - - - - - - - {#if vote.anchor_url} - - - {:else } - - - {/if} - - {/each} - -
Transaction HashBlockGov Action IdVoter TypeVoteVoter HashAnchor URLAnchor Hash
- - {truncate(vote.tx_hash, 30, "...")} - - - - {vote.block_number} - - {truncate(vote.gov_action_tx_hash, 10, "...")}#{vote.gov_action_index}{vote.voter_type}{vote.vote}{truncate(vote.voter_hash, 30, "...")} - {truncate(vote.anchor_url, 20, "...")} - {truncate(vote.anchor_hash, 20, "...")}--
-
-
-
- - - -
-
+Votes · Yaci Viewer + + + {#snippet icon()}{/snippet} + Votes cast by DReps, stake pools and the constitutional committee, newest first. + + +
+ v.id ?? v.tx_hash + v.voter_hash + v.gov_action_index} + rowHref={(v) => actionHref(v.gov_action_tx_hash, v.gov_action_index)} + emptyTitle="No votes yet" + emptyText="Votes appear once DReps, pools or the committee vote on a governance action." + > + {#snippet cell(v, key)} + {#if key === 'vote'}{voteLabel(v.vote)} + {:else if key === 'voter'} + {@const g = voterGroup(v.voter_type)} + {@const href = voterHref(v)} +
+ {g === 'cc' ? 'CC' : g === 'spo' ? 'SPO' : 'DRep'} + {#if href}{shorten(v.drep_id ?? v.voter_hash, 12, 6)}{:else}{shorten(v.voter_hash, 12, 6)}{/if} + {#if g === 'drep'}{:else if g === 'spo'}{/if} +
+ {VOTER_LABEL[g]} + {:else if key === 'action'}{shorten(v.gov_action_tx_hash, 8, 6)}#{v.gov_action_index} + + {:else if key === 'rationale'} + {:else}{/if} + {/snippet} +
+
diff --git a/applications/viewer/src/routes/names/+server.ts b/applications/viewer/src/routes/names/+server.ts new file mode 100644 index 00000000..49e8a772 --- /dev/null +++ b/applications/viewer/src/routes/names/+server.ts @@ -0,0 +1,30 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import { KEY } from '$lib/names/keys'; +import type { NameKind, NamesQuery } from '$lib/names/types'; +import { names } from '$lib/server/names'; + +/* + * Names for the ids a page shows: POST { scripts?, dreps?, pools?, actions?, tokens? } (lists of ids, any of the + * forms keys.ts accepts) → the names known for them, keyed by their normalised key. At most MAX ids per kind. + * Empty everywhere but Mainnet. + */ + +const MAX = 300; +const KINDS: NameKind[] = ['scripts', 'dreps', 'pools', 'actions', 'tokens']; + +export const POST: RequestHandler = async ({ request }) => { + let body: NamesQuery; + try { + body = await request.json(); + } catch { + return json({ error: 'Send a JSON body.' }, { status: 400 }); + } + const q = Object.fromEntries( + KINDS.map((kind) => { + const ids = Array.isArray(body?.[kind]) ? body[kind]!.slice(0, MAX) : []; + const keys = new Set(ids.map((id) => (typeof id === 'string' ? KEY[kind](id) : null)).filter((k): k is string => !!k)); + return [kind, [...keys]]; + }) + ) as Required; + return json(await names.lookup(q), { headers: { 'cache-control': 'no-store' } }); +}; diff --git a/applications/viewer/src/routes/pools/[pool_id]/+page.server.ts b/applications/viewer/src/routes/pools/[pool_id]/+page.server.ts index cb2915f5..84bfcb61 100644 --- a/applications/viewer/src/routes/pools/[pool_id]/+page.server.ts +++ b/applications/viewer/src/routes/pools/[pool_id]/+page.server.ts @@ -1,28 +1,71 @@ +import { error } from '@sveltejs/kit'; +import { api, paging, tryApi } from '$lib/api/client'; +import { must } from '$lib/api/load'; +import { isPoolId } from '$lib/staking/pool'; +import { fetchPoolMetadata } from '$lib/staking/poolMetadata.server'; +import { poolInsights } from '$lib/server/insights'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; - -export const load: PageServerLoad = async ({ params }) => { - const { pool_id } = params; - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - // First get the latest epoch - const epochResponse = await fetch(`${INDEXER_BASE_URL}/epochs/latest`); - const epochData = await epochResponse.json(); - const latestEpoch = epochData.epoch; - - // Then get pool details for this epoch - const poolResponse = await fetch(`${INDEXER_BASE_URL}/pools/pools/${pool_id}/epochs/${latestEpoch}`); - const poolData = await poolResponse.json(); - - if (poolResponse.ok) { - return { - pool: poolData, - latestEpoch: latestEpoch - }; - } - - return { - status: 404, - error: 'Pool not found' - }; -}; \ No newline at end of file + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +const TABS = ['delegators', 'blocks', 'rewards'] as const; +type Tab = (typeof TABS)[number]; + +/* + * A pool by bech32 id or hex hash: its latest registration, stake this epoch (and a few before, for a trend), blocks + * made this epoch, and the open tab's list. Yaci Store lists here are 1-based. + */ +export const load: PageServerLoad = async ({ params, url, fetch }) => { + const id = params.pool_id.trim(); + if (!isPoolId(id)) error(404, 'That is not a pool id. Pool ids start with pool1, or are a 56-character hex hash.'); + + const latest = await tryApi('/epochs/latest', { fetch }); + const epoch = Number(latest?.epoch ?? 0); + const pool = await must(`/pools/pools/${id}/epochs/${epoch}`, { fetch }, 'No stake pool with that id on this chain.'); + const hash: string = pool.pool_hash; + + const tab: Tab = (TABS as readonly string[]).includes(url.searchParams.get('tab') ?? '') ? (url.searchParams.get('tab') as Tab) : 'delegators'; + const { page, count } = paging(url, 20); + const rewardEpoch = Math.max(0, parseInt(url.searchParams.get('epoch') ?? '', 10) || epoch); + + const stakeAt = (e: number) => + e < 0 ? Promise.resolve(null) : tryApi(`/epochs/${e}/pools/${hash}/stake`, { fetch, timeoutMs: 8000 }).then((s) => (s ? { epoch: e, stake: Number(s.active_stake) } : null)); + + const list = async (): Promise<{ rows: Any[]; error: string | null }> => { + if (tab === 'blocks') return { rows: [], error: null }; + const path = tab === 'delegators' ? `/epochs/${epoch}/pools/${hash}/delegators` : `/pools/${hash}/epochs/${rewardEpoch}/rewards`; + try { + const rows = await api(path, { fetch, params: { page, count }, timeoutMs: 25000 }); + return { rows: rows ?? [], error: null }; + } catch (e) { + return { rows: [], error: (e as Error).message }; + } + }; + + const [trend, total, blocks, metadata, rows] = await Promise.all([ + Promise.all([0, 1, 2, 3, 4, 5].map((i) => stakeAt(epoch - i))), + tryApi(`/epochs/${epoch}/total-stake`, { fetch, timeoutMs: 8000 }), + tryApi(`/blocks/pool/${hash}`, { fetch, params: { epoch }, timeoutMs: 10000 }), + fetchPoolMetadata(pool.metadata_url), + list() + ]); + + return { + pool, + epoch, + tab, + page, + count, + rewardEpoch, + stake: trend[0]?.stake ?? null, + stakeTrend: trend.filter((t): t is { epoch: number; stake: number } => t !== null).reverse(), + totalStake: total?.active_stake != null ? Number(total.active_stake) : null, + blocks: blocks ? [...blocks].reverse() : null, + metadata, + rows: rows.rows, + listError: rows.error, + // Blocks, stake and delegators over 50 epochs, from Yaci Store analytics where the store has it. Streamed. + history: /^[0-9a-f]{56}$/.test(hash) ? poolInsights(hash, fetch, epoch || null).catch(() => null) : null + }; +}; diff --git a/applications/viewer/src/routes/pools/[pool_id]/+page.svelte b/applications/viewer/src/routes/pools/[pool_id]/+page.svelte index 533639a6..031c0cac 100644 --- a/applications/viewer/src/routes/pools/[pool_id]/+page.svelte +++ b/applications/viewer/src/routes/pools/[pool_id]/+page.svelte @@ -1,347 +1,184 @@ -
- - {#if showToast} -
- {toastMessage} -
- {/if} - - {#if data.error} -
- {data.error} -
- {:else} - -
-
-
-

Pool Details

-
-
- Status: - - {data.pool.status} - -
-
- Epoch: - {data.pool.epoch} -
-
-
-
- Pool ID: -
- {data.pool.pool_id} - -
-
-
-
- -
- -
-

- - - - Basic Information -

-
-
- Pool Hash: - {data.pool.pool_hash} - -
-
- VRF Key Hash: - {data.pool.vrf_key_hash} - -
-
-
- - -
-

- - - - Financial Information -

-
-
- Pledge: - {formatAda(data.pool.pledge)} ADA -
-
- Cost: - {formatAda(data.pool.cost)} ADA -
-
- Margin: - {(parseFloat(data.pool.margin) * 100).toFixed(2)}% -
-
- Reward Account: - {data.pool.reward_account} - -
-
-
- - -
-

- - - - Pool Owners -

-
- {#each data.pool.pool_owners as owner} -
- {owner} - -
- {/each} -
-
- - -
-

- - - - Relays -

-
- {#each data.pool.relays as relay} -
-
- DNS Name: - {relay.dnsName} - -
-
- Port: - {relay.port} -
-
- {/each} -
-
- - -
-

- - - - Metadata -

-
- -
- Hash: - {data.pool.metadata_hash} - -
-
-
- - -
-

- - - - Transaction -

-
- Hash: - - {data.pool.tx_hash} - - -
-
+{m?.ticker ?? shorten(p.pool_id, 10, 6)} · Stake pool · Yaci Viewer + + + {#snippet icon()}{/snippet} +
+
{p.pool_id}
+ {#if m?.description}

{m.description}

{/if} +
+ {#snippet actions()} + {#if status === 'active'}Active{:else if status === 'retiring'}Retiring in epoch {p.retire_epoch}{:else}Retired in epoch {p.retire_epoch}{/if} + {#if m?.homepage}Website{/if} + {/snippet} +
+ +
+ t.stake)} + > + {#snippet icon()}{/snippet} + + + {#snippet children()}
{/snippet} +
+ + {#snippet children()}
+ {marginPct(p.margin)}
{/snippet} +
+ + {#snippet icon()}{/snippet} + +
- -
-

- - - - Rewards -

-
-

- View rewards for this pool in the latest epoch ({data.latestEpoch}). -

- - View Latest Rewards - -
-
-
- {/if} +
+ +
+ + + + + {#if p.pool_owners?.length} +
    {#each p.pool_owners as o (o)}
  • {/each}
+ {:else}None listed{/if} +
+ + {#if p.tx_hash && p.tx_hash !== 'Genesis'}{shorten(p.tx_hash, 8, 6)}{:else}Genesis{/if} + · epoch {p.epoch} + + {(p.status ?? 'registration').toLowerCase()} +
+
+ +
+ + {#if relays.length} +
    + {#each relays as r, i (i)} +
  • + {r.dnsName ?? r.ipv4 ?? r.ipv6 ?? '—'} + {#if r.port}:{r.port}{/if} +
  • + {/each} +
+ {:else} +

No relays registered. Devnet pools often have none.

+ {/if} +
+ + {#if p.metadata_url} + {p.metadata_url} +
+ {#if !m}

Couldn't read the metadata file, so the name and ticker aren't shown.

{/if} + {:else} +

No metadata registered.

+ {/if} +
+
+
+ +{#if history.value}{/if} + +
+ + {#if data.listError} + + {:else if data.tab === 'delegators'} + r.address} + emptyTitle="No delegators this epoch" + emptyText="Delegations count from the stake snapshot, two epochs after they are made." + > + {#snippet cell(r, key)} + {#if key === 'address'}{shorten(r.address, 14, 8)} + {:else if key === 'since'}epoch {r.delegation_epoch ?? '—'} + {:else if key === 'amount'} + {:else if key === 'share'}{data.stake ? smallShare(Number(r.amount), data.stake) : '—'}{/if} + {/snippet} + + + {:else if data.tab === 'blocks'} + {#if shownBlocks.length} + b.number} rowHref={(b) => `/blocks/${b.number}`}> + {#snippet cell(b, key)} + {#if key === 'number'}#{num(b.number)}{:else}{shorten(b.hash, 16, 12)}{/if} + {/snippet} + + {#if (data.blocks?.length ?? 0) > shownBlocks.length}

Showing the latest {shownBlocks.length} of {num(data.blocks?.length ?? 0)} blocks.

{/if} + {:else} + + {/if} + {:else} +
+ Rewards paid out in epoch {data.rewardEpoch} +
+ + +
+
+ `${r.address}-${r.type}-${r.earned_epoch}`} + emptyTitle="No rewards in this epoch" + emptyText="Rewards are paid two epochs after the epoch they are earned in." + > + {#snippet cell(r, key)} + {#if key === 'address'}{shorten(r.address, 14, 8)} + {:else if key === 'type'}{r.type} + {:else if key === 'earned'}epoch {r.earned_epoch} + {:else}{/if} + {/snippet} + + + {/if}
- \ No newline at end of file +

Stake and delegators come from the epoch {data.epoch} snapshot.

diff --git a/applications/viewer/src/routes/pools/search/+page.server.ts b/applications/viewer/src/routes/pools/search/+page.server.ts new file mode 100644 index 00000000..b28e9b3f --- /dev/null +++ b/applications/viewer/src/routes/pools/search/+page.server.ts @@ -0,0 +1,15 @@ +import { tryApi } from '$lib/api/client'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* Recent pool activity, so the lookup page has something to click. */ +export const load: PageServerLoad = async ({ fetch }) => { + const [registrations, retirements, epoch] = await Promise.all([ + tryApi('/pools/registrations', { fetch, params: { page: 1, count: 10 } }), + tryApi('/pools/retirements', { fetch, params: { page: 1, count: 6 } }), + tryApi('/epochs/latest', { fetch }) + ]); + return { registrations: registrations ?? [], retirements: retirements ?? [], epoch: (epoch?.epoch as number) ?? null }; +}; diff --git a/applications/viewer/src/routes/pools/search/+page.svelte b/applications/viewer/src/routes/pools/search/+page.svelte index 0d9dee86..d7b07194 100644 --- a/applications/viewer/src/routes/pools/search/+page.svelte +++ b/applications/viewer/src/routes/pools/search/+page.svelte @@ -1,96 +1,87 @@ -
-
-

Pool Search

- -
-
-
-
- - -
+Stake pools · Yaci Viewer - {#if error} -
- - {error} -
- {/if} + -
- -
- -
-
+
+ + {#snippet icon()}{/snippet} + {#snippet actions()}All {/snippet} + {#if data.registrations.length} + + {:else} + + {/if} + -
-
- -
-

How to find a pool ID

-
-

Pool IDs are unique identifiers for Cardano stake pools. They start with "pool1" followed by a series of characters.

-

You can find pool IDs on:

-
    -
  • Pool registration certificates
  • -
  • Pool explorer websites
  • -
  • Your wallet's delegation interface
  • -
-
-
-
-
-
-
\ No newline at end of file + + {#snippet icon()}{/snippet} + {#snippet actions()}All {/snippet} + {#if data.retirements.length} +
    + {#each data.retirements as r (r.tx_hash + r.cert_index)} + {@const id = r.pool_id_bech32 ?? poolBech32(r.pool_id)} +
  • + {shorten(id, 12, 6)} + + {#if data.epoch != null && r.retirement_epoch <= data.epoch}retired {r.retirement_epoch}{:else}retires {r.retirement_epoch}{/if} +
  • + {/each} +
+ {:else} + + {/if} +
+
diff --git a/applications/viewer/src/routes/rewards/+page.server.ts b/applications/viewer/src/routes/rewards/+page.server.ts new file mode 100644 index 00000000..f6793d96 --- /dev/null +++ b/applications/viewer/src/routes/rewards/+page.server.ts @@ -0,0 +1,7 @@ +import { tryApi } from '$lib/api/client'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ fetch }) => { + const latest = await tryApi<{ epoch: number }>('/epochs/latest', { fetch }); + return { epoch: latest?.epoch ?? null }; +}; diff --git a/applications/viewer/src/routes/rewards/+page.svelte b/applications/viewer/src/routes/rewards/+page.svelte index b6645d87..029abed1 100644 --- a/applications/viewer/src/routes/rewards/+page.svelte +++ b/applications/viewer/src/routes/rewards/+page.svelte @@ -1,140 +1,90 @@ -
-

Cardano Rewards Explorer

+Rewards · Yaci Viewer -
- -
-
-

Search by Epoch

-

View all rewards distributed within a specific epoch.

-
- - e.key === 'Enter' && navigateToEpochRewards()} - /> -
-
- -
-
-
+ + {#snippet icon()}{/snippet} + Staking rewards are paid at each epoch boundary, two epochs after they are earned. + - -
-
-

Search by Account

-

View the rewards history for a specific stake address.

-
- - e.key === 'Enter' && navigateToAccountRewards()} - /> -
-
- -
-
-
+{#if recent.length} +
+ Jump to epoch + {#each recent as e (e)} + {e}{e === data.epoch ? ' · current' : ''} + {/each} +
+{/if} - -
-
-

Search by Pool & Epoch

-

View rewards distributed by a specific pool in an epoch.

-
- - -
-
- - e.key === 'Enter' && navigateToPoolEpochRewards()} - /> -
-
- -
-
-
-
+
+ {#snippet card(form: string, Icon: typeof Gift, title: string, text: string, body: Snippet)} +
go(form, e)}> +
+ +
+

{title}

+

{text}

+
+
+ {@render body()} + {#if problem?.form === form}

{problem.text}

{/if} + +
+ {/snippet} - + {#snippet epochBody()} + + {/snippet} + {#snippet accountBody()} + + {/snippet} + {#snippet poolBody()} +
+ + +
+ {/snippet} -
\ No newline at end of file + {@render card('epoch', Timer, 'By epoch', 'Every reward paid out in one epoch, with the pots it came from.', epochBody)} + {@render card('account', KeyRound, 'By stake account', 'One account’s reward history across epochs.', accountBody)} + {@render card('pool', Server, 'By pool and epoch', 'What a pool’s leader and members received in an epoch.', poolBody)} +
diff --git a/applications/viewer/src/routes/rewards/account/[address]/+page.server.ts b/applications/viewer/src/routes/rewards/account/[address]/+page.server.ts index fa12e73c..e9f4da53 100644 --- a/applications/viewer/src/routes/rewards/account/[address]/+page.server.ts +++ b/applications/viewer/src/routes/rewards/account/[address]/+page.server.ts @@ -1,99 +1,7 @@ -import { error } from '@sveltejs/kit'; +import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -// Use the public environment variable since it's already set correctly -const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; -const DEFAULT_COUNT = 15; -const DEFAULT_ORDER = 'desc'; - -// Define interfaces for the expected API responses -interface AccountPoolReward { - epoch_no: number; - pool_id: string; - amount: number; -} - -interface AccountRestReward { - epoch_no: number; - amount: number; - type: string; -} - -export const load: PageServerLoad = async ({ params, url, fetch }) => { - const address = params.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; - - if (!address) { - throw error(400, 'Stake address is required'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { // Example limit - throw error(400, 'Invalid count number'); - } - if (order !== 'asc' && order !== 'desc') { - throw error(400, 'Invalid order parameter'); - } - - const fetchAccountPoolRewards = async (currentPage = 1) => { - const apiUrl = `${INDEXER_BASE_URL}/accounts/${address}/rewards?page=${currentPage - 1}&count=${count}&order=${order}`; - try { - console.log("Fetching Account Pool Rewards URL:", apiUrl); - const response = await fetch(apiUrl); - if (!response.ok) { - console.error(`Failed to fetch account pool rewards: ${response.status} ${response.statusText}`); - return { data: [], error: `Account Pool Rewards: ${response.statusText}` }; - } - const data: AccountPoolReward[] = await response.json(); - console.log(`Fetched ${data.length} account pool rewards.`); - return { data, error: null }; - } catch (err) { - console.error(`Error fetching account pool rewards:`, err); - return { data: [], error: 'Failed to fetch account pool rewards' }; - } - }; - - const fetchAccountRestRewards = async (currentPage = 1) => { - const apiUrl = `${INDEXER_BASE_URL}/accounts/${address}/reward_rest?page=${currentPage - 1}&count=${count}&order=${order}`; - try { - console.log("Fetching Account Rest Rewards URL:", apiUrl); - const response = await fetch(apiUrl); - if (!response.ok) { - console.error(`Failed to fetch account rest rewards: ${response.status} ${response.statusText}`); - return { data: [], error: `Account Rest Rewards: ${response.statusText}` }; - } - const data: AccountRestReward[] = await response.json(); - console.log(`Fetched ${data.length} account rest rewards.`); - return { data, error: null }; - } catch (err) { - console.error(`Error fetching account rest rewards:`, err); - return { data: [], error: 'Failed to fetch account rest rewards' }; - } - }; - - // Fetch the first page (or requested page) of each type concurrently - const [poolRewardsResult, restRewardsResult] = await Promise.all([ - fetchAccountPoolRewards(page), - fetchAccountRestRewards(page) - ]); - - const errors = [ - poolRewardsResult.error, - restRewardsResult.error - ].filter(e => e !== null); - const combinedError = errors.length > 0 ? errors.join('; ') : null; - - return { - address: address, - poolRewards: poolRewardsResult.data, - restRewards: restRewardsResult.data, - currentPage: page, - count: count, - order: order, - error: combinedError, - }; +/* The stake account page now shows reward history; keep old links working. */ +export const load: PageServerLoad = ({ params }) => { + redirect(301, `/stake/${params.address.trim()}?tab=rewards`); }; diff --git a/applications/viewer/src/routes/rewards/account/[address]/+page.svelte b/applications/viewer/src/routes/rewards/account/[address]/+page.svelte index 4393cafd..08828001 100644 --- a/applications/viewer/src/routes/rewards/account/[address]/+page.svelte +++ b/applications/viewer/src/routes/rewards/account/[address]/+page.svelte @@ -1,305 +1 @@ - - -
- - {#if showToast} -
- {toastMessage} -
- {/if} - - -

Account Rewards History

-

For: {address}

- - {#if error} -
-
- - Error fetching data: {error} -
-
- {/if} - - -
- - -
- - -
- Sort by Epoch: - - -
- - {#if loading} -
-
-
- {:else} - -
- {#if currentTab === 'pool'} -

Pool Rewards History

- {#if !poolRewards || poolRewards.length === 0} -

No pool rewards found for this account.

- {:else} -
- - - - - - - - - - - {#each poolRewards as reward (reward.epoch + reward.pool_id)} - - - - - - - {/each} - -
EpochPool IDTypeAmount (ADA)
{reward.epoch} -
- {truncate(reward.pool_id, 40, '...')} - -
-
{reward.type} - {formatAda(reward.amount)} -
-
- {/if} - - {:else if currentTab === 'rest'} -

Other Rewards History

- {#if !restRewards || restRewards.length === 0} -

No other rewards found for this account.

- {:else} -
- - - - - - - - - - {#each restRewards as reward, i (reward.epoch + reward.type + reward.amount + i)} - - - - - - {/each} - -
EpochTypeAmount (ADA)
{reward.epoch}{reward.type} - {formatAda(reward.amount)} -
-
- {/if} - {/if} - - -
- - Page {currentPage} - -
-
- {/if} -
- - \ No newline at end of file + diff --git a/applications/viewer/src/routes/rewards/components/SearchBar.svelte b/applications/viewer/src/routes/rewards/components/SearchBar.svelte deleted file mode 100644 index 4ee220bb..00000000 --- a/applications/viewer/src/routes/rewards/components/SearchBar.svelte +++ /dev/null @@ -1,69 +0,0 @@ - - -
-
- -
- - -
- - -
- - -
-
- -
- - -
-
\ No newline at end of file diff --git a/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.server.ts b/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.server.ts index a07e32a6..ddf19490 100644 --- a/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.server.ts +++ b/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.server.ts @@ -1,124 +1,37 @@ 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; // Number of items per page +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; -// Define interfaces for the expected API responses -interface PoolReward { - pool_id: string; - amount: number; - epoch_no: number; -} - -interface RestReward { - stake_address: string; - amount: number; - type: string; // e.g., "leader", "member", "reserve", "treasury" - epoch_no: number; -} +const TABS = { rewards: 'rewards', other: 'reward_rest', unclaimed: 'unclaimed_reward_rest' } as const; +type Tab = keyof typeof TABS; +/* Rewards that became spendable in an epoch, plus that epoch's pots. Lists are 1-based. */ export const load: PageServerLoad = async ({ params, url, fetch }) => { - const epoch = params.epoch; - // Read page and count from URL search params - const page = parseInt(url.searchParams.get('page') || '1'); - const count = parseInt(url.searchParams.get('count') || String(DEFAULT_COUNT)); - - // Validate epoch, page, count... - if (!epoch || isNaN(Number(epoch))) { - throw error(400, 'Invalid epoch number'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { // Use the validated count - throw error(400, 'Invalid count number'); - } - - const fetchPoolRewards = async (currentPage = 1) => { - const url = `${INDEXER_BASE_URL}/epochs/${epoch}/rewards?page=${currentPage}&count=${count}`; - try { - console.log("Fetching Pool Rewards URL:", url); - const response = await fetch(url); - if (!response.ok) { - console.error(`Failed to fetch pool rewards for epoch ${epoch}: ${response.status} ${response.statusText}`); - const errorText = await response.text(); - console.error("Error details:", errorText); - return { data: [], error: `Pool Rewards: ${response.statusText}` }; // Return empty data and error - } - const data: PoolReward[] = await response.json(); - console.log(`Fetched ${data.length} pool rewards.`); - return { data, error: null }; - } catch (err) { - console.error(`Error fetching pool rewards for epoch ${epoch}:`, err); - return { data: [], error: 'Failed to fetch pool rewards' }; - } - }; - - const fetchRestRewards = async (currentPage = 1) => { - const url = `${INDEXER_BASE_URL}/epochs/${epoch}/reward_rest?page=${currentPage}&count=${count}`; - try { - console.log("Fetching Rest Rewards URL:", url); - const response = await fetch(url); - if (!response.ok) { - console.error(`Failed to fetch rest rewards for epoch ${epoch}: ${response.status} ${response.statusText}`); - const errorText = await response.text(); - console.error("Error details:", errorText); - return { data: [], error: `Rest Rewards: ${response.statusText}` }; - } - const data: RestReward[] = await response.json(); - console.log(`Fetched ${data.length} rest rewards.`); - return { data, error: null }; - } catch (err) { - console.error(`Error fetching rest rewards for epoch ${epoch}:`, err); - return { data: [], error: 'Failed to fetch rest rewards' }; - } - }; - - const fetchUnclaimedRestRewards = async (currentPage = 1) => { - const url = `${INDEXER_BASE_URL}/epochs/${epoch}/unclaimed_reward_rest?page=${currentPage}&count=${count}`; - try { - console.log("Fetching Unclaimed Rest Rewards URL:", url); - const response = await fetch(url); - if (!response.ok) { - console.error(`Failed to fetch unclaimed rest rewards for epoch ${epoch}: ${response.status} ${response.statusText}`); - const errorText = await response.text(); - console.error("Error details:", errorText); - return { data: [], error: `Unclaimed Rewards: ${response.statusText}` }; - } - const data: RestReward[] = await response.json(); - console.log(`Fetched ${data.length} unclaimed rest rewards.`); - return { data, error: null }; - } catch (err) { - console.error(`Error fetching unclaimed rest rewards for epoch ${epoch}:`, err); - return { data: [], error: 'Failed to fetch unclaimed rest rewards' }; - } - }; - - // Fetch the *requested page* (not just page 1) of each data type concurrently - const [poolRewardsResult, restRewardsResult, unclaimedRewardsResult] = await Promise.all([ - fetchPoolRewards(page), - fetchRestRewards(page), - fetchUnclaimedRestRewards(page) - ]); - - const errors = [ - poolRewardsResult.error, - restRewardsResult.error, - unclaimedRewardsResult.error - ].filter(e => e !== null); - - // Combine errors if multiple fetches failed - const combinedError = errors.length > 0 ? errors.join('; ') : null; - - return { - epoch: Number(epoch), - poolRewards: poolRewardsResult.data, - restRewards: restRewardsResult.data, - unclaimedRewards: unclaimedRewardsResult.data, - currentPage: page, // Return the requested page number - count: count, // Return the used count - error: combinedError, - }; -}; \ No newline at end of file + if (!/^\d+$/.test(params.epoch)) error(404, 'Epochs are whole numbers.'); + const epoch = Number(params.epoch); + const tab: Tab = (url.searchParams.get('tab') as Tab) in TABS ? (url.searchParams.get('tab') as Tab) : 'rewards'; + const { page, count } = paging(url, 20); + + const [rows, pots, latest] = await Promise.all([ + api(`/epochs/${epoch}/${TABS[tab]}`, { fetch, params: { page, count }, timeoutMs: 25000 }).then( + (r) => ({ rows: r ?? [], error: null as string | null }), + (e) => ({ rows: [] as Any[], error: (e as Error).message }) + ), + tryApi(`/adapot/epochs/${epoch}`, { fetch }), + tryApi('/epochs/latest', { fetch }) + ]); + + return { + epoch, + latest: (latest?.epoch as number) ?? null, + tab, + page, + count, + rows: rows.rows, + listError: rows.error, + pots: pots as Record | null + }; +}; diff --git a/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.svelte b/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.svelte index d6bdc234..2bdb8aac 100644 --- a/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.svelte +++ b/applications/viewer/src/routes/rewards/epoch/[epoch]/+page.svelte @@ -1,381 +1,86 @@ -
- - {#if showToast} -
- {toastMessage} -
- {/if} - - -

Rewards for Epoch {epoch}

- - {#if error && !loading} -
-
- - {#if error.startsWith('Warning')} - - {:else} - - {/if} - - {error} -
-
- {/if} - - -
- - - -
- - - {#if !loading && (poolRewards.length > 0 || restRewards.length > 0 || unclaimedRewards.length > 0)} -
- - Page {currentPage} - -
- {/if} - - -
- {#if loading} -
- -
- {:else if currentTab === 'pool'} -

Pool Rewards

- {#if !poolRewards || poolRewards.length === 0} -

No pool rewards found for this epoch.

- {:else} -
- - - - - - - - - - - - - {#each poolRewards as reward (reward.pool_id + reward.address + reward.earned_epoch)} - - - - - - - - - {/each} - -
Pool IDStake AddressTypeAmount (ADA)Earned EpochSpendable Epoch
-
- {truncate(reward.pool_id, 20, '...')} - -
-
-
- {truncate(reward.address, 20, '...')} - -
-
{reward.type} - {formatAda(reward.amount)} - {reward.earned_epoch}{reward.spendable_epoch}
-
- {/if} - {:else if currentTab === 'rest'} -

Other Rewards (Treasury, Reserves, etc.)

- {#if !restRewards || restRewards.length === 0} -

No other rewards found for this epoch.

- {:else} -
- - - - - - - - - - - - {#each restRewards as reward, i (reward.address + reward.type + reward.earned_epoch + i)} - - - - - - - - {/each} - -
Stake AddressTypeAmount (ADA)Earned EpochSpendable Epoch
-
- {truncate(reward.address, 20, '...')} - -
-
{reward.type} - {formatAda(reward.amount)} - {reward.earned_epoch}{reward.spendable_epoch}
-
- {/if} - {:else if currentTab === 'unclaimed'} -

Unclaimed Other Rewards

- {#if !unclaimedRewards || unclaimedRewards.length === 0} -

No unclaimed rewards found for this epoch.

- {:else} -
- - - - - - - - - - - - {#each unclaimedRewards as reward, i (reward.address + reward.type + reward.earned_epoch + i)} - - - - - - - - {/each} - -
Stake AddressTypeAmount (ADA)Earned EpochSpendable Epoch
-
- {truncate(reward.address, 20, '...')} - -
-
{reward.type} - {formatAda(reward.amount)} - {reward.earned_epoch}{reward.spendable_epoch}
-
- {/if} - {/if} - - - {#if !loading && (poolRewards.length > 0 || restRewards.length > 0 || unclaimedRewards.length > 0)} -
- - Page {currentPage} - -
- {/if} -
-
- - \ No newline at end of file +Rewards in epoch {data.epoch} · Yaci Viewer + + + {#snippet icon()}{/snippet} + Rewards that became spendable at the start of this epoch. + {#snippet actions()} + {data.epoch - 1} + {data.epoch + 1} + {/snippet} + + +{#if data.pots} +
+ + {#snippet children()}
{/snippet} +
+ + {#snippet children()}
{/snippet} +
+ + {#snippet children()}
{/snippet} +
+ + {#snippet children()}
{/snippet} +
+
+{/if} + +
+ + {#if data.listError} + + {:else} + `${r.address}-${r.type}-${r.pool_id ?? ''}-${r.earned_epoch}-${r.amount}`} + emptyTitle={empty.title} + emptyText={empty.text} + > + {#snippet cell(r, key)} + {#if key === 'address'}{shorten(r.address, 14, 8)} + {:else if key === 'type'}{r.type} + {:else if key === 'pool'}{#if r.pool_id}{@const id = poolBech32(r.pool_id)}{shorten(id, 10, 6)} {:else}—{/if} + {:else if key === 'earned'}{r.earned_epoch ?? '—'} + {:else}{/if} + {/snippet} + + + {/if} +
diff --git a/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.server.ts b/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.server.ts index 018d53a1..266f7093 100644 --- a/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.server.ts +++ b/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.server.ts @@ -1,72 +1,25 @@ import { error } from '@sveltejs/kit'; +import { api, paging, tryApi } from '$lib/api/client'; +import { isPoolId } from '$lib/staking/pool'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; -const DEFAULT_COUNT = 15; - -// Updated interface to match API response -interface PoolEpochReward { - address: string; - reward: number; // Changed from amount - type: string; // Added - earned_epoch: number; // Changed from epoch_no -} +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; +/* What a pool's leader and members were paid in an epoch. The list is 1-based. */ export const load: PageServerLoad = async ({ params, url, fetch }) => { - const poolId = params.pool_id; - const epoch = params.epoch; - - // Read 1-based page from URL, default to 1 - const page = parseInt(url.searchParams.get('page') || '1'); - const count = parseInt(url.searchParams.get('count') || String(DEFAULT_COUNT)); - - if (!poolId) { - throw error(400, 'Pool ID is required'); - } - if (!epoch || isNaN(Number(epoch))) { - throw error(400, 'Invalid epoch number'); - } - if (isNaN(page) || page < 1) { - throw error(400, 'Invalid page number'); - } - if (isNaN(count) || count < 1 || count > 100) { // Example limit - throw error(400, 'Invalid count number'); - } - - const fetchPoolEpochRewards = async (requestedPage = 1) => { - // API expects 0-based page index - const zeroBasedPage = requestedPage - 1; - const apiUrl = `${INDEXER_BASE_URL}/pools/${poolId}/epochs/${epoch}/rewards?page=${zeroBasedPage}&count=${count}`; - try { - console.log("Fetching Pool Epoch Rewards URL:", apiUrl); - const response = await fetch(apiUrl); - if (!response.ok) { - console.error(`Failed to fetch pool epoch rewards: ${response.status} ${response.statusText}`); - return { data: [], error: `Pool Epoch Rewards: ${response.statusText}` }; - } - const data: PoolEpochReward[] = await response.json(); - console.log(`Fetched ${data.length} pool epoch rewards for requested UI page ${requestedPage} (API page ${zeroBasedPage}).`); - return { data, error: null }; - } catch (err) { - console.error(`Error fetching pool epoch rewards:`, err); - return { data: [], error: 'Failed to fetch pool epoch rewards' }; - } - }; - - // Fetch using 1-based page number - const rewardsResult = await fetchPoolEpochRewards(page); + const poolId = params.pool_id.trim(); + if (!isPoolId(poolId)) error(404, 'That is not a pool id.'); + if (!/^\d+$/.test(params.epoch)) error(404, 'Epochs are whole numbers.'); + const epoch = Number(params.epoch); + const { page, count } = paging(url, 20); - return { - poolId: poolId, - epoch: Number(epoch), - rewards: rewardsResult.data, - currentPage: page, // Return 1-based page - count: count, - error: rewardsResult.error, - // Add total_pages from API if available, otherwise calculate - // total_pages: rewardsResult.total_pages || Math.ceil(rewardsResult.total / count) - // Assuming the API doesn't return total/total_pages for this endpoint - // The client will rely on rewards.length === count for hasMore - }; -}; \ No newline at end of file + const [rows, latest] = await Promise.all([ + api(`/pools/${poolId}/epochs/${epoch}/rewards`, { fetch, params: { page, count }, timeoutMs: 25000 }).then( + (r) => ({ rows: r ?? [], error: null as string | null }), + (e) => ({ rows: [] as Any[], error: (e as Error).message }) + ), + tryApi('/epochs/latest', { fetch }) + ]); + return { poolId, epoch, latest: (latest?.epoch as number) ?? null, page, count, rows: rows.rows, listError: rows.error }; +}; diff --git a/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.svelte b/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.svelte index 948f0b69..e17883c6 100644 --- a/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.svelte +++ b/applications/viewer/src/routes/rewards/pool/[pool_id]/epoch/[epoch]/+page.svelte @@ -1,237 +1,59 @@ -
- - {#if showToast} -
- {toastMessage} -
- {/if} - - -

Rewards for Pool in Epoch {epoch}

-
- Pool ID: -
- {poolId} - -
-
- - {#if error && !loading} -
- -
- - {error} -
-
- {/if} - - - {#if !loading && rewards.length > 0} -
- - Page {currentPage} - -
- {/if} - - -
- {#if loading} -
- -
- {:else if !rewards || rewards.length === 0} -

No rewards found for this pool in this epoch.

- {:else} -
- - - - - - - - - - - {#each rewards as item (item.address + item.earned_epoch)} - - - - - - - {/each} - -
Stake AddressTypeAmount (ADA)Earned Epoch
-
- {truncate(item.address, 60, '...')} - -
-
{item.type} - {formatAda(item.reward)} - {item.earned_epoch}
-
- {/if} - - - {#if !loading && rewards.length > 0} -
- - Page {currentPage} - -
- {/if} -
-
- - \ No newline at end of file +Pool rewards · epoch {data.epoch} · Yaci Viewer + + + {#snippet icon()}{/snippet} + {shorten(id, 14, 8)} + {#snippet actions()} + {data.epoch - 1} + {data.epoch + 1} + {/snippet} + + +
+ {#if data.listError} + + {:else} + {#if data.rows.length} +
+ {data.rows.length} reward{data.rows.length === 1 ? '' : 's'} on this page + Total +
+ {/if} + `${r.address}-${r.type}-${r.earned_epoch}`} + emptyTitle="No rewards for this pool in this epoch" + emptyText="Rewards are paid two epochs after they are earned, and only if the pool made blocks." + > + {#snippet cell(r, key)} + {#if key === 'address'}{shorten(r.address, 14, 8)} + {:else if key === 'type'}{r.type} + {:else if key === 'earned'}{r.earned_epoch ?? '—'} + {:else}{/if} + {/snippet} + + + {/if} +
diff --git a/applications/viewer/src/routes/rewards/store.ts b/applications/viewer/src/routes/rewards/store.ts deleted file mode 100644 index bdfeab2d..00000000 --- a/applications/viewer/src/routes/rewards/store.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { writable } from 'svelte/store'; - -interface RewardsState { - totalRewards: number; - activeStakes: number; - latestEpoch: number; - recentRewards: any[]; - loading: boolean; - error: string | null; -} - -export const rewardsStore = writable({ - totalRewards: 0, - activeStakes: 0, - latestEpoch: 0, - recentRewards: [], - loading: false, - error: null -}); - -export async function fetchRewardsData(): Promise { - try { - rewardsStore.update(state => ({ ...state, loading: true })); - - // TODO: Implement API calls to: - // 1. Get total rewards - // 2. Get active stakes - // 3. Get latest epoch - // 4. Get recent rewards - - rewardsStore.update(state => ({ ...state, loading: false })); - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - rewardsStore.update(state => ({ - ...state, - loading: false, - error: errorMessage - })); - } -} - -export async function fetchStakeDistribution(epoch: number): Promise { - try { - // TODO: Implement API call to get stake distribution for a specific epoch - return []; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`Failed to fetch stake distribution: ${errorMessage}`); - } -} - -export async function fetchRewardHistory(stakeAddress: string): Promise { - try { - // TODO: Implement API call to get reward history for a specific stake address - return []; - } catch (error: unknown) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; - throw new Error(`Failed to fetch reward history: ${errorMessage}`); - } -} \ No newline at end of file diff --git a/applications/viewer/src/routes/scripts/[hash]/+page.server.ts b/applications/viewer/src/routes/scripts/[hash]/+page.server.ts index 7793cc1f..4bc66573 100644 --- a/applications/viewer/src/routes/scripts/[hash]/+page.server.ts +++ b/applications/viewer/src/routes/scripts/[hash]/+page.server.ts @@ -1,30 +1,35 @@ -import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; import { error } from '@sveltejs/kit'; +import { tryApi } from '$lib/api/client'; +import { must } from '$lib/api/load'; +import { isPlutus } from '$lib/assets/script'; +import { loadScriptActivity } from '$lib/julc/activity.server'; +import { scriptInsights } from '$lib/server/insights'; +import type { PageServerLoad } from './$types'; +/* A script by hash: its type and size, the JSON for native scripts, and the CBOR. */ export const load: PageServerLoad = async ({ params, fetch }) => { - const hash = params.hash; - - if (!/^[0-9a-fA-F]{56}$/.test(hash)) { - throw error(400, 'Invalid script hash. Expected 56 hex characters.'); - } - - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - const [scriptInfo, scriptJson, scriptCbor] = await Promise.all([ - fetch(`${INDEXER_BASE_URL}/scripts/${hash}`).then(r => r.ok ? r.json() : null), - fetch(`${INDEXER_BASE_URL}/scripts/${hash}/json`).then(r => r.ok ? r.json() : null), - fetch(`${INDEXER_BASE_URL}/scripts/${hash}/cbor`).then(r => r.ok ? r.json() : null), - ]); + const hash = params.hash.trim().toLowerCase(); + if (!/^[0-9a-f]{56}$/.test(hash)) error(404, 'That is not a script hash (56 hex characters).'); - if (!scriptInfo) { - throw error(404, 'Script not found'); - } + const info = await must<{ script_hash: string; type?: string; serialised_size?: number | null }>( + `/scripts/${hash}`, + { fetch }, + 'No script with that hash on this chain. Scripts are stored once a transaction uses or publishes them.' + ); + const [json, cbor] = await Promise.all([ + tryApi<{ json: unknown }>(`/scripts/${hash}/json`, { fetch }), + tryApi<{ cbor: string | null }>(`/scripts/${hash}/cbor`, { fetch }) + ]); - return { - hash, - scriptInfo, - scriptJson, - scriptCbor, - }; + return { + hash, + type: info.type ?? null, + size: info.serialised_size ?? null, + json: json?.json ?? null, + cbor: cbor?.cbor ?? null, + // Streamed: the page renders without waiting for the execution history. + activity: isPlutus(info.type) ? loadScriptActivity(hash, fetch) : null, + // Runs and ex-units over 90 days, from Yaci Store analytics where the store has it (null otherwise). Streamed. + usage: isPlutus(info.type) ? scriptInsights(hash, fetch).catch(() => null) : null + }; }; diff --git a/applications/viewer/src/routes/scripts/[hash]/+page.svelte b/applications/viewer/src/routes/scripts/[hash]/+page.svelte index 79b800b0..a0c67c7b 100644 --- a/applications/viewer/src/routes/scripts/[hash]/+page.svelte +++ b/applications/viewer/src/routes/scripts/[hash]/+page.svelte @@ -1,130 +1,135 @@ - -
-
- -
-
-

- Script Hash -

-
- -
-
+Script {shorten(data.hash, 8, 6)} · Yaci Viewer -
-
-
Type
-
- {scriptInfo.type || '—'} -
-
+ + {#snippet icon()}{/snippet} +
+ {data.hash} + +
+ {#snippet actions()} + + {label} + {#if plutus && data.cbor}Validator lab{/if} + {/snippet} +
-
-
Size
-
- {#if scriptInfo.serialised_size} - {scriptInfo.serialised_size.toLocaleString()} bytes - {:else} - — - {/if} -
-
-
+
+ +
+ {label} + {#if bytes}{num(bytes)} bytes{:else}—{/if} + + {#if scriptAddress} + + {/if} +
+
+ + + +
- -
-
- +{#if !plutus && data.json} + + +
+
+{/if} - -
+{#if plutus && data.cbor} + +
+ {#if data.activity} +
+ {#await data.activity} + + {:then activity} + {#if activity}{/if} + {/await}
- - -
- {#if activeTabIndex === JSON_TAB} -
- {#if scriptJson} - - {:else} -
No JSON representation available.
- {/if} -
+ {/if} + {#if usage.value}{/if} +{:else} + + {#snippet actions()}{#if data.cbor}{/if}{/snippet} + {#if data.cbor} + {data.cbor} + {#if data.cbor.length > 600} + {/if} - - {#if activeTabIndex === CBOR_TAB} -
- {#if scriptCbor && scriptCbor.cbor} -
- -
-
-
{scriptCbor.cbor}
-
- {:else} -
No CBOR representation available.
- {/if} -
- {/if} -
-
-
- - + {:else} + + {/if} + +{/if} diff --git a/applications/viewer/src/routes/scripts/lab/+page.server.ts b/applications/viewer/src/routes/scripts/lab/+page.server.ts new file mode 100644 index 00000000..eef7c08d --- /dev/null +++ b/applications/viewer/src/routes/scripts/lab/+page.server.ts @@ -0,0 +1,32 @@ +import { tryApi } from '$lib/api/client'; +import type { Genesis } from '$lib/julc/replay/slots'; +import { utxoOf } from '$lib/julc/authoring/utxo'; +import type { PageServerLoad } from './$types'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Any = any; + +/* + * The validator lab. ?script= starts from a script on this chain, ?utxo=# (or _) from a UTxO. + * Parameters, genesis (slot times) and the tip slot come along for building the transaction. + */ +export const load: PageServerLoad = async ({ fetch, url }) => { + const hash = url.searchParams.get('script')?.toLowerCase() ?? null; + const ref = url.searchParams.get('utxo')?.match(/^([0-9a-f]{64})[#_](\d+)$/i); + const [params, genesis, tip, scriptInfo, scriptBody, utxo] = await Promise.all([ + tryApi('/epochs/latest/parameters', { fetch }), + tryApi('/genesis', { fetch }), + tryApi<{ slot?: number }>('/blocks/latest', { fetch }), + hash && /^[0-9a-f]{56}$/.test(hash) ? tryApi<{ type?: string }>(`/scripts/${hash}`, { fetch }) : null, + hash && /^[0-9a-f]{56}$/.test(hash) ? tryApi<{ cbor?: string }>(`/scripts/${hash}/cbor`, { fetch }) : null, + ref ? tryApi(`/utxos/${ref[1].toLowerCase()}/${ref[2]}`, { fetch }) : null + ]); + return { + params, + genesis, + tipSlot: tip?.slot ?? null, + script: hash && scriptBody?.cbor ? { hash, type: scriptInfo?.type ?? null, cbor: scriptBody.cbor } : null, + scriptMissing: !!hash && !scriptBody?.cbor, + utxo: utxoOf(utxo) + }; +}; diff --git a/applications/viewer/src/routes/scripts/lab/+page.svelte b/applications/viewer/src/routes/scripts/lab/+page.svelte new file mode 100644 index 00000000..cf4ba2ff --- /dev/null +++ b/applications/viewer/src/routes/scripts/lab/+page.svelte @@ -0,0 +1,16 @@ + + +Validator lab · Yaci Viewer + + + {#snippet icon()}{/snippet} + Write a validator in Java (or bring a compiled one), point it at real UTxOs from this chain and see whether it accepts, as you type. Nothing is deployed, signed or submitted. + + + diff --git a/applications/viewer/src/routes/scripts/monitor/+page.svelte b/applications/viewer/src/routes/scripts/monitor/+page.svelte new file mode 100644 index 00000000..14d7a338 --- /dev/null +++ b/applications/viewer/src/routes/scripts/monitor/+page.svelte @@ -0,0 +1,158 @@ + + +Script monitor · Yaci Viewer + + + {#snippet icon()}{/snippet} +

+ Every new transaction that runs scripts is re-run in your browser with julc as its block arrives: pass or fail, the budget used, and how close it came to the + per-transaction limits. +

+ {#snippet actions()} + {#if monitor.running} + + {:else} + + {/if} + {#if monitor.entries.length}{/if} + {/snippet} +
+ +{#if !supported} + +{:else if installed === false} + +{:else} +
+ {#if monitor.running} + + Watching{monitor.lastBlock ? ` · block #${num(monitor.lastBlock)}` : ' for blocks…'} + {:else} + Paused + {/if} + {#if monitor.busy || monitor.queued}{monitor.queued ? `${monitor.queued} waiting` : 'Running'}{/if} + +
+ {#if !devnet && monitor.cap > 3} +

Each re-run takes several store requests; on a busy public network, re-running every script transaction puts real load on the store.

+ {/if} + {#if monitor.failure}

{monitor.failure}

{/if} + +
+ + + + + + +
+ + {#if !monitor.entries.length} + + {:else} +
    + {#each monitor.entries as e (e.hash)} +
  1. + + +
  2. + {/each} +
+ {/if} +{/if} diff --git a/applications/viewer/src/routes/stake/[address]/+page.server.ts b/applications/viewer/src/routes/stake/[address]/+page.server.ts index fd53382b..0a12098c 100644 --- a/applications/viewer/src/routes/stake/[address]/+page.server.ts +++ b/applications/viewer/src/routes/stake/[address]/+page.server.ts @@ -1,53 +1,70 @@ import { error } from '@sveltejs/kit'; +import { paging, tryApi } from '$lib/api/client'; +import type { Slow } from '$lib/api/slow'; +import type { AnalyticsSource } from '$lib/analytics/types'; +import { analyticsRewards, REWARD_EPOCHS } from '$lib/server/analytics-sections'; +import { GUARD, guardedApi, settle } from '$lib/server/heavy-guard'; +import { stakeAccountSection } from '$lib/server/stake-account'; +import { stakeInsights } from '$lib/server/insights'; 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; +const TABS = { rewards: 'rewards', withdrawals: 'withdrawals', other: 'reward_rest' } as const; + +/** A list section: its rows, and the epochs and source when they came from analytics. */ +type List = { rows: Any[]; analytics: (AnalyticsSource & { fromEpoch: number; toEpoch: number }) | null }; + +/* + * A stake account: what it controls, where it delegates (pool and DRep), and its rewards history. The account and the + * list stream in after the page renders: a busy account can take Yaci Store half a minute or more, and the store keeps + * running a query after the viewer gives up (bloxbean/yaci-store#1269), so they go through the guard. When they are + * too slow and the store has its analytics API, the account and the rewards lists come from there instead (rewards + * then page by 20 epochs). + */ export const load: PageServerLoad = async ({ params, url, fetch }) => { - const address = params.address; - - if (!address || !address.startsWith('stake')) { - throw error(400, 'Invalid 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') || 'rewards'; - - 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 [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(() => []), - ]); - - return { - address, - rewards, - rewardRest, - withdrawals, - currentPage: page, - count, - order, - tab - }; + const address = params.address.trim(); + if (!/^(stake|stake_test)1[02-9ac-hj-np-z]+$/.test(address)) error(404, 'That is not a stake address.'); + + const asked = url.searchParams.get('tab') ?? 'rewards'; + const tab = (Object.hasOwn(TABS, asked) ? asked : 'rewards') as keyof typeof TABS; + const endpoint = TABS[tab]; + const { page, count } = paging(url, 20); + const listPath = `/accounts/${address}/${endpoint}`; + // A deep page is slow because of its offset: it gets its own guard key, so it can't mark the list for every visitor. + const listKey = page === 1 ? listPath : `${listPath} page ${page} of ${count}`; + + const drep = await tryApi(`/governance/delegation-votes/address/${address}`, { + fetch, + params: { page: 1, count: 1, order: 'desc' }, + timeoutMs: GUARD.timeoutMs.balance + }); + + const fromAnalytics = async (otherwise: Slow): Promise> => { + if (endpoint === TABS.withdrawals) return otherwise; + const latest = await tryApi('/epochs/latest', { fetch, timeoutMs: GUARD.timeoutMs.balance }); + if (latest?.epoch == null) return otherwise; + const r = await settle(analyticsRewards(address, { fetch, page, currentEpoch: Number(latest.epoch), rest: endpoint === TABS.other })); + if (!r.ok || !r.value) return otherwise; + return { ok: true, value: { rows: r.value.rows, analytics: { ...r.value.source, fromEpoch: r.value.fromEpoch, toEpoch: r.value.toEpoch } } }; + }; + const list = settle(guardedApi(listKey, listPath, { fetch, params: { page, count, order: 'desc' }, timeoutMs: GUARD.timeoutMs.accountLists }).then((rows) => ({ rows: rows ?? [], analytics: null }))).then( + (r): Slow | Promise> => (!r.ok && r.reason === 'heavy' ? fromAnalytics(r) : r) + ); + + return { + address, + account: stakeAccountSection(address, fetch), + drep: (drep?.[0] ?? null) as { drep_id?: string; drep_hash?: string; drep_type?: string; block_time?: number } | null, + tab, + page, + count, + list, + rewardEpochs: REWARD_EPOCHS, + // Rewards and active stake over 50 epochs, from Yaci Store analytics where the store has it. Streamed. + history: tryApi('/epochs/latest', { fetch, timeoutMs: GUARD.timeoutMs.balance }) + .then((latest) => stakeInsights(address, fetch, latest?.epoch != null ? Number(latest.epoch) : null)) + .catch(() => null) + }; }; diff --git a/applications/viewer/src/routes/stake/[address]/+page.svelte b/applications/viewer/src/routes/stake/[address]/+page.svelte index 549473fc..98bd71d1 100644 --- a/applications/viewer/src/routes/stake/[address]/+page.svelte +++ b/applications/viewer/src/routes/stake/[address]/+page.svelte @@ -1,299 +1,149 @@ -
-
- -
-
-

- 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 account?.pool_id}Delegating{:else if account}Not delegated{/if} + {/snippet} +
+ +
+
+
+
Controlled stake
+
+ {#if accountResult.value === undefined} + {:else if account}{:else}—{/if} +
+
+ {#if accountResult.value && !accountResult.value.ok} + {accountResult.value.reason === 'error' ? 'Couldn’t load this stake account.' : 'This stake account is too slow to load on this Yaci Store right now (yaci-store#1269).'} + {:else if account?.analytics} + + {:else} + Withdrawable rewards {#if account}{:else}—{/if} + {/if} +
+
+
+
Stake pool
+ {#if account?.pool_id} + {shorten(account.pool_id, 12, 8)} +
earns staking rewards
+ {:else if accountResult.value === undefined} +
+ {:else if !account} +
—
+ {: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 history.value}{/if} + +
+ + {#if list.value === undefined} + + {:else if !list.value.ok} + {#if list.value.reason === 'error'} + + {:else} + + {/if} + {: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} + {#if listAnalytics} + Epochs {listAnalytics.fromEpoch} to {listAnalytics.toEpoch}, {data.rewardEpochs} epochs a page. + {/if} + `${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} + + 0 : rows.length === data.count} /> + {/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..b64c2f23 100644 --- a/applications/viewer/src/routes/transactions/[id]/+page.server.ts +++ b/applications/viewer/src/routes/transactions/[id]/+page.server.ts @@ -1,30 +1,4 @@ +import { loadTransaction } from '$lib/tx/load.server'; 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() : []), - ]); - - if (tx) { - return { - tx, - contracts: scripts, - metadata, - withdrawals, - mints, - }; - } - - return { - status: 404, - body: { error: 'Can not fetch transaction.' } - }; -}; +export const load: PageServerLoad = ({ params, fetch }) => loadTransaction(params.id, fetch); diff --git a/applications/viewer/src/routes/transactions/[id]/+page.svelte b/applications/viewer/src/routes/transactions/[id]/+page.svelte index a310e3fe..228ee9de 100644 --- a/applications/viewer/src/routes/transactions/[id]/+page.svelte +++ b/applications/viewer/src/routes/transactions/[id]/+page.svelte @@ -1,347 +1,222 @@ - -
-
-
-
-

- Transaction Hash -

-
- {tx.hash} - -
-
- -
-
-
- Block / Slot -
-
- - {tx.block_height} - - / - {tx.slot} -
-
+Tx {shorten(tx.hash, 8, 6)} · Yaci Viewer -
-
Fee
-
- {lovelaceToAda(tx.fees, 4)} ADA -
+ + {#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}
- -
-
- Total Output +
+ {/if} + +
+ +
+ +
+ {#if active === 'utxos'} + (datumOf = u)} /> + {:else if active === 'scripts'} + + {:else if active === 'mints'} +
+ + + + {#each data.mints as m (m.unit)} + + + + + + + {/each} + +
AssetPolicyFingerprintQuantity
{parseUnit(m.unit).name}{Number(m.quantity) > 0 ? '+' : ''}{num(m.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} +
+
-
- {lovelaceToAda(tx.total_output, 2)} ADA + {/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}
- -
-
TTL
-
{tx.ttl}
+
+
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 withdrawals && withdrawals.length > 0} - - {/if} - - {#if mints && mints.length > 0} - - {/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}
-
- -
- {#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} - -
- {/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} -
- - - - - - - - - {#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} -
-
- {/if} -
- {/if} - - {#if activeTabIndex == JSON_TAB} -
- -
- {/if} -
+ {: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 === 'cbor' && cbor} + + {:else if active === 'raw'} + + {:else} + + {/if}
- + diff --git a/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.server.ts b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.server.ts new file mode 100644 index 00000000..00b3b2ab --- /dev/null +++ b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.server.ts @@ -0,0 +1,13 @@ +import { error } from '@sveltejs/kit'; +import { loadTransaction } from '$lib/tx/load.server'; +import type { PageServerLoad } from './$types'; + +const PURPOSES = ['spend', 'mint', 'cert', 'reward', 'vote', 'propose']; + +/* The debugger for one script run of a transaction: /transactions/{hash}/debug/{purpose}/{index}. */ +export const load: PageServerLoad = async ({ params, fetch }) => { + const purpose = params.purpose.toLowerCase(); + if (!PURPOSES.includes(purpose)) error(404, `Unknown script purpose “${params.purpose}”. Use one of ${PURPOSES.join(', ')}.`); + if (!/^\d{1,4}$/.test(params.index)) error(404, 'The redeemer index must be a number.'); + return { ...(await loadTransaction(params.id, fetch)), purpose, index: Number(params.index) }; +}; diff --git a/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.svelte b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.svelte new file mode 100644 index 00000000..431473e2 --- /dev/null +++ b/applications/viewer/src/routes/transactions/[id]/debug/[purpose]/[index]/+page.svelte @@ -0,0 +1,46 @@ + + +Debug {data.purpose} #{data.index} · Tx {shorten(tx.hash, 8, 6)} · Yaci Viewer + +Transaction {shorten(tx.hash, 10, 8)} + + + {#snippet icon()}{/snippet} + {#if script} +
+ Script +
+ {/if} + {#snippet actions()} + {#if script?.type && plutusVersion(script.type)}Plutus {plutusVersion(script.type)}{/if} + {#if tx.invalid}Failed on chain{/if} + {/snippet} +
+ +{#key `${tx.hash}/${data.purpose}/${data.index}`} + +{/key} diff --git a/applications/viewer/src/routes/transactions/[id]/fork/+page.server.ts b/applications/viewer/src/routes/transactions/[id]/fork/+page.server.ts new file mode 100644 index 00000000..c2aa4008 --- /dev/null +++ b/applications/viewer/src/routes/transactions/[id]/fork/+page.server.ts @@ -0,0 +1,5 @@ +import { loadTransaction } from '$lib/tx/load.server'; +import type { PageServerLoad } from './$types'; + +/* The what-if lab for a transaction: /transactions/{hash}/fork. */ +export const load: PageServerLoad = ({ params, fetch }) => loadTransaction(params.id, fetch); diff --git a/applications/viewer/src/routes/transactions/[id]/fork/+page.svelte b/applications/viewer/src/routes/transactions/[id]/fork/+page.svelte new file mode 100644 index 00000000..b463f2b9 --- /dev/null +++ b/applications/viewer/src/routes/transactions/[id]/fork/+page.svelte @@ -0,0 +1,30 @@ + + +Fork Tx {shorten(tx.hash, 8, 6)} · Yaci Viewer + +Transaction {shorten(tx.hash, 10, 8)} + + + {#snippet icon()}{/snippet} +

+ Change redeemers, datums, signers, the validity range, outputs, mint or the rules, and every script runs again as you type. Nothing is submitted: the fork exists + only in your browser. +

+ {#snippet actions()} + {#if tx.invalid}Failed on chain{/if} + {/snippet} +
+ +{#key tx.hash} + +{/key} diff --git a/applications/viewer/src/routes/withdrawals/+page.server.ts b/applications/viewer/src/routes/withdrawals/+page.server.ts index 00673fa4..9b93c2aa 100644 --- a/applications/viewer/src/routes/withdrawals/+page.server.ts +++ b/applications/viewer/src/routes/withdrawals/+page.server.ts @@ -1,21 +1,24 @@ +import { error } from '@sveltejs/kit'; +import { api, paging } from '$lib/api/client'; import type { PageServerLoad } from './$types'; -import { env } from '$env/dynamic/public'; -export const load: PageServerLoad = async ({ url }) => { - const page = parseInt(url.searchParams.get('page') || '1'); - const count = 15; +export interface Withdrawal { + tx_hash: string; + address: string; + amount: number | string; + epoch?: number; + block_number?: number; + block_time?: number; + slot?: number; +} - const INDEXER_BASE_URL = env.PUBLIC_INDEXER_BASE_URL; - - let withdrawals: any[] = []; - try { - const res = await fetch(`${INDEXER_BASE_URL}/txs/withdrawals?page=${page}&count=${count}`); - if (res.ok) { - withdrawals = await res.json(); - } - } catch { - // API may not be available - } - - return { withdrawals, currentPage: page, count }; +/* Reward withdrawals, newest first. /txs/withdrawals pages are 1-based. */ +export const load: PageServerLoad = async ({ url, fetch }) => { + const { page, count } = paging(url, 20, 100); + try { + const rows = await api('/txs/withdrawals', { fetch, params: { page, count } }); + return { rows: rows ?? [], page, count }; + } catch (e) { + error(503, (e as Error).message); + } }; diff --git a/applications/viewer/src/routes/withdrawals/+page.svelte b/applications/viewer/src/routes/withdrawals/+page.svelte index be60db06..0add36d4 100644 --- a/applications/viewer/src/routes/withdrawals/+page.svelte +++ b/applications/viewer/src/routes/withdrawals/+page.svelte @@ -1,124 +1,58 @@ -
-

Recent Withdrawals

- - {#if !data.withdrawals || data.withdrawals.length === 0} - - {:else} -
-
- - - -
-
- -
- {#if loading} -
- -
- {/if} -
- - - - - - - - - - - - {#each data.withdrawals as w, i (w.tx_hash + w.address + i)} - - - - - - - - {/each} - -
Tx HashAddressAmount (ADA)BlockTime
- - - - - - - {lovelaceToAda(w.amount, 2)} - - {w.block_number} - - {getDate(w.block_time)} -
-
-
- -
-
- - - -
-
- {/if} -
+Withdrawals · Yaci Viewer + + + {#snippet icon()}{/snippet} + Staking rewards moved from stake accounts into wallets, newest first. + + +
+ `${r.tx_hash}-${r.address}`} + rowHref={(r) => `/transactions/${r.tx_hash}`} + emptyTitle="No withdrawals yet" + emptyText="A withdrawal appears once a stake account claims its rewards in a transaction." + > + {#snippet cell(r, key)} + {#if key === 'account'} + + + {shorten(r.address, 12, 8)} + + + {:else if key === 'tx'} + {shorten(r.tx_hash, 10, 8)} + {:else if key === 'epoch'} + {#if r.epoch != null}{num(r.epoch)}{:else}—{/if} + {:else if key === 'age'} + + {:else if key === 'amount'} + {#if Number(r.amount) === 0}₳0{:else}{/if} + {/if} + {/snippet} + + +
diff --git a/applications/viewer/src/util/ada_util.js b/applications/viewer/src/util/ada_util.js deleted file mode 100644 index 0211dabf..00000000 --- a/applications/viewer/src/util/ada_util.js +++ /dev/null @@ -1,9 +0,0 @@ -export function lovelaceToAda(num, decimalPlaces) { - if (!decimalPlaces) - return num / Math.pow(10, 6); - else { - let ada = num / Math.pow(10, 6); - return ada.toFixed(decimalPlaces); - } -} - diff --git a/applications/viewer/src/util/util.js b/applications/viewer/src/util/util.js deleted file mode 100644 index 3dbe5c78..00000000 --- a/applications/viewer/src/util/util.js +++ /dev/null @@ -1,32 +0,0 @@ -export function truncate(fullStr, strLen, separator) { - if (!fullStr) return ''; - if (fullStr.length <= strLen) return fullStr; - - separator = separator || '...'; - - var sepLen = separator.length, - charsToShow = strLen - sepLen, - frontChars = Math.ceil(charsToShow/2), - backChars = Math.floor(charsToShow/2); - - return fullStr.substr(0, frontChars) + - separator + - fullStr.substr(fullStr.length - backChars); -}; - -export function getDate(blockTime) { - if (!blockTime) return ''; - - const date = new Date(blockTime * 1000); - const options = { - year: 'numeric', - month: 'long', - day: 'numeric', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - hour12: false - }; - - return date.toLocaleString(undefined, options); -} 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/static/robots.txt b/applications/viewer/static/robots.txt new file mode 100644 index 00000000..14db10d2 --- /dev/null +++ b/applications/viewer/static/robots.txt @@ -0,0 +1,6 @@ +# Per-address and per-account pages can start slow queries on a large Yaci Store +# (bloxbean/yaci-store#1269), so crawlers are kept off them. +User-agent: * +Disallow: /addresses/ +Disallow: /stake/ +Disallow: /epoch-stakes/accounts diff --git a/applications/viewer/tailwind.config.cjs b/applications/viewer/tailwind.config.cjs index 722f170a..505255c1 100644 --- a/applications/viewer/tailwind.config.cjs +++ b/applications/viewer/tailwind.config.cjs @@ -1,24 +1,78 @@ +/* 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: {} - }, - - plugins: [require('daisyui')], - darkMode: 'class', - daisyui: { - themes: [ - { - yaci: { - ...require('daisyui/src/theming/themes')['light'] + 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' } }, - 'yaci-dark': { - ...require('daisyui/src/theming/themes')['dark'] + 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: [] }; module.exports = config; diff --git a/applications/viewer/tests/script-lab.spec.ts b/applications/viewer/tests/script-lab.spec.ts new file mode 100644 index 00000000..4422a4de --- /dev/null +++ b/applications/viewer/tests/script-lab.spec.ts @@ -0,0 +1,220 @@ +import { expect, test } from '@playwright/test'; + +/* + * Script Lab (julc engine). Needs data on the configured store: + * + * SCRIPT_LAB_HASH=<56-hex Plutus script hash> SCRIPT_LAB_TX= \ + * SCRIPT_LAB_RUN= PW_CHANNEL=chrome npx playwright test script-lab + * + * The guardrail tests need a constitution with a guardrail script (Mainnet's: committee min size 5 is allowed, 1 is + * not); SCRIPT_LAB_PROPOSAL=_ names a parameter-change proposal for the proposal page. + * SCRIPT_LAB_CBOR_TX names a script transaction whose CBOR the store saved (store.transaction.save-cbor). + * + * The julc engine needs WebAssembly GC and exnref, which Playwright's bundled Chromium 128 lacks, so use a current + * browser channel (PW_CHANNEL=chrome). Without support the test checks the "can't run" message instead. + */ +const hash = process.env.SCRIPT_LAB_HASH; +const txHash = process.env.SCRIPT_LAB_TX; +const run = process.env.SCRIPT_LAB_RUN ?? 'spend/0'; +const proposal = process.env.SCRIPT_LAB_PROPOSAL; +const cborTx = process.env.SCRIPT_LAB_CBOR_TX; + +const supported = () => + WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 95, 1, 120, 0])) && + WebAssembly.validate(new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 4, 1, 96, 0, 0, 3, 2, 1, 0, 10, 8, 1, 6, 0, 31, 64, 0, 11, 11])); + +test('decodes a script into searchable UPLC and verifies its hash', async ({ page }) => { + test.skip(!hash, 'Set SCRIPT_LAB_HASH to a Plutus script hash on the configured store'); + await page.goto(`/scripts/${hash}`); + if (!(await page.evaluate(supported))) { + await expect(page.getByText('This browser can’t run the julc engine')).toBeVisible(); + return; + } + await page.getByRole('button', { name: 'Decode script' }).click(); + const code = page.getByRole('region', { name: 'UPLC' }); + await expect(code).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText('Verified')).toBeVisible(); + await expect(page.getByText(/Builtins used \(\d+\)/)).toBeVisible(); + await expect(code.getByText('(program', { exact: false }).first()).toBeVisible(); + + // Search: typing finds matches, Enter moves to the next one. + await page.getByPlaceholder('Search uplc').fill('builtin'); + await expect(page.getByText(/^1 \/ \d+\+?$/)).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page.getByText(/^2 \/ \d+\+?$/)).toBeVisible(); + + // Only the lines on screen are rendered. + expect(await code.locator('.sticky').count()).toBeLessThan(200); + + await page.getByRole('tab', { name: 'CBOR' }).click(); + await expect(page).toHaveURL(/view=cbor/); +}); + +test('re-runs every script of a transaction and compares with the declared budget', async ({ page }) => { + test.skip(!txHash, 'Set SCRIPT_LAB_TX to a transaction with Plutus redeemers on the configured store'); + await page.goto(`/transactions/${txHash}?tab=scripts`); + if (!(await page.evaluate(supported))) { + await expect(page.getByText('This browser can’t run the julc engine')).toBeVisible(); + return; + } + const cards = page.locator('article'); + const count = await cards.count(); + await page.getByRole('button', { name: /^Re-run/ }).click(); + await expect(page.getByRole('button', { name: 'Run again' })).toBeVisible({ timeout: 90_000 }); + + // Every script got a result, with julc's budget next to the declared one. + await expect(page.getByText(/julc re-run/i)).toHaveCount(count); + await expect(page.getByText(/declared$/).first()).toBeVisible(); + await expect(page.getByText(/reproduced exactly|reproduced with|passed|failed|couldn’t run/i).first()).toBeVisible(); + + // What the validator saw: the ScriptContext julc built, decoded. + await page.getByText('What the validator saw (ScriptContext)').first().click(); + await expect(page.getByText(/ScriptContext · /).first()).toBeVisible(); +}); + +test('steps through a script run, with breakpoints and step links', async ({ page }) => { + test.skip(!txHash, 'Set SCRIPT_LAB_TX (and SCRIPT_LAB_RUN) to a run on the configured store'); + await page.goto(`/transactions/${txHash}/debug/${run}`); + if (!(await page.evaluate(supported))) { + await expect(page.getByText(/can’t run the julc engine/)).toBeVisible(); + return; + } + const stepButton = page.getByRole('button', { name: 'Step', exact: true }); + await expect(stepButton).toBeVisible({ timeout: 90_000 }); + const position = page.getByText(/^Step [\d,]+ \/ [\d,]+$/); + await expect(position).toHaveText(/^Step 0 \//); + + await page.keyboard.press('ArrowRight'); + await page.keyboard.press('ArrowRight'); + await expect(position).toHaveText(/^Step 2 \//); + await expect(page).toHaveURL(/step=2/); + + await page.keyboard.press('End'); + await expect(page.getByText(/The script finished successfully|The script failed|Error/).first()).toBeVisible(); + + // Break on the first builtin the script uses, then continue from the start. + await page.keyboard.press('Home'); + const builtin = page.getByRole('button', { pressed: false }).filter({ hasText: /^[a-z][A-Za-z0-9_]+$/ }).first(); + await builtin.click(); + await page.keyboard.press('c'); + await expect(page.getByText('builtin', { exact: true }).first()).toBeVisible(); + + // A step link opens that exact step. + await page.goto(`/transactions/${txHash}/debug/${run}?step=5`); + await expect(page.getByText(/^Step 5 \//)).toBeVisible({ timeout: 90_000 }); +}); + +test('forks a transaction: an edit re-runs every script and shows what changed', async ({ page }) => { + test.skip(!txHash, 'Set SCRIPT_LAB_TX to a transaction with Plutus redeemers on the configured store'); + await page.goto(`/transactions/${txHash}/fork`); + if (!(await page.evaluate(supported))) { + await expect(page.getByText(/can’t run the julc engine/)).toBeVisible(); + return; + } + await expect(page.getByText('Fork vs on chain')).toBeVisible({ timeout: 90_000 }); + await expect(page.getByText('No changes yet: edit anything on the left')).toBeVisible(); + + // Replace the first redeemer with something no validator expects. + const editor = page.getByRole('textbox', { name: /#\d+ · / }).first(); + await editor.fill('{"constructor": 99, "fields": [{"int": 1}]}'); + await expect(page.getByText(/^1 change/)).toBeVisible(); + await expect(page.locator('article').getByText('Fork', { exact: true }).first()).toBeVisible(); + + // Invalid input is reported and not run. + await editor.fill('{"constructor": '); + await expect(page.getByText(/JSON|Unexpected/).first()).toBeVisible(); + + await page.getByRole('button', { name: 'Reset' }).first().click(); + await expect(page.getByText('No changes yet: edit anything on the left')).toBeVisible(); + + // A run of the fork can be exported as a fixture. + const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('button', { name: 'Export fixture' }).first().click()]); + expect(download.suggestedFilename()).toMatch(/\.json$/); +}); + +test('script page: activity, and a blueprint names the redeemer variants', async ({ page }) => { + test.skip(!hash, 'Set SCRIPT_LAB_HASH to a Plutus script hash on the configured store'); + await page.goto(`/scripts/${hash}`); + await page.waitForSelector('html[data-ready]'); + const activity = page.locator('section').filter({ hasText: 'Declared CPU per execution' }); + await expect(activity).toBeVisible({ timeout: 30_000 }); + await expect(activity.getByText(/^Constr \d+$/).first()).toBeVisible(); + + // Attach a blueprint naming constructor 0 of the redeemer; the variants list then uses its name. + const blueprint = { + preamble: { title: 'test/blueprint', plutusVersion: 'v2' }, + validators: [{ title: 'test.spend', hash, redeemer: { schema: { title: 'Action', anyOf: [{ title: 'Go', dataType: 'constructor', index: 0, fields: [] }, { title: 'Stop', dataType: 'constructor', index: 1, fields: [] }] } } }] + }; + await page.locator('input[type=file]').setInputFiles({ name: 'plutus.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(blueprint)) }); + await expect(page.getByText('test/blueprint · 1 validator')).toBeVisible(); + await expect(activity.getByText(/^Action\.(Go|Stop)$/).first()).toBeVisible(); + await page.getByRole('button', { name: 'Remove blueprint test/blueprint' }).click(); + await expect(page.getByText('test/blueprint · 1 validator')).toHaveCount(0); +}); + +test('script monitor watches new blocks', async ({ page }) => { + await page.goto('/scripts/monitor'); + if (!(await page.evaluate(supported))) { + await expect(page.getByText(/can’t run the julc engine/)).toBeVisible(); + return; + } + await expect(page.getByText(/^Watching/)).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('Blocks watched')).toBeVisible(); + await page.getByRole('button', { name: 'Pause' }).click(); + await expect(page.getByText('Paused')).toBeVisible(); +}); + +test('guardrail check: a draft is accepted, then rejected, and the rejection can be debugged', async ({ page }) => { + await page.goto('/governance/guardrail?p.committeeMinSize=5'); + await page.waitForSelector('html[data-ready]'); + test.skip(!(await page.getByText('the constitution’s guardrail', { exact: true }).count()), 'This chain’s constitution has no guardrail script'); + test.skip(!(await page.evaluate(supported)), 'This browser can’t run the julc engine'); + await expect(page.locator('[data-verdict=accepted]')).toBeVisible({ timeout: 60_000 }); + await page.getByLabel('New value for Committee min size').fill('1'); + await expect(page.locator('[data-verdict=rejected]')).toBeVisible({ timeout: 30_000 }); + await expect(page).toHaveURL(/p\.committeeMinSize=1/); + await page.getByRole('button', { name: 'Debug the rejection' }).click(); + await expect(page.locator('#guardrail-debugger').getByText(/failure at/)).toBeVisible({ timeout: 30_000 }); +}); + +test('proposal page runs the proposal’s guardrail', async ({ page }) => { + test.skip(!proposal, 'Set SCRIPT_LAB_PROPOSAL to a parameter-change proposal (_) with a guardrail'); + await page.goto(`/governance/govactions/${proposal}`); + await page.waitForSelector('html[data-ready]'); + await expect(page.getByText('Proposed changes')).toBeVisible(); + test.skip(!(await page.evaluate(supported)), 'This browser can’t run the julc engine'); + await page.getByRole('button', { name: 'Run the guardrail' }).click(); + await expect(page.locator('[data-verdict=accepted]')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByText('allowed').first()).toBeVisible(); +}); + +test('validator lab: compile Java, run it, and find the failing line', async ({ page }) => { + test.setTimeout(120_000); + await page.goto('/scripts/lab'); + await page.waitForSelector('html[data-ready]'); + test.skip(!(await page.evaluate(supported)), 'This browser can’t run the julc engine'); + const owner = 'a0f1aa7dca95017c11e7e373aebcf0c4568cf47ec12b94f8eb5bba8b'; + await page.getByRole('button', { name: 'Owner only' }).click(); + await page.getByPlaceholder('hex, e.g. a key hash').fill(owner, { timeout: 60_000 }); + await page.getByLabel('Add a signer').fill(owner); + await page.getByRole('button', { name: 'Add', exact: true }).click(); + await expect(page.locator('[data-verdict=accepted]')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: `Remove signer ${owner}` }).click(); + await expect(page.locator('[data-verdict=rejected]')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('not signed by the owner', { exact: true })).toBeVisible(); + await expect(page.getByText(/Failed on/)).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: 'Debug the Java' }).click(); + await expect(page.locator('#lab-debugger [data-current]')).toBeVisible({ timeout: 30_000 }); +}); + +test('stored transaction CBOR: the CBOR tab checks it, and re-runs use it', async ({ page }) => { + test.skip(!cborTx, 'Set SCRIPT_LAB_CBOR_TX to a script transaction whose CBOR the store saved'); + await page.goto(`/transactions/${cborTx}?tab=cbor`); + await page.waitForSelector('html[data-ready]'); + await expect(page.locator('[data-hash-check=ok]')).toBeVisible({ timeout: 30_000 }); + await expect(page.getByText('transaction body', { exact: true })).toBeVisible(); + test.skip(!(await page.evaluate(supported)), 'This browser can’t run the julc engine'); + await page.getByRole('tab', { name: /Scripts/ }).click(); + await page.getByRole('button', { name: /Re-run/ }).click(); + await expect(page.locator('[data-source=cbor]')).toBeVisible({ timeout: 60_000 }); +}); diff --git a/applications/viewer/tests/smoke.spec.ts b/applications/viewer/tests/smoke.spec.ts new file mode 100644 index 00000000..42c69a5b --- /dev/null +++ b/applications/viewer/tests/smoke.spec.ts @@ -0,0 +1,69 @@ +import { expect, test, type Page } from '@playwright/test'; + +/** Wait until the app has hydrated, so keyboard shortcuts are wired up. */ +const ready = (page: Page) => page.waitForSelector('html[data-ready]'); + +/* + * Smoke tests against a running Yaci Store (PUBLIC_INDEXER_BASE_URL, default http://localhost:8080/api/v1), + * e.g. a Yaci DevKit devnet started with Yaci Store enabled. + */ + +test('dashboard shows the chain tip and live blocks', async ({ page }) => { + await page.goto('/'); + await expect(page.getByRole('heading', { level: 1 })).toContainText('Chain tip'); + await expect(page.getByText('Live blocks')).toBeVisible(); + await expect(page.locator('a[href^="/blocks/"]').first()).toBeVisible(); +}); + +test('search jumps to a block', async ({ page }) => { + await page.goto('/'); + await ready(page); + await page.keyboard.press('/'); + const box = page.getByRole('dialog', { name: 'Search' }).getByRole('textbox'); + await expect(box).toBeFocused(); + await box.fill('1'); + await expect(page.getByRole('dialog').getByText('Block', { exact: true })).toBeVisible(); + await page.keyboard.press('Enter'); + await expect(page).toHaveURL(/\/blocks\/1$/); + await expect(page.getByRole('heading', { level: 1 })).toHaveText('#1'); +}); + +test('block page steps to the previous block with the arrow key', async ({ page }) => { + await page.goto('/blocks/2'); + await ready(page); + await page.keyboard.press('ArrowLeft'); + await expect(page).toHaveURL(/\/blocks\/1$/); +}); + +test('unknown transaction shows the not-found page', async ({ page }) => { + const res = await page.goto(`/transactions/${'0'.repeat(64)}`); + expect(res?.status()).toBe(404); + await expect(page.getByText("We couldn't find that")).toBeVisible(); +}); + +for (const path of [ + '/blocks', + '/transactions', + '/addresses', + '/assets', + '/epochs', + '/pools/search', + '/rewards', + '/epoch-stakes', + '/withdrawals', + '/adapot/current', + '/certificates/stakekey-registrations', + '/governance', + '/governance/proposals', + '/governance/dreps', + '/governance/committee' +]) { + test(`${path} renders without errors`, async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', (e) => errors.push(e.message)); + const res = await page.goto(path); + expect(res?.status()).toBeLessThan(400); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + expect(errors).toEqual([]); + }); +} diff --git a/applications/viewer/tests/test.ts b/applications/viewer/tests/test.ts deleted file mode 100644 index 0a724a4b..00000000 --- a/applications/viewer/tests/test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('index page has expected heading', async ({ page }) => { - await page.goto('/'); - const heading = await page.textContent('h1'); - expect(heading).toBe('Blockchain DataViewer'); -}); - -test('navigation is visible', async ({ page }) => { - await page.goto('/'); - const nav = page.locator('nav'); - await expect(nav).toBeVisible(); -}); - -test('blocks page loads', async ({ page }) => { - await page.goto('/blocks'); - await expect(page).toHaveURL(/.*blocks/); -}); - -test('transactions page loads', async ({ page }) => { - await page.goto('/transactions'); - await expect(page).toHaveURL(/.*transactions/); -}); diff --git a/applications/viewer/vite.config.js b/applications/viewer/vite.config.js index 652979fb..e524313b 100644 --- a/applications/viewer/vite.config.js +++ b/applications/viewer/vite.config.js @@ -1,11 +1,13 @@ import { sveltekit } from '@sveltejs/kit/vite'; -/** @type {import('vite').UserConfig} */ -const config = { +// Not annotated: vitest bundles its own Vite, whose config type differs from the app's. +export default { 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}'] + include: ['src/**/*.{test,spec}.{js,ts}', 'scripts/**/*.test.ts'] } }; - -export default config; diff --git a/config/env b/config/env index caf55888..9333b92b 100644 --- a/config/env +++ b/config/env @@ -27,7 +27,7 @@ HOST_YANO_N2N_PORT=14447 ####################################################### PUBLIC_INDEXER_BASE_URL=http://yaci-cli:8080/api/v1 #URL used in the client side -PUBLIC_INDEXER_CLIENT_BASE_URL=http://localhost:8080/api/v1 +PUBLIC_INDEXER_CLIENT_BASE_URL=http://localhost:${HOST_STORE_API_PORT}/api/v1 PUBLIC_INDEXER_WS_URL=ws://localhost:${HOST_STORE_API_PORT}/ws/liveblocks IS_DOCKER=true