diff --git a/DESIGN.md b/DESIGN.md
index 09bf217..119ec09 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -2,7 +2,7 @@
## Overview
-A loud, cheerful 1990s supermarket weekly flyer for visitors reading vault 6 (BaskVault `0x739fd5b653aa092a434534fa1ade67c1770b5a5b` on Robinhood Chain) and using a wallet to deposit, redeem or manage Stock Tokens. Every route shares the store-sign header, hanging aisle navigation, stocked shelves, sky-blue surround and checkerboard floor. Figures, warnings and owner controls use plain white surfaces. The owner’s store worker is the only person on the site, in three supplied poses for Vault, Deposit and Redeem.
+A loud, cheerful 1990s supermarket weekly flyer for visitors reading vault 6 (BaskVault `0x739fd5b653aa092a434534fa1ade67c1770b5a5b` on Robinhood Chain) and using a wallet to deposit, redeem or manage Stock Tokens. Every route shares the store-sign header, hanging aisle navigation, stocked shelves, sky-blue surround and checkerboard floor. Figures, warnings and owner controls use plain white surfaces. The owner’s store worker is the only person on the site, in three supplied poses that pop out of the Vault, Deposit and Redeem title panels.
## Colors
@@ -27,33 +27,34 @@ Status always has words; color alone never carries a check result. There is one
Anton (`Flyer`) is a local regular WOFF2 face for chunky condensed uppercase headings and the store sign. VT323 (`Pixel`) is a local regular WOFF2 face for the marquee and small accent labels. Body, forms, warnings and numbers use Arial/Helvetica/system sans at 16px and line-height 1.5. Address strings use monospace. Font licenses are local. No external font request is needed.
-H1 is 64px on desktop and 36px on phones. Title eyebrows use bold Arial at 14px/20px to avoid pixel-font shimmer on scaled displays. H2 is 32px (30px on phones), H3 25px (24px on phones). Headings use line-height 1.125 and balanced wrapping. Body measure is at most 70–75 characters for prose. Numbers use tabular numerals; quantities and addresses wrap without losing their full value. Inputs stay at 16px to avoid phone focus zoom. Native bold remains distinct in the system body family; local display faces use only their supplied regular weight.
+H1 is 64px on desktop and 36px on phones. Title eyebrows use bold Arial at 14px/20px to avoid pixel-font shimmer on scaled displays; the Vault title has none. On the three character pages the desktop heading instead fills its column on one line, or two balanced lines when one line would be too small. H2 is 32px (30px on phones), H3 25px (24px on phones). Headings use line-height 1.125 and balanced wrapping. Body measure is at most 70–75 characters for prose. Numbers use tabular numerals; quantities and addresses wrap without losing their full value. Inputs stay at 16px to avoid phone focus zoom. Native bold remains distinct in the system body family; local display faces use only their supplied regular weight.
## Layout
-The content maximum is 1200px, with 20px desktop and 16px phone gutters. Flyer panels have 24px padding, 24px gaps and thick colored frames. Forms and details group within panels. The Vault hero uses a flexible title and 380px illustration column (320px at 661–960px). Its checker strip stays at the title’s bottom. At 660px and below, the picture frame spans the full width directly beneath the title, with WOW! retained.
+The content maximum is 1200px, with 20px desktop and 16px phone gutters. Flyer panels have 24px padding, 24px gaps and thick colored frames. Forms and details group within panels. The Vault, Deposit and Redeem title panels share one pop-out layout. The heading and sentence (and the eyebrow on Deposit and Redeem) sit in the left column, centred top to bottom. The character stands at the right end on the checker strip, her head rising above the panel's top edge, with her speech bubble beside her head. Her picture is 390px high, 340px at 999px and below. At 660px and below she stands on her own yellow shelf at the top of the panel (300px high, 270px below 360px): on Vault the heading starts 17px under her shelf with the sentence below it; on Deposit and Redeem the eyebrow and heading stand beside her and the sentence follows below. The checker strip stays at least 24px below the title text at every width.
-At 960px, stock cards fall from three columns to two; role and owner grids become one column. At 660px, stock cards, forms, docs and claims use one column; the menu retains four legible hanging signs. Metric panels retain two columns. Each stock is a complete phone card with explicit field labels, not a horizontally scrolling table. The data refresh action stays beside its status. Phones omit the duplicate global transaction summary, keeping the status beside its action. Phone header, title and shelf spacing is reduced so task guidance appears sooner. Long addresses wrap. Required screenshots cover 1440px and 375px; additional checks and their limits are recorded with validation.
+The Stock shelves labels fill as many columns as fit (5 big and 7 small labels a row at 1440px, 2 big and 3 small on a 375px phone; 2 small at 360px and below, 1 big at 320px). At 960px, role and owner grids become one column. At 660px, forms, docs and claims use one column; the menu retains four legible hanging signs. Metric panels retain two columns. Each stock is a shelf label with its words written out, never a horizontally scrolling table. The data refresh action stays beside its status. Phones omit the duplicate global transaction summary, keeping the status beside its action. Phone header, title and shelf spacing is reduced so task guidance appears sooner. Long addresses wrap. Required screenshots cover 1440px and 375px; additional checks and their limits are recorded with validation.
## Elevation & Depth
-This is a flat print-inspired system. Flyer panels use a 5px sunshine border with a 3px red outer frame. Nested panels use 2px royal-blue borders. The sign and hanging menu use solid offset ink shadows. All text stays unrotated and in normal document flow; only the overflow-clipped slogan track moves. White receipt/data panels contain no decorative texture.
+This is a flat print-inspired system. Flyer panels use a 5px sunshine border with a 3px red outer frame. Nested panels use 2px royal-blue borders. The sign and hanging menu use solid offset ink shadows. Text stays in normal document flow. On the Stock shelves the shelf signs, the share stickers and the CLOSED stamp sit slightly tilted, like stickers on a shelf edge. Only the overflow-clipped slogan track moves continuously; the shelf labels swing once (see Components). White receipt/data panels contain no decorative texture.
## Shapes
-Panels and receipts are rectangular; controls use 3px corner radii and at least 44px height. Art combines original SVG/CSS with the manifest files kept byte for byte: the owner’s character (the only person on the site) and Microsoft Fluent Emoji flat food (MIT). The three pictures show a produce crate, an offered basket and a full grocery bag. The project’s BASKET PROTOCOL name badge is allowed. Word-only starbursts use a CSS polygon and carry only WOW! or Fresh! The only checker pattern is a decorative floor/title strip. Decoration has no currency, numerical labels, real product marks or borrowed characters.
+Panels and receipts are rectangular; controls use 3px corner radii and at least 44px height. Art combines original SVG/CSS with the manifest files kept byte for byte: the owner’s character (the only person on the site) and Microsoft Fluent Emoji flat food (MIT). The three pictures show a produce crate, an offered basket and a full grocery bag. The project’s BASKET PROTOCOL name badge is allowed. The only word-only starburst is Fresh! on the stock shelves (a CSS polygon). The only checker pattern is a decorative floor/title strip. The favicon is a red panel with a yellow frame and the header's basket mark in yellow. Decoration has no currency, numerical labels, real product marks or borrowed characters.
## Components
-- `components.tsx`: `PageTitle`, `Note`, `Empty`, `Addr`, `AddressLink`, `RoleInfo`, `TxButton`, `ActionStatus`, `DisabledReason`, `Receiver`, and the basket mark. Reuse these for hierarchy, warnings, readable addresses and guarded actions.
+- `components.tsx`: `PageTitle` (optional eyebrow, rendered only when given; optional title character: picture and speech bubble), `Note`, `Empty`, `Addr`, `AddressLink`, `RoleInfo`, `TxButton`, `ActionStatus`, `DisabledReason`, `Receiver`, and the basket mark. Reuse these for hierarchy, warnings, readable addresses and guarded actions.
- `Scenery.tsx`: `Marquee` and `StoreShelf`. The two decorative shelf rows use empty-alt local images: the first 14 food SVGs in manifest order above, then the next 19 below, each list repeated five times and clipped without motion. The light-blue back wall is `#c4e7ff`; red planks have ink edges. Desktop icons are 40px squares with 12px gaps in a 120px strip. Phones use 22px squares, 8px gaps and 3px planks in a 50px strip, with a 4px gap above the shelf frame; this preserves the previous above-fold messages at 375×812. Both shelf locations share this system.
-- `Marquee` has two identical halves, each four copies of the unchanged slogans and at least one viewport wide. Only `overflow: hidden` clips the window; the moving track has `will-change: transform`. It loops from 0 to −50% in 180 seconds on desktop and 45 seconds on phones. There is no pause, stop or hide control, per the owner’s decision. Animation runs only under `prefers-reduced-motion: no-preference`; reduced motion is static.
-- `.character-picture` uses each manifest width/height, `display: block`, `width: 100%`, `object-fit: contain` and `object-position: 50% 100%`. Vault picture heights are 300/260/200px; flow pictures are 320/260/240px at desktop/tablet/phone widths. Frames grow to accommodate speech. At the narrowest tablet aside widths, the Redeem bubble sits above-left of the head with a downward tail, reserving space above the bag; phone bubbles sit alongside the head. Deposit and Redeem stages use sunshine with 4px red borders. Speech stays still in white rounded bubbles with 3px ink borders and tails, in Flyer at 18px/1.4 (15px on phones); copy retains its supplied case. Each picture has its assigned descriptive alt text. No image is cropped, stretched, recolored or animated.
-- `Vault.tsx`: metric cards, stock cards and `DepositState`. Symbol, state, held amount, NAV share and failed checks remain visible; Details reveals feed and pool data. A search filters cards without changing contract order. Price status comes from contract reasons (fifteen, in `BaskVault.Reason` order); reasons 9–11 explicitly say the pool check was not run. `DepositState` (also on Deposit) prints the hours in New York words plus the raw seconds, and for reason 3 says when deposits reopen (weekday, New York time, date, countdown and the visitor's local time) and for reason 14 that they wait for a price update, computed by `newYork.ts` from `settings()` and the latest block timestamp. Unreadable values are spelled out, never shown as a real zero.
+- `Marquee` has two identical halves, each holding eight copies of the unchanged slogan line (720px wide in VT323 at 24px). Each half shows only its first k lines, k being the fewest whole lines that cover the window: 1 up to 720px wide, 2 up to 1440px, 3 up to 2160px, and so on up to 8 above 5040px. So there is no blank space in the window, the text runs on across the seam with the normal word gap, and the moving track is at most two screens plus two lines wide (no `will-change`; a very wide moving block stops being drawn on iPhones). Only `overflow: hidden` clips the window. It loops from 0 to −50% in k × 45 seconds (16px per second), or k × 11.25 seconds at 660px and below (64px per second). There is no pause, stop or hide control, per the owner’s decision. Animation runs only under `prefers-reduced-motion: no-preference`; reduced motion is static.
+- Title characters: each picture keeps its manifest width/height ratio with `object-fit: contain`, bottom-aligned, standing on the checker strip (on her shelf on phones). The bubble's tail points at her mouth; the bubble stays clear of her face, hands and held items, and no word is split across lines. Speech stays still in white rounded bubbles with ink borders, in Flyer at 20px (18px at 999px and below, 16px on phones); copy retains its supplied case. Lines: Vault "I was hoping you would come through my aisle", Deposit "Go ahead and put it in", Redeem "are you sure that’s all you came here for?". Each picture has its assigned descriptive alt text. No image is cropped, stretched, recolored or animated.
+- `Vault.tsx`: metric cards, the Stock shelves and `DepositState`. The Stock shelves are a store shelf unit (light-blue back wall, royal-blue uprights, the food strip as its top shelf) with every stock as a yellow shelf-edge label clipped onto a red shelf edge, in two groups under red shelf signs: "In the basket" (held, or held amount unreadable: big labels, biggest share first) and "On the shelf, not held" (small labels, listing order). Every label shows the ticker, the feed price as a red flyer price, when it updated ("just now" under a minute; the drawer keeps the full words) and the status in words (Open, Closed to deposits, Retired, or "Open · not taking deposits" with the price reason in a chip); big labels add the share sticker and bar, the value and the amount held. Only a feed price missing or too old (reason 9) is dimmed with an "old price" tag; under reasons 10 and 11 the price passed the age check, so it shows normally and the chip gives the reason. Warnings (check this pairing, balance check) print on the label. Tapping a label opens a drawer under its row with the feed, feed price and age, pool check, held amount, share, owed amount and the Stock Token and price feed links (Close or Escape). Filters All / Open / Closed / Held with counts and a search by ticker or address narrow the shelves; while one is on, "Show all" brings every stock back. Money uses `usd()`, cut to the cent like the NAV figure. The labels of each row swing once on their clips when the row first comes into view and lift 4px on hover or focus; nothing moves under reduced motion, and filtering or searching stops the swing for good. Price status comes from contract reasons (fifteen, in `BaskVault.Reason` order); reasons 9–11 explicitly say the pool check was not run. `DepositState` (also on Deposit) prints the hours in words (New York time, or UTC-5/UTC-4 under Dst 1/2), and for reason 3 says when deposits reopen (weekday, time, date, a countdown from block time and the visitor's local time) and for reason 14 that they wait for a price update, computed by `newYork.ts` from `settings()` and the latest block timestamp. Once the opening is reached it re-reads the vault and says deposits may be open. Unreadable values are spelled out, never shown as a real zero.
+- `Docs.tsx`: plain explanations of the vault; an FAQ panel after Redemption answers what happens when a stock can’t be sent on redeem (the Redeem page itself carries no such note).
- `Flows.tsx`: decimal amount fields, exact Use full balance controls, search-to-add stocks, previews immediately after selected amounts, named approval steps and owed-first claim cards. Claim all precedes the cards; zero balances fold away. Receivers remain full, wrapped and labelled. Preview data is invalidated when inputs/accounts change and after successful deposits.
-- `Owner.tsx`: `ActionForm`, pairing cards, genesis sequence (`listGenesis`, `finalizeGenesis`), settings and pending proposal cards built from `pendingProposals` ids and `proposal(id)`, executable from two to nine days after creation. The Hours setting shows the current schedule in words and raw seconds and takes From and To as a weekday plus New York time (or "Always open"), previewing value, value2 and words; Dst shows its three rule words; FreshCount and FreshHours explain the market-holiday closure. Proposals send the `BaskVault.Action` struct. Forms use native details disclosures. Stock selectors begin on Choose a stock, retain an existing choice across reads and clear it when the stock disappears. Launch folds to one done line; Pause, Pending and Immediate precede proposal forms. Jump controls scroll and focus without a route change. Consequential irreversible actions require an explicit checkbox; unauthorized controls remain visible and disabled. Financial buttons use system text rather than display lettering.
+- `Owner.tsx`: `ActionForm`, pairing cards, genesis sequence (`listGenesis`, `finalizeGenesis`), settings and pending proposal cards built from `pendingProposals` ids and `proposal(id)`, executable from two to nine days after creation. The Hours setting shows the current schedule in words and raw seconds and takes From and To as a weekday plus New York time (or "Always open"), previewing value, value2 and words; Dst shows its three rule words; FreshCount and FreshHours explain the market-holiday closure. Proposals send the `BaskVault.Action` struct. Forms use native details disclosures. Stock selectors begin on Choose a stock, retain an existing choice across reads and clear it when the stock disappears. Launch folds to one done line; Pause, Pending and Immediate precede proposal forms. Jump controls scroll and focus without a route change. Finalize stays disabled until the listing rows are pasted and every pasted row is listed with the same values. Consequential irreversible actions require an explicit checkbox; unauthorized controls remain visible and disabled. Financial buttons use system text rather than display lettering.
-All controls use native elements and visible labels. Focus has a 3px ink perimeter with a sunshine surround. Hash navigation focuses the page H1; a skip link reaches main content. Errors use alert regions, transaction progress uses a status region, and read failures offer Retry. Disabled buttons have nearby reasons and recovery controls. Primary hover/focus/active states explicitly keep white text on ink; disabled primary buttons use the same legible muted palette as other disabled controls. Action-local status retains transaction links and reports confirmed refreshes, proposal IDs/ready times and remaining owed amounts. Loading says Reading...; failure says unreadable with Retry. Balance and claim Retry controls appear only after failed reads; a complete, successful empty claim read says that nothing is owed. Incomplete stock reads say “Owed balances unreadable. Retry vault.” with Retry vault. Claim and loss result scopes include the token address so equal symbols cannot overwrite one another’s result. Motion is limited to the continuously running slow marquee (unless reduced motion is requested) and color-only control feedback. Buttons no longer scale text on press. Forced-colors focus uses the system Highlight color.
+All controls use native elements and visible labels. Focus has a 3px ink perimeter with a sunshine surround. Hash navigation focuses the page H1; a skip link reaches main content. Errors use alert regions, transaction progress uses a status region, and read failures offer Retry. Disabled buttons have nearby reasons and recovery controls. Primary hover/focus/active states explicitly keep white text on ink; disabled primary buttons use the same legible muted palette as other disabled controls. Action-local status retains transaction links and reports confirmed refreshes, proposal IDs/ready times and remaining owed amounts. Loading says Reading...; failure says unreadable with Retry. Balance and claim Retry controls appear only after failed reads; a complete, successful empty claim read says that nothing is owed. Incomplete stock reads say “Owed balances unreadable. Retry vault.” with Retry vault. Claim and loss result scopes include the token address so equal symbols cannot overwrite one another’s result. Motion is limited to the continuously running slow marquee, the Stock shelves labels' one-time restocking swing and 4px lift (all off when reduced motion is requested) and color-only control feedback. Buttons no longer scale text on press. Forced-colors focus uses the system Highlight color.
## Do's and Don'ts
diff --git a/README.md b/README.md
index 703481d..dedf6bf 100644
--- a/README.md
+++ b/README.md
@@ -29,10 +29,11 @@ Publish the **contents of the committed `dist/` directory** to the existing stat
- `allAssets()` supplies `managedBalance`, `owedBalance`, `balanceReadable`, `shortfall` (short means shortfall above zero), prices, price reasons and pool prices. Failed aggregate reads fall back to `assetTokens()` and `asset(token)` per stock; pool checks and reasons remain unreadable. One `depositStatus([])` supplies the global status, and the latest block timestamp is read with every snapshot because the vault's hours and freshness use block time.
- Deposit hours are seconds since Sunday 00:00 New York time (`hoursFrom` inclusive, `hoursTo` exclusive; both zero means always open), with the `dst` setting choosing the US daylight saving rule, never daylight saving (UTC-5) or always daylight saving (UTC-4). `web/src/newYork.ts` ports `NewYorkTime.sol`; the deposit status says when deposits reopen (reason 3) or that they wait for a price update (reason 14), computed from `settings()` and the latest block, never the browser time zone.
- Deposits use token/amount arrays, exact short approvals, wallet receiver, 0.5% minimum-share tolerance and a ten-minute deadline. The contract preview checks the size limit. Successful deposits clear inputs and the old preview.
-- Redemption refreshes its preview immediately before sending, uses a selected nonzero receiver other than the vault, and 0.1% minimum amounts in current `assetTokens` order. Unsent stocks are owed to that receiver. Claims read every listed token and send groups of at most ten; one failing stock reverts its whole batch, nothing is lost, and each stock has its own Claim button.
+- Redemption refreshes its preview immediately before sending, uses a selected nonzero receiver other than the vault, and 0.1% minimum amounts in current `assetTokens` order. Unsent stocks are owed to that receiver (explained in the Docs FAQ). Claims read every listed token and send groups of at most ten; one failing stock reverts its whole batch, nothing is lost, and each stock has its own Claim button.
- Genesis listings use `listGenesis`, show each row as it is checked, preserve row order and re-read each listing after confirmation. Whether a token is listed comes from `assetTokens()` because `asset()` reverts for unlisted tokens. A set pool requires a `minLiquidity` above zero. Current `poolWindow` pool liquidity and pool/feed gap are checked before listing; failed or marked rows need correction. A declined prompt can resume after checks. Submitted hashes survive receipt timeouts in session storage; listing compares pending/latest nonces after reload and prefers the node's record of the saved transaction's nonce over the count saved before the prompt.
-- The only browser storage is per-tab listing text and pending transaction recovery; there are no cookies, trackers or new services.
-- Governance sends `propose(Action)` with the `BaskVault.Action` struct (kind, token, target, pool, quoteFeed, value, value2, setting), fields per kind as in the pinned source and unused fields zero. Proposals wait two days and are executable until nine days after creation (exclusive); the Proposed event's `executableAt` is shown after sending. Settings are in vault 6 order with `Dst` at index 6. The UI additionally requires deposits paused to execute Retire and Resync, and to propose Resync.
+- The only browser storage is the pasted listing rows (kept for this tab and copied for new tabs) and pending transaction recovery; there are no cookies, trackers or new services.
+- Governance sends `propose(Action)` with the `BaskVault.Action` struct (kind, token, target, pool, quoteFeed, value, value2, setting), fields per kind as in the pinned source and unused fields zero. Proposals wait two days and are executable until nine days after creation (exclusive); the Proposed event's `executableAt` and `expiresAt` are shown after sending. Settings are in vault 6 order with `Dst` at index 6. The UI additionally requires deposits paused to execute Retire and Resync, and to propose Resync.
+- Dates are written in words with UTC and New York time, for example "Sun 11 Oct 2026, 04:40 UTC (00:40 New York)", never as a numeric date. A start ("executable from", "Wait until", a loss's recognition date) is rounded up to the next whole minute and an end is written "until just before" a time rounded down, so a waiting window never looks longer than it is.
## Reproduce ABI and runtime
@@ -51,9 +52,11 @@ Compilation uses solc 0.8.26+commit.8a97fa7a via `--standard-json`, optimizer 20
## Validation
-`npm run validate` checks decimal-normalized NAV, indicative flags, all eleven proposal kinds as the Action struct (including Hours and Dst), minimums, receivers, listing input, revert wording, live aggregate reads and bytecode identity. `check-interface.ts` adds the New York hours parser, the `NewYorkTime` port's inside/nextOpening vectors for all three `dst` modes, setting words and the pool `minLiquidity` rule. `check-preservation.mjs` checks unchanged protected configuration/contracts, forbids every abandoned vault address fragment in every file and keeps the send-check invariants. `check-calldata.mjs` keeps `Losses.tsx`, `main.tsx`, `components.tsx` and `Scenery.tsx` byte-identical to HEAD and compares every call expression of `Flows.tsx` and `Vault.tsx` with HEAD.
+`npm run validate` checks decimal-normalized NAV, indicative flags, all eleven proposal kinds as the Action struct (including Hours and Dst), minimums, receivers, listing input, revert wording, live aggregate reads and bytecode identity. `check-interface.ts` adds the New York hours parser, the `NewYorkTime` port's inside/nextOpening vectors for all three `dst` modes, setting words and the pool `minLiquidity` rule. `check-preservation.mjs` checks unchanged protected configuration/contracts, forbids every abandoned vault address fragment in every file and keeps the send-check invariants. `check-calldata.mjs` compares the source with the commit it was applied to (HEAD): see "Site update v9" below for what it allows.
-The bounded browser/fork runner covers a 25-row listing with a declined prompt, receipt timeout, pending nonce and reload, a US market holiday (every feed three hours old) and a Saturday, the Hours and Dst proposals and every transaction control. It requires Anvil, solc 0.8.26, Playwright and Chromium (WebKit for `check-art.mjs`). The runner requires an unfinalized empty genesis state so that it can exercise initial listing. Install Playwright outside the repository if necessary, and set `PLAYWRIGHT_MODULE` to its `index.mjs` when it is not at the runner's environment default; `BASKET_RPC` selects the upstream RPC.
+The bounded browser/fork runner covers a 25-row listing with a declined prompt, receipt timeout, pending nonce and reload, a US market holiday (every feed three hours old) and a Saturday, the Hours and Dst proposals and every transaction control. It requires Anvil, solc 0.8.26, Playwright and Chromium (WebKit for `check-art.mjs`). The runner requires an unfinalized empty genesis state so that it can exercise initial listing. Install Playwright outside the repository if necessary, and set `PLAYWRIGHT_MODULE` to its `index.mjs` when it is not at the runner's environment default.
+
+The runner forks block **83,874,298** by default: the last block where vault 6 is empty and not finalized, with the same code as today. The live vault now has 25 listed stocks and may be finalized, so a fresh block can no longer exercise the first listing. That block is older than what pruning RPCs keep, so the runner reads it from the archive RPC `https://robinhood.api.pocket.network` through a small read-only proxy built into the runner: only read methods pass, block-hash parameters become block numbers, at most eight requests run at once and busy or failed reads are retried. `BASKET_FORK_BLOCK` and `BASKET_RPC` still override both; an override RPC must be an archive node for chain 4663, and an override block must keep vault 6 empty and unfinalized. The runner also mines one local block before its first call (Anvil 1.8 needs it) and waits for the second "List stocks" pass to finish before Finalize.
```sh
cd web
@@ -70,12 +73,52 @@ node scripts/check-calldata.mjs
node scripts/check-bundle.mjs
```
-It pins a fresh block after 83,448,310 (or `BASKET_FORK_BLOCK`), caches the needed genuine account/storage (vault 6 layout: words 0-41, the tokens array, the address and proposal mappings) and next 512 block-history ring reads before public RPC pruning, preserves the actual vault bytecode, creates disposable Stock Token/feed/pool fixtures on the fork (feeds have a settable lag), serves the actual production export at `/preview/`, exercises wallet actions and writes screenshots and decoded sends to `artifacts/`. Before each deposit it warps to the next opening when the fork is outside the hours and checks `insideHours()` on both sides of the boundary. All child services close when the command ends.
+It forks the block above (it must be after 83,448,310), caches the needed genuine account/storage (vault 6 layout: words 0-41, the tokens array, the address and proposal mappings) and next 512 block-history ring reads before public RPC pruning, preserves the actual vault bytecode, creates disposable Stock Token/feed/pool fixtures on the fork (feeds have a settable lag), serves the actual production export at `/preview/`, exercises wallet actions and writes screenshots and decoded sends to `artifacts/`. Before each deposit it warps to the next opening when the fork is outside the hours and checks `insideHours()` on both sides of the boundary. All child services close when the command ends.
Actual results, design review coverage, screenshots and limitations are recorded in [artifacts/validation.md](artifacts/validation.md). The 2026-10-09 vault 6 production build, typecheck, live/ABI, guard, export, motion and fix checks and the fork walk-through passed (61 checks, 70 wallet sends). The 24 empty/25-stock screenshots cover all six pages at 1440 and 375px. WebKit was unavailable for `check-art.mjs`, so its manifest hash part was repeated separately and its WebKit render part is recorded as unperformed; the art files are unchanged. Native Windows display scaling and physical phones remain unverified. Fork evidence is local testing, not an independent certification or a live transaction.
Fonts are Anton and VT323 under the SIL Open Font License; notices are in `web/public/fonts/`. Art is original SVG/CSS plus the manifest files kept byte for byte: the owner’s character (the only person on the site) and Microsoft Fluent Emoji food (MIT). `artifacts/art-manifest.json` pins all 37 assets; the food license is `web/public/art/food/Fluent-Emoji-MIT.txt`. There are no borrowed characters. Decorative artwork carries no numbers, currency signs or real brand names; the project’s BASKET PROTOCOL name badge is allowed. The pinned Better Interface and Impeccable attribution and licenses are retained in `web/validation/DESIGN-GUIDANCE-LICENSE`.
-The slogan strip always runs, with no pause control, except when the visitor requests reduced motion. Local art is loaded through relative image URLs; the shipped export makes no outside requests except chain RPC reads. The two food shelves and all three picture frames are documented in [DESIGN.md](DESIGN.md).
+The slogan strip always runs, with no pause control, except when the visitor requests reduced motion. Local art is loaded through relative image URLs; the shipped export makes no outside requests except chain RPC reads. The two food shelves, the title characters and the slogan strip are documented in [DESIGN.md](DESIGN.md).
To compare phone visibility to a saved previous export, run `BASKET_BASELINE_DIST=/absolute/path/to/previous/dist node scripts/check-visibility.mjs` from `web/`. It checks six routes at 375×812, both without a wallet and with a read-only injected connection to the live owner.
+
+## Site update v9
+
+Look:
+
+- The store worker pops out of the Vault, Deposit and Redeem title panels. She stands on the checker strip at the panel's right end, her head rises above its top edge and her speech bubble sits beside her head. On phones she stands on her own shelf at the top of the panel. The separate picture frames and the WOW! burst are gone; Fresh! on the stock shelves stays.
+- The tagline is gone from the footer and from the Vault title. The footer keeps the basket mark, "Basket Protocol" and its links. The Vault title has no eyebrow; its heading and sentence sit centred beside her.
+- The yellow note about unsent stocks left the Redeem page. A new FAQ panel in Docs, right after Redemption, explains it.
+- The slogan strip shows only as many whole slogan lines as the screen needs (rule in DESIGN.md), so wide windows have no blank gap and phones keep drawing it.
+- Stock shelves: each stock is a yellow shelf-edge label on a red store shelf (rule in DESIGN.md). Held stocks get big labels at the top, biggest share first; the rest get small labels in listing order. A tap opens the details (feed, pool check, owed amount, both addresses). Filters and search are kept, with "Show all" while one is on. The labels swing once when their shelf first comes into view (never under reduced motion). Values are cut to the cent like the NAV figure. Display only: no read, check or transaction changed.
+- New favicon: a red panel with a yellow frame and the header's basket in yellow.
+
+Fixes (the same inputs still give the same calldata; the only new behaviour near a send is a refusal before the wallet prompt):
+
+1. Pool and List proposals take `minLiquidity` as a 128-bit number, like the listing rows.
+2. BalanceGas and PayGas show the vault's combined gas rules and the current maximum.
+3. Under Dst 1 or 2 the hours read "UTC-5" or "UTC-4" instead of New York time.
+4. PaymentFailed and BalanceUnreadable name the stock; a failing Claim all says which stock blocked the batch.
+5. MathOverflow and InvalidTick have plain words.
+6. The reopen countdown follows block time; once the opening is reached the page re-reads the vault and says deposits may be open.
+7. Dates are in words (see Integration).
+8. FreshCount 0 reads "off"; other values read in words.
+9. Vault and Deposit show the hours in words only; Owner keeps the raw seconds.
+10. The Launch panel says when the listed stocks are unreadable.
+11. Redeem preview amounts follow each stock's index and are hidden when the stock list is unreadable.
+12. Pool liquidity words use the vault's `poolWindow` and `poolDeviation`.
+13. Once a pending listing transaction is mined or replaced, the page says so; a transaction cancelled or replaced in the wallet is never reported as Confirmed.
+14. Finalize needs the pasted listing rows and refuses rows listed with other values.
+15. Under Dst 0 an Hours start on Sunday 2:00-2:59 am is refused (that hour is skipped on the spring-forward Sunday).
+16. The vault itself is refused as new owner, guardian or fee recipient.
+
+What the checks allow in this update:
+
+- `check-bundle.mjs`: in `web/src` only the look files (components, Vault, Flows, Docs, styles, main, Scenery) and the fix files (chain, model, newYork, governance, Owner, wallet) change; Losses, poolMath, deployment and the ABI stay as they were; `web/pinned`, `web/index.html`, the build configuration and the validation fixtures are unchanged; in `web/public` only `favicon.svg` changes, and it and `dist/favicon.svg` must have the chosen icon's SHA-256; no file is added.
+- `check-calldata.mjs`: main and Scenery equal HEAD once their one edit is put back. In every source file the vault, token, feed and read calls, the argument parsers and encoders and every wallet send or raw provider request equal HEAD, apart from the listed fix changes; sends and requests have no exception at all, and Vault, Scenery, main and Docs have none. Vault.tsx makes no read, check or send call; its controls equal a pinned list (the Retry vault button and address links, plus the Stock shelves' Details, Close, filters, search and Show all) and the shelves' handlers equal their pinned text. The changed Owner actions and the governance argument builders equal HEAD once their listed pieces are put back, and the new refusal helpers equal their pinned text. HEAD's and the new encoders are run side by side on thousands of inputs, under fixed clocks for the deadline: same calldata, and only the listed new refusals.
+- `check-art.mjs` (Chromium and WebKit): the title characters, the tagline gone, the other pages' eyebrows unchanged, the footer, the slogan strip rule, and the Stock shelves (one label per listed stock with ticker, price, status and Details, inside the shelf unit, at least 44px to tap, no old stock cards, still under reduced motion; the unit's food strip shows its first row only). `check-motion.mjs` (Chromium, three display scales): the same strip rule while scrolling, and the shelves' swing runs once per label and stops.
+- `check-visibility.mjs`: only the Vault tagline may disappear from the first phone screen; title text pushed down by her picture is listed with its reason.
+- `check-interface.ts`: unit cases for the fixes and the date rounding.
+
+The fork runner's 24 screenshots are JPEG quality 36 (40 before): with the Docs FAQ and the longer Owner texts the quality-40 set no longer fits the 8 MiB delivery budget. So run `run-browser.mjs` before `check-bundle.mjs` (as in the list above): until the runner rewrites the 24 screenshots, the tree still holds the quality-40 set and the bundle reads over the limit.
diff --git a/web/public/favicon.svg b/web/public/favicon.svg
index 3070648..50345f9 100644
--- a/web/public/favicon.svg
+++ b/web/public/favicon.svg
@@ -1 +1 @@
-
+
\ No newline at end of file
diff --git a/web/scripts/check-art.mjs b/web/scripts/check-art.mjs
index ca02070..e4ec09f 100644
--- a/web/scripts/check-art.mjs
+++ b/web/scripts/check-art.mjs
@@ -13,7 +13,7 @@ const manifest = JSON.parse(manifestBytes), hashes = [];
for (const dir of ["web/public", "dist"]) {
const expected = manifest.files.map(f => f.path).sort();
const actual = fs.readdirSync(path.join(root, dir, "art"), {recursive:true, withFileTypes:true})
- .filter(e => e.isFile()).map(e => path.relative(path.join(root,dir),path.join(e.parentPath,e.name))).sort();
+ .filter(e => e.isFile()).map(e => path.relative(path.join(root,dir),path.join(e.parentPath,e.name)).split(path.sep).join("/")).sort();
assert.deepEqual(actual, expected);
for (const f of manifest.files) {
const b = fs.readFileSync(path.join(root, dir, f.path));
@@ -22,11 +22,67 @@ for (const dir of ["web/public", "dist"]) {
hashes.push({path: `${dir}/${f.path}`, bytes:b.length, sha256:sha(b)});
}
}
+// Look update: the pop-out title character on Vault, Deposit and Redeem.
+const LINES = {vault:"I was hoping you would come through my aisle", deposit:"Go ahead and put it in", redeem:"are you sure that’s all you came here for?"};
+// Picture regions in natural pixels [x0, y0, x1, y1]. The bubble may cover no opaque pixel of the picture and none of
+// these boxes; its tail may cover no opaque pixel of the face (mouth included), hands or held items; the title text
+// may cover no opaque pixel of the figure.
+const REGIONS = {
+ vault: {w:606, h:1000, face:[150,55,300,212], raisedHand:[22,12,118,128], restingHand:[345,476,430,532], crate:[290,405,596,902]},
+ deposit: {w:313, h:1000, face:[78,58,208,205], leftHand:[30,282,96,348], rightHand:[218,282,282,348], basket:[10,296,293,505]},
+ redeem: {w:484, h:1000, face:[240,52,372,214], bag:[12,42,306,448], leftHand:[58,358,136,450], rightHand:[178,378,268,452]},
+};
+const pictureHeight = width => width >= 1000 ? 390 : width >= 661 ? 340 : width >= 360 ? 300 : 270;
+const bundle = fs.readdirSync(path.join(root, "dist/assets")).filter(f => f.endsWith(".js")).map(f => fs.readFileSync(path.join(root, "dist/assets", f), "utf8")).join("\n");
+for (const line of Object.values(LINES)) assert.ok(bundle.includes(line), line);
+assert.ok(bundle.includes("A different receiver must connect here to claim any stocks still owed to it."), "receiver wording");
+for (const gone of ["hehe", "WOW!", "Unsent stocks are owed", "must connect to claim", "vault-hero", "clerk-panel", "picture-stage"]) assert.ok(!bundle.includes(gone), "removed from the bundle: " + gone);
+// v9 A4: the old tagline is gone from the whole built site (page, scripts, styles, icon), in any letter case.
+const TAGLINE = /one\s+basket/i;
+for (const f of fs.readdirSync(path.join(root, "dist"), {recursive:true}).map(String).filter(f => /\.(html|js|css|svg|json|txt)$/.test(f)))
+ assert.doesNotMatch(fs.readFileSync(path.join(root, "dist", f), "utf8"), TAGLINE, "tagline in dist/" + f);
+// The other pages keep their eyebrow (HEAD text); the Vault title has none.
+const EYEBROWS = {vault:null, deposit:"Fill your basket", redeem:"At the checkout", docs:"Know your basket", owner:"Behind the counter", losses:"Accounting health"};
+// v9 A2 slogan strip: each half shows k whole slogan lines (one line is 720 px), k = the fewest lines that cover the
+// viewport (1 up to 720 px, 2 up to 1440 px, ... 8 from 5041 px); 45 s per line (16 px/s), 11.25 s at 660 px and
+// below (64 px/s); the moving block is at most two screens plus two lines wide and has no will-change.
+const SLOGAN = "Basket buddies! • Take a stroll down the aisles • Give your cart a twirl • ";
+const stripLines = width => Math.min(8, Math.max(1, Math.ceil(width / 720)));
+// In the page: the strip's geometry, with the animation paused at `fraction` of its loop (null: leave it). `joins` are
+// the gaps from the last glyph of each shown line to the first glyph of the next one (inside a half and across the
+// seam between the halves), `wordGap` the normal gap between a bullet and the next word.
+function stripGeometry(fraction) {
+ const track = document.querySelector(".marquee-track"), win = track.parentElement, a = track.getAnimations();
+ if (fraction !== null && a[0]) { a[0].pause(); a[0].currentTime = a[0].effect.getTiming().duration * fraction; }
+ const R = e => e.getBoundingClientRect();
+ const glyph = (span, i) => {
+ const w = document.createTreeWalker(span, NodeFilter.SHOW_TEXT);
+ for (let n, k = i; (n = w.nextNode()); k -= n.length) if (k < n.length) { const rg = document.createRange(); rg.setStart(n, k); rg.setEnd(n, k + 1); return rg.getBoundingClientRect(); }
+ return null;
+ };
+ const halves = [...track.children].map(h => ({text: h.textContent, width: R(h).width, minWidth: getComputedStyle(h).minWidth,
+ spans: [...h.children].map(s => ({text: s.textContent, display: getComputedStyle(s).display, width: R(s).width}))}));
+ const shown = [...track.children].map(h => [...h.children].filter(s => getComputedStyle(s).display !== "none"));
+ const run = shown.flat(), text = run[0].textContent, last = text.trimEnd().length - 1, bullet = text.indexOf("•");
+ const wordGap = glyph(run[0], bullet + 2).left - glyph(run[0], bullet).right;
+ const joins = run.slice(1).map((s, i) => glyph(s, 0).left - glyph(run[i], last).right);
+ const t = R(track), w = R(win), cs = getComputedStyle(track);
+ // Box edges, exact in both engines: the shown lines sit edge to edge from the track's left end to its right end,
+ // each line box as wide as its text (WebKit rounds single-glyph boxes to whole pixels, so glyph gaps get a looser bound).
+ const boxes = run.map(R), halfBoxes = [...track.children].map(R);
+ const edges = [boxes[0].left - t.left, t.right - boxes[boxes.length - 1].right, halfBoxes[0].left - t.left, halfBoxes[1].left - halfBoxes[0].right, t.right - halfBoxes[1].right,
+ ...boxes.slice(1).map((b, i) => b.left - boxes[i].right)];
+ const textWidths = run.map(sp => { const rg = document.createRange(); rg.selectNodeContents(sp); return R(sp).width - rg.getBoundingClientRect().width; });
+ return {halves, shownCounts: shown.map(s => s.length), track: t.width, frame: w.width, viewport: innerWidth, wordGap, joins,
+ lead: glyph(run[0], 0).left - t.left, tail: t.right - glyph(run[run.length - 1], last).right, edges, textWidths,
+ covers: t.left <= w.left + 0.01 && t.right >= w.right - 0.01, offset: t.left - w.left, running: a.filter(x => x.playState === "running").length, willChange: cs.willChange, animationName: cs.animationName,
+ transform: cs.transform, duration: a[0]?.effect.getTiming().duration, contain: getComputedStyle(win).contain, isolation: getComputedStyle(win).isolation};
+}
const server = http.createServer((req,res) => {
const parts = req.url.split("/");
const dir = parts[1] === "baseline" ? process.env.BASKET_BASELINE_DIST : path.join(root,"dist");
const p = path.resolve(dir, parts.slice(2).join("/") || "index.html");
- if (!p.startsWith(dir+"/")) return res.writeHead(403).end();
+ if (!p.startsWith(dir+path.sep)) return res.writeHead(403).end();
try {
res.setHeader("Content-Type", ({".html":"text/html",".js":"application/javascript",".css":"text/css",".webp":"image/webp",".svg":"image/svg+xml",".woff2":"font/woff2"})[path.extname(p)] || "text/plain");
res.end(fs.readFileSync(p));
@@ -34,7 +90,122 @@ const server = http.createServer((req,res) => {
});
await new Promise(r=>server.listen(0,"127.0.0.1",r));
const base = `http://127.0.0.1:${server.address().port}`;
-const checks=[], visibility=[], bubbles=[];
+// Title panel geometry in the page: bubble words, bubble/strip/frame edges and the wallet hint gap; with `pixels`, the
+// picture pixels under the bubble, its tail and the title text.
+function titleGeometry({R, name, pixels}) {
+ const title = document.querySelector(".page-title"), r = e => e.getBoundingClientRect();
+ const holder = title.querySelector(":scope > .title-character"), img = holder.querySelector("img.character-picture"), bubble = holder.querySelector("p.speech-bubble");
+ const T = r(title), br = r(bubble), ir = r(img), cs = getComputedStyle(bubble), bw = parseFloat(getComputedStyle(title).borderTopWidth), bl = parseFloat(cs.borderLeftWidth);
+ const out = {overflow: document.documentElement.scrollWidth > innerWidth, split: [], wordOutside: []};
+ const tn = bubble.firstChild, lines = new Set();
+ for (const m of tn.textContent.matchAll(/\S+/g)) {
+ const rg = document.createRange(); rg.setStart(tn, m.index); rg.setEnd(tn, m.index + m[0].length);
+ const tops = new Set([...rg.getClientRects()].filter(x => x.width > 0).map(x => Math.round(x.top)));
+ if (tops.size !== 1) out.split.push(m[0]);
+ tops.forEach(t => lines.add(t));
+ const wr = rg.getBoundingClientRect();
+ if (wr.left < br.left + bl - 0.5 || wr.right > br.right - bl + 0.5 || wr.top < br.top + bl - 0.5 || wr.bottom > br.bottom - bl + 0.5) out.wordOutside.push(m[0]);
+ }
+ out.bubbleLines = lines.size;
+ const after = getComputedStyle(title, "::after"), stripTop = T.bottom - bw - parseFloat(after.height) - parseFloat(after.borderTopWidth);
+ const textRects = [title.querySelector(":scope > .eyebrow"), title.querySelector(":scope > h1"), title.querySelector(":scope > p:last-of-type")]
+ .filter(Boolean).flatMap(e => { const rg = document.createRange(); rg.selectNodeContents(e); return [...rg.getClientRects()].filter(x => x.width > 0); });
+ out.textToStrip = stripTop - Math.max(...textRects.map(x => x.bottom));
+ // v9 A4: the title text block (eyebrow, or the heading where there is none, down to the sentence) is centred
+ // vertically beside her on wider screens; on phones the Vault heading starts 17 px under her shelf.
+ const firstText = title.querySelector(":scope > .eyebrow") ?? title.querySelector(":scope > h1");
+ out.firstText = firstText.tagName;
+ out.textAbove = r(firstText).top - (T.top + bw + parseFloat(getComputedStyle(title).paddingTop));
+ out.textBelow = stripTop - parseFloat(after.marginTop) - r(title.querySelector(":scope > p:last-of-type")).bottom;
+ out.textUnderHolder = r(firstText).top - r(holder).bottom;
+ out.phone = innerWidth <= 660;
+ out.name = name;
+ out.bubbleInFrame = br.left >= T.left + bw - 0.5 && br.right <= T.right - bw + 0.5 && br.top >= T.top + bw - 0.5 && br.bottom <= stripTop + 0.5;
+ const hit = (a, b) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
+ out.textOnBubble = textRects.some(x => hit(x, br));
+ const reg = R[name], ratio = reg.w / reg.h;
+ let dw = ir.width, dh = ir.height;
+ if (ir.width / ir.height > ratio) dw = ir.height * ratio; else dh = ir.width / ratio;
+ const D = {x: ir.left + (ir.width - dw) / 2, y: ir.bottom - dh, w: dw, h: dh}, sx = dw / reg.w, sy = dh / reg.h;
+ const keys = Object.keys(reg).filter(k => !["w", "h"].includes(k));
+ const box = k => { const [a, b, c, d] = reg[k]; return {left: D.x + a * sx, top: D.y + b * sy, right: D.x + c * sx, bottom: D.y + d * sy}; };
+ out.bubbleOnRegions = keys.filter(k => hit(br, box(k)));
+ out.picture = {x: D.x, y: D.y, w: D.w, h: D.h, elementHeight: ir.height};
+ out.bubble = {x: br.x, y: br.y, w: br.width, h: br.height};
+ out.popAboveFrame = T.top - (D.y + 8 * sy);
+ out.pictureInView = D.x >= 0 && D.x + D.w <= innerWidth;
+ out.clipped = [];
+ for (let e = img.parentElement; e && e !== document.documentElement; e = e.parentElement) {
+ const c = getComputedStyle(e);
+ if (c.overflowX !== "visible" || c.overflowY !== "visible" || c.clipPath !== "none" || c.contain.includes("paint")) {
+ const er = r(e);
+ if (D.x < er.left - 0.5 || D.x + D.w > er.right + 0.5 || D.y < er.top - 0.5 || D.y + D.h > er.bottom + 0.5) out.clipped.push(e.className || e.tagName);
+ }
+ }
+ const wh = document.querySelector(".wallet-help");
+ if (wh) {
+ const btn = wh.querySelector("button"), rg = document.createRange();
+ rg.selectNodeContents(wh); rg.setEndBefore(btn);
+ const rs = [...rg.getClientRects()].filter(x => x.width > 0), b = r(btn), W = r(wh);
+ const t = {left: Math.min(...rs.map(x => x.left)), right: Math.max(...rs.map(x => x.right)), top: Math.min(...rs.map(x => x.top)), bottom: Math.max(...rs.map(x => x.bottom))};
+ const sameRow = b.top < t.bottom && t.top < b.bottom;
+ out.walletGap = sameRow ? b.left - t.right : b.top - t.bottom;
+ out.walletInside = b.right <= W.right + 0.5 && t.right <= W.right + 0.5;
+ }
+ if (pixels) {
+ const canvas = document.createElement("canvas"); canvas.width = reg.w; canvas.height = reg.h;
+ const g = canvas.getContext("2d"); g.drawImage(img, 0, 0, reg.w, reg.h);
+ const A = g.getImageData(0, 0, reg.w, reg.h).data;
+ const regionOf = (ix, iy) => keys.find(k => { const [a, b, c, d] = reg[k]; return ix >= a && ix <= c && iy >= b && iy <= d; }) || "other";
+ const scan = (rc, test) => {
+ const found = {};
+ for (let py = Math.floor(Math.max(rc.top, D.y)); py < Math.min(rc.bottom, D.y + D.h); py++)
+ for (let px = Math.floor(Math.max(rc.left, D.x)); px < Math.min(rc.right, D.x + D.w); px++) {
+ if (test && !test(px + 0.5, py + 0.5)) continue;
+ const ix = Math.floor((px + 0.5 - D.x) / sx), iy = Math.floor((py + 0.5 - D.y) / sy);
+ if (ix < 0 || iy < 0 || ix >= reg.w || iy >= reg.h || A[(iy * reg.w + ix) * 4 + 3] <= 60) continue;
+ const k = regionOf(ix, iy); found[k] = (found[k] || 0) + 1;
+ }
+ return found;
+ };
+ out.bubbleCovers = scan(br);
+ const before = getComputedStyle(bubble, "::before");
+ const ys = before.clipPath.replace(/^polygon\(|\)$/g, "").split(/,\s*(?![^(]*\))/).map(p => parseFloat(p.trim().split(/\s+(?![^(]*\))/).pop()));
+ const ox = br.left + bl, oy = br.top + parseFloat(cs.borderTopWidth) + parseFloat(before.top);
+ const tip = {x: ox + parseFloat(before.left), y: oy + ys[0]}, b1 = {x: ox, y: oy + ys[1]}, b2 = {x: ox, y: oy + ys[2]};
+ const side = (p, q, s) => (p.x - s.x) * (q.y - s.y) - (q.x - s.x) * (p.y - s.y);
+ const inTail = (x, y) => { const P = {x, y}, d1 = side(P, tip, b1), d2 = side(P, b1, b2), d3 = side(P, b2, tip); return !((d1 < 0 || d2 < 0 || d3 < 0) && (d1 > 0 || d2 > 0 || d3 > 0)); };
+ out.tail = {tip, b1, b2};
+ out.tailCovers = scan({left: Math.min(tip.x, ox), top: Math.min(tip.y, b1.y), right: Math.max(tip.x, ox), bottom: b2.y}, inTail);
+ out.textCovers = textRects.reduce((n, x) => n + Object.values(scan(x)).reduce((s, v) => s + v, 0), 0);
+ }
+ return out;
+}
+function assertGeometry(g, label, pixels) {
+ assert.equal(g.overflow, false, `${label} overflow`);
+ assert.deepEqual(g.split, [], `${label}: a bubble word is split`);
+ assert.deepEqual(g.wordOutside, [], `${label}: bubble text outside the bubble`);
+ assert.ok(g.textToStrip >= 12, `${label}: checker strip touches the title text (${g.textToStrip})`);
+ assert.equal(g.firstText, g.name === "vault" ? "H1" : "P", `${label}: the Vault title has no eyebrow, the others keep theirs`);
+ if (!g.phone) assert.ok(Math.abs(g.textAbove - g.textBelow) < 0.5, `${label}: title text not centred beside her (${g.textAbove} above, ${g.textBelow} below)`);
+ else if (g.name === "vault") assert.ok(Math.abs(g.textUnderHolder - 17) < 0.5, `${label}: Vault heading not 17 px under her shelf (${g.textUnderHolder})`);
+ assert.equal(g.bubbleInFrame, true, `${label}: bubble outside the title frame`);
+ assert.equal(g.textOnBubble, false, `${label}: title text under the bubble`);
+ assert.deepEqual(g.bubbleOnRegions, [], `${label}: bubble on face, hands or held item`);
+ assert.ok(g.popAboveFrame > 0, `${label}: head does not rise above the panel`);
+ assert.equal(g.pictureInView, true, `${label}: picture outside the viewport`);
+ assert.deepEqual(g.clipped, [], `${label}: picture clipped`);
+ assert.ok(g.walletGap >= 8, `${label}: wallet hint gap ${g.walletGap}`);
+ assert.equal(g.walletInside, true, `${label}: wallet hint outside its box`);
+ if (pixels) {
+ assert.deepEqual(g.bubbleCovers, {}, `${label}: bubble covers the picture`);
+ assert.deepEqual(Object.keys(g.tailCovers).filter(k => k !== "other"), [], `${label}: tail covers face, hands or held item`);
+ assert.equal(g.textCovers, 0, `${label}: title text on the figure`);
+ }
+}
+// A hash change re-renders asynchronously: wait until the requested route is the current page before measuring it.
+const routeShown = name => !!document.querySelector(`a[aria-current="page"][href="#${name}"]`);
+const checks=[], visibility=[], bubbles=[], shelfCounts=[];
let browser;
try {
for (const [engine, launcher] of [["Chromium",chromium],["WebKit",webkit]]) {
@@ -44,66 +215,179 @@ try {
try { const res=await fetch(route.request().url(), {method:"POST",headers:{"content-type":"application/json"},body:route.request().postData(),signal:AbortSignal.timeout(20000)}); await route.fulfill({body:await res.text(),contentType:"application/json"}); }
catch {await route.abort();}
});
- for (const width of [375,2560]) {
+ const strips=[];
+ for (const width of [320,375,660,661,720,721,1024,1440,1441,1920,2160,2161,2560,2880,2881,3440,3600,3601,4320,4321,5040,5041,5760]) {
await page.setViewportSize({width,height:1000});
await page.goto(base+"/preview/#deposit");
+ await page.waitForFunction(routeShown,"deposit");
await page.evaluate(()=>document.fonts.ready);
assert.equal(await page.locator('.marquee button').count(),0);
- const track=page.locator('.marquee-track');
- const geometry=await track.evaluate(e=>({halves:[...e.children].map(c=>({width:c.getBoundingClientRect().width,text:c.textContent})),frame:e.parentElement.clientWidth,contain:getComputedStyle(e.parentElement).contain,isolation:getComputedStyle(e.parentElement).isolation,duration:e.getAnimations()[0].effect.getTiming().duration}));
- assert.equal(geometry.halves[0].text,geometry.halves[1].text);
- assert.ok(Math.abs(geometry.halves[0].width-geometry.halves[1].width)<0.01, "Identical halves within compositor subpixel rounding");
- assert.ok(geometry.halves[0].width>=geometry.frame);
- assert.equal(geometry.contain,"none"); assert.equal(geometry.isolation,"auto");
- assert.equal(geometry.duration,width===375?45000:180000);
- for (const fraction of [0,.25,.5,.75,.99999,1]) {
- assert.equal(await track.evaluate((e,f)=>{const a=e.getAnimations()[0];a.pause();a.currentTime=a.effect.getTiming().duration*f;const r=e.getBoundingClientRect(),p=e.parentElement.getBoundingClientRect();return r.left<=p.left&&r.right>=p.right;},fraction),true);
- await page.locator('.marquee').screenshot({path:path.join(captures,`${engine}-loop-${width}-${fraction}.png`)});
+ const k=stripLines(width),per=width<=660?11250:45000,label=`${engine} strip ${width}`;
+ const g=await page.evaluate(stripGeometry,null);
+ assert.equal(g.viewport,width,label);
+ assert.equal(g.halves.length,2,label+': two halves');
+ for(const h of g.halves) {
+ assert.equal(h.spans.length,8,label+': eight lines in the markup of each half');
+ for(const s of h.spans) assert.equal(s.text,SLOGAN,label+': line text');
+ assert.equal(h.minWidth,'auto',label+': no min-width on a half');
+ h.spans.forEach((s,i)=>{assert.equal(s.display==='none',i>=k,label+`: line ${i+1} shown only when k=${k} needs it`);if(i=width&&g.halves[0].width>=g.frame-0.01,label+': k lines cover the viewport');
+ if(k>1&&width<=5760) assert.ok((k-1)*7200,label+': word gap');
+ // Line boxes edge to edge (no margin, padding or stretch anywhere in the track), each as wide as its text.
+ for(const e of s.edges) assert.ok(Math.abs(e)<0.01,label+': line boxes not edge to edge '+JSON.stringify(s.edges));
+ for(const e of s.textWidths) assert.ok(Math.abs(e)<1,label+': a line box wider than its text '+JSON.stringify(s.textWidths));
+ // Glyphs: WebKit reports single-glyph boxes rounded out to whole pixels (each edge up to 1 px off), hence 2.5 px
+ // there; a blank stretch would be a whole line (720 px) or more, and the box edges above are exact.
+ const tol=engine==='WebKit'?2.5:0.5;
+ assert.ok(Math.abs(s.lead)getComputedStyle(e).animationName),"none");
+ await page.evaluate(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))));
+ const still=await page.evaluate(stripGeometry,null);
+ assert.equal(still.animationName,"none",label+': reduced motion');
+ assert.ok(Math.abs(still.offset)<0.01&&still.running===0&&['none','matrix(1, 0, 0, 1, 0, 0)'].includes(still.transform),label+': reduced motion still '+JSON.stringify([still.offset,still.running,still.transform]));
+ assert.equal(still.covers,true,label+': still strip full');
+ assert.deepEqual(still.shownCounts,[k,k]);
await page.emulateMedia({reducedMotion:"no-preference"});
- checks.push(`${engine} ${width}: equal full-width halves, sampled complete loop, 45/180s, overflow only, no pause, reduced motion still`);
+ strips.push(`${width}:${k}/${Math.round(g.track)}/${g.duration / 1000}s`);
}
+ checks.push(`${engine} slogan strip, width:lines per half/track px/loop [${strips.join(' ')}]: eight lines in the markup of each half, the first k shown (k = the fewest 720 px lines covering the viewport), identical halves, track <= 2 x (viewport + 720), no will-change, 16 px/s (64 px/s at 660 px and below); at 8 loop positions the window is covered, line boxes sit edge to edge and every line join and the seam between the halves has the normal word gap; reduced motion still and full`);
await page.emulateMedia({reducedMotion:"reduce"});
- for(const width of [1440,960,800,661,660,375,320]) {
+ for(const width of [1440,1000,999,960,800,720,661,660,375,360,359,320]) {
await page.setViewportSize({width,height:width===375?812:1000});
for(const name of ["vault","deposit","redeem","docs","owner","losses"]) {
await page.goto(base+"/preview/#"+name);
+ await page.waitForFunction(routeShown,name);
await page.evaluate(()=>document.fonts.ready);
await page.waitForFunction(()=>[...document.images].every(i=>i.complete&&i.naturalWidth>0));
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,`${engine} ${name} ${width} overflow`);
- const shelf=await page.locator('.store-shelf').evaluateAll(es=>es.map(e=>[...e.children].map(row=>({width:row.clientWidth,last:row.lastElementChild.getBoundingClientRect().right,right:row.getBoundingClientRect().right,icons:[...row.children].map(i=>({src:i.getAttribute('src'),w:i.getBoundingClientRect().width,h:i.getBoundingClientRect().height,alt:i.alt,transform:getComputedStyle(i).transform}))}))));
+ const shelf=await page.locator('.store-shelf').evaluateAll(es=>es.map(e=>({unit:!!e.closest('.stock-section .sa-unit'),rows:[...e.children].map(row=>({display:getComputedStyle(row).display,width:row.clientWidth,last:row.lastElementChild.getBoundingClientRect().right,right:row.getBoundingClientRect().right,icons:[...row.children].map(i=>({src:i.getAttribute('src'),w:i.getBoundingClientRect().width,h:i.getBoundingClientRect().height,alt:i.alt,transform:getComputedStyle(i).transform}))}))})));
assert.equal(shelf.length,name==='vault'?2:1);
+ // v9 Stock shelves: on Vault the second shelf is the top shelf of the stock shelf unit, which shows only its
+ // first food row (by design); every other shelf row is shown and filled to its end.
+ assert.equal(shelf.filter(s=>s.unit).length,name==='vault'?1:0,`${engine} ${name} ${width}: food shelf inside the Stock shelves unit`);
const foods=manifest.files.filter(f=>f.path.endsWith('.svg'));
- for(const rows of shelf) for(let n=0;n<2;n++) {
+ for(const {unit,rows} of shelf) for(let n=0;n<2;n++) {
const row=rows[n],list=n?foods.slice(14):foods.slice(0,14);
- assert.ok(row.last>=row.right,`shelf end ${width}`);
+ if(unit&&n===1) assert.equal(row.display,'none',`${engine} ${name} ${width}: the shelf unit shows only its first food row`);
+ else {assert.notEqual(row.display,'none',`${engine} ${name} ${width}: shelf row ${n+1} shown`);assert.ok(row.width>0&&row.icons.length>0,`${engine} ${name} ${width}: shelf row ${n+1} empty`);assert.ok(row.last>=row.right,`shelf end ${width}`);}
for(let i=0;i!document.querySelector('button.refresh')?.disabled);
+ const sh=await page.evaluate(()=>{
+ const sec=document.querySelectorAll('.stock-section'),s=sec[0],R=e=>e.getBoundingClientRect(),unit=s?.querySelector(':scope .sa-unit');
+ const labels=[...s.querySelectorAll('article.sa-label')].map(l=>({ticker:l.querySelector('h4')?.textContent??'',price:!!l.querySelector('.sa-price'),status:l.querySelector('.sa-st')?.textContent??'',more:l.querySelector('button.sa-more')?.getAttribute('aria-expanded'),left:R(l).left,right:R(l).right,height:R(l).height,inSlot:!!l.parentElement?.matches('li.sa-slot')&&!!l.closest('ul.sa-shelves[role="list"]')}));
+ const all=s.querySelector('.sa-filters button');
+ return {sections:sec.length,heading:s.querySelector(':scope > .section-heading h2')?.textContent,old:document.querySelectorAll('.stock-grid, .stock-card').length,unit:unit?{left:R(unit).left,right:R(unit).right}:null,labels,
+ all:all?Number(all.querySelector('.sa-count')?.textContent):null,allName:all?.firstChild?.textContent?.trim(),controls:[...s.querySelectorAll('.sa-filters button, .sa-search input')].map(e=>R(e).height),
+ moving:document.getAnimations().filter(a=>a.effect?.target?.closest?.('.stock-section')).length};
+ });
+ assert.equal(sh.sections,1,`${engine} vault ${width}: one Stock shelves panel`);
+ assert.equal(sh.heading,'Stock shelves');
+ assert.equal(sh.old,0,`${engine} vault ${width}: old stock cards`);
+ assert.ok(sh.unit,`${engine} vault ${width}: shelf unit`);
+ if(sh.all!==null) {
+ assert.equal(sh.allName,'All');
+ assert.equal(sh.labels.length,sh.all,`${engine} vault ${width}: one label per listed stock`);
+ assert.ok(sh.all>0);
+ for(const l of sh.labels) {
+ assert.ok(l.ticker.length>0&&l.price&&/^(Open|Closed|Retired)/.test(l.status)&&l.more==='false'&&l.inSlot,`${engine} vault ${width}: label ${JSON.stringify(l)}`);
+ assert.ok(l.left>=sh.unit.left-0.5&&l.right<=sh.unit.right+0.5,`${engine} vault ${width}: label ${l.ticker} outside the shelf unit`);
+ assert.ok(l.height>=44,`${engine} vault ${width}: label ${l.ticker} tap height ${l.height}`);
+ }
+ assert.equal(sh.controls.length,5);
+ for(const h of sh.controls) assert.ok(h>=44,`${engine} vault ${width}: filter/search height ${h}`);
+ }
+ assert.equal(sh.moving,0,`${engine} vault ${width}: motion in the Stock shelves under reduced motion`);
+ shelfCounts.push(`${engine} ${width}:${sh.labels.length}`);
+ }
+ // The old picture frames and the WOW! burst are gone everywhere; the Fresh! burst stays on the Stock shelves panel.
+ assert.equal(await page.locator('.vault-hero, .clerk-panel, .picture-stage, .deposit-picture, .redeem-picture').count(),0,`${engine} ${name} old frames`);
+ assert.equal(await page.getByText('WOW!').count(),0,`${engine} ${name} WOW!`);
+ assert.equal(await page.locator('.burst').count(),name==='vault'?1:0,`${engine} ${name} bursts`);
+ if(name==='vault') assert.equal(await page.locator('.stock-section .section-heading .burst.small').innerText(),'Fresh!');
+ // v9 A4: the tagline is nowhere on the page; the Vault title has no eyebrow (no empty one either), the other pages
+ // keep theirs; the footer is the basket icon, "Basket Protocol" and the links.
+ const words=await page.evaluate(()=>{const t=document.querySelector('.page-title'),e=t.querySelector(':scope > .eyebrow');return {eyebrow:e?e.textContent:null,eyebrows:t.querySelectorAll(':scope > p.eyebrow').length,first:t.firstElementChild.tagName,body:document.body.textContent,title:document.title,footer:[...document.querySelector('footer > div').children].map(c=>c.tagName.toLowerCase()+(c.getAttribute('class')?'.'+c.getAttribute('class'):'')+(c.tagName==='STRONG'?':'+c.textContent:''))};});
+ assert.equal(words.eyebrow,EYEBROWS[name],`${engine} ${name} eyebrow`);
+ assert.equal(words.eyebrows,name==='vault'?0:1,`${engine} ${name} eyebrow count`);
+ assert.equal(words.first,name==='vault'?'H1':'P',`${engine} ${name} first title element`);
+ assert.doesNotMatch(words.body+' '+words.title,TAGLINE,`${engine} ${name} tagline on the page`);
+ assert.deepEqual(words.footer,['svg.basket-icon','strong:Basket Protocol','span.footer-links'],`${engine} ${name} footer`);
if(['vault','deposit','redeem'].includes(name)) {
- const picture=await page.locator('.character-picture').evaluate(e=>({width:e.getAttribute('width'),height:e.getAttribute('height'),fit:getComputedStyle(e).objectFit,position:getComputedStyle(e).objectPosition,display:getComputedStyle(e).display,h:e.getBoundingClientRect().height,src:e.getAttribute('src')}));
+ assert.equal(await page.locator(`.page-title.has-character.character-${name}`).count(),1);
+ assert.equal(await page.locator('.character-picture').count(),1,`${engine} ${name} one picture`);
+ assert.equal(await page.locator('.speech-bubble').count(),1,`${engine} ${name} one bubble`);
+ assert.equal(await page.locator(`.page-title.character-${name} > .title-character > img.character-picture + p.speech-bubble`).count(),1,`${engine} ${name} picture and bubble in the title`);
+ assert.equal(await page.locator('aside.panel > :first-child').evaluateAll(es=>es.every(e=>e.tagName==='H2')),true,`${engine} ${name} asides start with their heading`);
+ const picture=await page.locator('.character-picture').evaluate(e=>({width:e.getAttribute('width'),height:e.getAttribute('height'),fit:getComputedStyle(e).objectFit,position:getComputedStyle(e).objectPosition,display:getComputedStyle(e).display,h:e.getBoundingClientRect().height,src:e.getAttribute('src'),alt:e.alt}));
const f=manifest.files.find(f=>'./'+f.path===picture.src);
+ assert.equal(picture.src,`./art/character/${name}.webp`);
assert.equal(Number(picture.width),f.width);assert.equal(Number(picture.height),f.height);
+ assert.ok(picture.alt.length>0);
assert.equal(picture.fit,'contain');assert.equal(picture.position,'50% 100%');assert.equal(picture.display,'block');
- assert.equal(picture.h,width>960?(name==='vault'?300:320):width>660?260:name==='vault'?200:240);
+ assert.ok(Math.abs(picture.h-pictureHeight(width))<0.01,`${engine} ${name} ${width} picture height ${picture.h}`);
const bubble=page.locator('.speech-bubble');
- assert.equal(await bubble.innerText(),({vault:'I was hoping you would come through my aisle',deposit:'Go ahead and put it in hehe',redeem:'are you sure that’s all you came here for?'})[name]);
+ assert.equal(await bubble.innerText(),LINES[name]);
assert.equal(await bubble.evaluate(e=>getComputedStyle(e).transform),'none');
- assert.equal(await bubble.evaluate(e=>{const b=e.getBoundingClientRect(),p=e.parentElement.getBoundingClientRect();return b.left>=p.left&&b.right<=p.right&&b.top>=p.top&&b.bottom<=p.bottom;}),true,`${engine} ${name} ${width}: speech stays inside frame`);
+ const geometry=await page.evaluate(titleGeometry,{R:REGIONS,name,pixels:true});
+ assertGeometry(geometry,`${engine} ${name} ${width}`,true);
if([1440,800,375,320].includes(width)) {
- await page.locator(name==='vault'?'.vault-hero':'.picture-stage').screenshot({path:path.join(captures,`${engine}-${name}-${width}.png`)});
+ const clip=await page.evaluate(()=>{const t=document.querySelector('.page-title').getBoundingClientRect(),i=document.querySelector('.character-picture').getBoundingClientRect(),top=Math.max(0,Math.min(t.top,i.top)+scrollY-12);return {x:0,y:top,width:innerWidth,height:t.bottom+scrollY+12-top};});
+ await page.screenshot({path:path.join(captures,`${engine}-${name}-${width}.png`),clip,fullPage:true});
await page.screenshot({path:path.join(captures,`${engine}-${name}-${width}-screen.png`)});
}
- const geometry = await page.locator('.character-picture').evaluate(e => {
- const i=e.getBoundingClientRect(), b=e.parentElement.querySelector('.speech-bubble').getBoundingClientRect();
- const scale=Math.min(i.width/e.naturalWidth,i.height/e.naturalHeight);
- return {scale,x:i.x+(i.width-e.naturalWidth*scale)/2,y:i.bottom-e.naturalHeight*scale,bubble:{x:b.x,y:b.y,width:b.width,height:b.height}};
- });
- bubbles.push({engine,name,width,picture,geometry});
+ if([1440,800,375,320].includes(width)) bubbles.push(JSON.parse(JSON.stringify({engine,name,width,picture,geometry:{picture:geometry.picture,bubble:geometry.bubble,bubbleLines:geometry.bubbleLines,textToStrip:geometry.textToStrip,popAboveFrame:geometry.popAboveFrame,walletGap:geometry.walletGap}},(k,v)=>typeof v==='number'?Math.round(v*100)/100:v)));
+ } else {
+ assert.equal(await page.locator('.page-title.has-character, .title-character, .character-picture, .speech-bubble').count(),0,`${engine} ${name} no character`);
}
+ if(name==='redeem') assert.equal(await page.getByText(/Unsent stocks are owed/).count(),0,`${engine} redeem receiver note`);
+ if(name==='docs') {
+ const faq=await page.locator('.docs-grid > .panel').evaluateAll(ps=>{const i=ps.findIndex(p=>p.querySelector('h2')?.textContent==='Redemption'),f=ps[i+1];return {heading:f?.querySelector(':scope > h2')?.textContent,question:f?.querySelector(':scope > h3')?.textContent,answer:f?.querySelector(':scope > p')?.textContent.replace(/\s+/g,' ').trim()};});
+ assert.deepEqual(faq,{heading:'FAQ',question:'What if a stock can’t be sent when I redeem?',answer:'Rarely, a stock can’t be sent at that moment (for example its issuer has paused transfers). The vault then keeps it for the receiver, who collects it later with Claim on the Redeem page. Only the receiver wallet can claim, so redeem to a wallet you control, not an exchange deposit address.'});
+ }
+ }
+ checks.push(`${engine} ${width}: six routes without overflow; both shelf rows filled with ordered square images (the Stock shelves unit on Vault shows its first row only, by design); Stock shelves: one shelf-edge label per listed stock (${shelfCounts.at(-1)?.split(':')[1]}) with ticker, price, status and Details, inside the shelf unit, labels/filters/search at least 44 px, no old stock cards, still under reduced motion; title character inside the title panel with exact speech and picture height, head above the panel, bubble clear of the picture, tail clear of face/hands/items, whole bubble words, strip clear of the text, wallet hint gap; no old frames or WOW!, Fresh! kept; Docs FAQ; no Redeem receiver note; no tagline on the page, Vault title without eyebrow (text centred beside her, or 17 px under her on phones), other eyebrows kept, footer icon/name/links`);
+ }
+ // Every width from 320 to 2560 px: whole bubble words inside the bubble, the strip clear of the title text, the bubble in
+ // its frame and off the picture's face/hands/item boxes, no overflow and a wallet hint gap of at least 8 px.
+ for(const name of ["vault","deposit","redeem"]) {
+ await page.setViewportSize({width:1440,height:1000});
+ await page.goto(base+"/preview/#"+name);
+ await page.waitForFunction(routeShown,name);
+ await page.evaluate(()=>document.fonts.ready);
+ await page.waitForFunction(()=>[...document.images].every(i=>i.complete&&i.naturalWidth>0));
+ const lines={};
+ for(let width=320;width<=2560;width++) {
+ await page.setViewportSize({width,height:1000});
+ const g=await page.evaluate(titleGeometry,{R:REGIONS,name,pixels:width%40===0});
+ assertGeometry(g,`${engine} ${name} ${width}`,width%40===0);
+ lines[g.bubbleLines]=(lines[g.bubbleLines]||0)+1;
}
- checks.push(`${engine} ${width}: six routes without overflow; both shelf rows filled with ordered square images, Vault copy included; picture geometry and exact speech`);
+ checks.push(`${engine} ${name}: every width 320-2560 px keeps whole bubble words (bubble lines ${JSON.stringify(lines)}), the strip clear of the title text, the title text centred beside her (Vault heading 17 px under her on phones), the bubble in its frame and the wallet hint gap; picture pixels checked every 40 px`);
}
await browser.close();browser=undefined;
}
diff --git a/web/scripts/check-bundle.mjs b/web/scripts/check-bundle.mjs
index 49ff322..f90be21 100644
--- a/web/scripts/check-bundle.mjs
+++ b/web/scripts/check-bundle.mjs
@@ -6,21 +6,36 @@ import {createHash} from 'node:crypto';
const root=process.env.BASKET_SOURCE_ROOT||path.resolve('..');
const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8'});
const changed=git(['diff','--name-only','HEAD']).trim().split('\n').filter(Boolean);
-// Vault 6 re-point: web/pinned (its foundry.toml too) and web/src may change, except the four HEAD-frozen files checked by check-calldata.mjs.
-const frozenSource=['Losses.tsx','main.tsx','components.tsx','Scenery.tsx'].map(f=>'web/src/'+f);
+// v9 (look update, low fixes F1-F16, slogan strip, favicon, tagline removal, Stock shelves labels in Vault.tsx and
+// styles.css): only these web/src files may change;
+// Losses, poolMath, deployment and the ABI stay HEAD-frozen and main/Scenery may only take their one documented edit
+// (both byte-checked by check-calldata.mjs); web/pinned stays unchanged; in web/public only favicon.svg may change,
+// and only to the chosen icon (art files unchanged); no file is added to web/src or web/public.
+const lookSource=['components.tsx','Vault.tsx','Flows.tsx','Docs.tsx','styles.css','main.tsx','Scenery.tsx'].map(f=>'web/src/'+f);
+const fixSource=['chain.ts','model.ts','newYork.ts','governance.ts','Owner.tsx','wallet.tsx'].map(f=>'web/src/'+f);
+const frozenSource=['Losses.tsx','poolMath.ts','deployment.ts','vault.abi.json'].map(f=>'web/src/'+f);
+// The new favicon: red panel, yellow frame, the header's basket in yellow.
+const FAVICON_SHA256='0a31ead9064a33cbc12abe6388cf3f0a29888d3a183819e02116e56dd8ba2238';
+const sha256=p=>createHash('sha256').update(fs.readFileSync(path.join(root,p))).digest('hex');
+assert.equal(sha256('web/public/favicon.svg'),FAVICON_SHA256,'web/public/favicon.svg is the chosen icon');
+assert.equal(sha256('dist/favicon.svg'),FAVICON_SHA256,'dist/favicon.svg is the chosen icon');
for(const p of changed){
- if(p.startsWith('web/pinned/'))continue;
+ assert.ok(!p.startsWith('web/pinned/'),'Pinned source changed: '+p);
+ assert.ok(!p.startsWith('web/public/')||p==='web/public/favicon.svg','Public file changed: '+p);
+ // The page shell and build configuration stay as at HEAD (no script can be added outside web/src); in web/validation only the README text changes.
+ assert.ok(!['web/index.html','web/vite.config.ts','web/tsconfig.json'].includes(p)&&(!p.startsWith('web/validation/')||p==='web/validation/README.md'),'Page shell, build configuration or validation fixture changed: '+p);
assert.ok(!/(^|\/)(lib|node_modules|\.github|\.git)(\/|$)|(^|\/)\.env(?:\.|$)|(^|\/)(foundry\.toml|foundry\.lock|remappings\.txt|\.gitmodules|package\.json|[^/]*lock[^/]*)$/.test(p),'Protected path changed: '+p);
- if(p.startsWith('web/src/'))assert.ok(!frozenSource.includes(p),'HEAD-frozen source changed: '+p);
+ if(p.startsWith('web/src/'))assert.ok(!frozenSource.includes(p)&&(lookSource.includes(p)||fixSource.includes(p)),'Source outside the look update and the low fixes changed: '+p);
assert.ok(!p.startsWith('src/'),'Root contract source changed');
}
+assert.deepEqual(git(['ls-files','--others','--exclude-standard','--','web/src','web/public']).trim().split('\n').filter(Boolean),[],'New files in web/src or web/public');
assert.ok(!git(['ls-files','--stage']).split('\n').some(s=>s.startsWith('160000 ')),'No git submodules');
const files=[...new Set(git(['ls-files','--cached','--others','--exclude-standard','-z']).split('\0'))].filter(p=>p&&!p.startsWith('.imd/')&&!p.startsWith('test/scratch/')&&fs.existsSync(path.join(root,p))).sort();
for(const p of files) assert.ok(!/(^|\/)(node_modules|\.npm|\.cache|\.vite|\.playwright-mcp)(\/|$)|\.(tgz|deb|tsbuildinfo)$/.test(p),'Dependency/cache/archive payload: '+p);
const shots=files.filter(p=>/^artifacts\/(empty|stocks25)-.*\.jpg$/.test(p));assert.equal(shots.length,24);
const extraImages=files.filter(p=>/^artifacts\/.*\.(png|jpg|webp|svg)$/.test(p)&&!shots.includes(p)&&!/^artifacts\/redeem-title-dpr-(1|1\.25|1\.5)\.png$/.test(p));assert.deepEqual(extraImages,[]);
const productionFiles={};for(const p of files.filter(p=>p.startsWith('dist/'))){const b=fs.readFileSync(path.join(root,p));productionFiles[p]={bytes:b.length,sha256:createHash('sha256').update(b).digest('hex')};}
-const result={limitBytes:8388608,uncompressedDeliveryBytes:0,fileCount:files.length,productionExportBytes:Object.values(productionFiles).reduce((a,b)=>a+b.bytes,0),productionFiles,accounting:'Complete candidate file contents including tracked lib sources and this report. Excludes Git metadata, removed task inputs and disposable test/scratch. No Git metadata was modified.',checks:['Protected configuration, dependencies and root contracts unchanged; web/pinned and web/src changed only within the vault 6 scope','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained at quality 40; no additional artifact images','Both copies of all 37 manifest assets included']};
+const result={limitBytes:8388608,uncompressedDeliveryBytes:0,fileCount:files.length,productionExportBytes:Object.values(productionFiles).reduce((a,b)=>a+b.bytes,0),productionFiles,accounting:'Complete candidate file contents including tracked lib sources and this report. Excludes Git metadata, removed task inputs and disposable test/scratch. No Git metadata was modified.',checks:['Protected configuration, dependencies, root contracts, web/pinned, web/index.html, web/vite.config.ts, web/tsconfig.json and the validation fixtures unchanged; web/public unchanged except favicon.svg, which (and dist/favicon.svg) has the SHA-256 of the chosen icon; web/src changed only within the v9 scope (look: components, Vault (with the Stock shelves labels), Flows, Docs, styles, main (footer tagline removed), Scenery (slogan strip); low fixes: chain, model, newYork, governance, Owner, wallet; Losses, poolMath, deployment and the ABI frozen)','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 36 since v9, 40 before); no additional artifact images','Both copies of all 37 manifest assets included']};
const report=path.join(root,'artifacts/bundle-check.json');
for(let i=0;i<4;i++){fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');result.uncompressedDeliveryBytes=files.reduce((sum,p)=>sum+fs.statSync(path.join(root,p)).size,0);}
-fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');assert.ok(result.uncompressedDeliveryBytes<=result.limitBytes,JSON.stringify(result));console.log('PASS complete bundle',result.uncompressedDeliveryBytes,'/',result.limitBytes,'bytes; lib included');
+fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');assert.ok(result.uncompressedDeliveryBytes<=result.limitBytes,"Over the 8 MiB budget (if the 24 screenshots are still the quality-40 set, run scripts/run-browser.mjs first: it rewrites them at quality 36): "+JSON.stringify(result));console.log('PASS complete bundle',result.uncompressedDeliveryBytes,'/',result.limitBytes,'bytes; lib included');
diff --git a/web/scripts/check-calldata.mjs b/web/scripts/check-calldata.mjs
index 7d6008a..6a227f0 100644
--- a/web/scripts/check-calldata.mjs
+++ b/web/scripts/check-calldata.mjs
@@ -2,21 +2,375 @@ import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
+import { pathToFileURL } from 'node:url';
import ts from 'typescript';
+import { build } from 'esbuild';
const root=process.env.BASKET_SOURCE_ROOT||path.resolve('..');
-const baseline=p=>execFileSync('git',['show','HEAD:'+p],{cwd:root,encoding:'utf8'});
+const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8'});
+const baseline=p=>git(['show','HEAD:'+p]);
const read=p=>fs.readFileSync(path.join(root,p),'utf8');
-// Vault 6 re-point: Losses, main, components and Scenery stay as at HEAD; Flows and Vault keep every call expression (only VAULT differs).
-const protectedFiles=['Losses.tsx','main.tsx','components.tsx','Scenery.tsx'];
+// v9 (look update + low fixes F1-F16). Byte-identical to HEAD: the pages left alone and everything that defines
+// the vault address, its code hash, its ABI and the pool arithmetic.
+const protectedFiles=['Losses.tsx','poolMath.ts','deployment.ts','vault.abi.json'];
for(const f of protectedFiles) assert.equal(read('web/src/'+f),baseline('web/src/'+f),f);
-const names=new Set(['vault','token','feed','read','many','simulate','verifyNetwork','encode','depositArgs','redeemArgs','w.send','p.request']);
+// main.tsx and Scenery.tsx: byte-identical to HEAD once the one documented edit is put back (each piece exactly once):
+// A4 the footer tagline removed; A2 eight slogan lines per strip half instead of four.
+const TAGLINE=['Stock','Tokens.','One','basket.'].join(' ');
+const editedFiles={
+ 'main.tsx':[['Basket Protocol\n ','Basket Protocol\n '+TAGLINE+'\n ']],
+ 'Scenery.tsx':[['{Array.from({ length: 8 }, (_, i) => (','{Array.from({ length: 4 }, (_, i) => (']],
+};
+for(const [f,edits] of Object.entries(editedFiles)){
+ let text=read('web/src/'+f);
+ for(const [n,o] of edits){assert.equal(text.split(n).length,2,f+': expected once: '+n);text=text.split(n).join(o);}
+ assert.equal(text,baseline('web/src/'+f),f+' changed beyond the documented edit');
+}
+// Calls that build, encode, simulate or send a transaction (or parse its arguments) or read the chain.
+const names=new Set(['vault','token','feed','read','many','simulate','verifyNetwork','encode','depositArgs','redeemArgs','w.send','p.request',
+ 'safe','proposalSpec','proposalAction','settingValues','poolValues','parseLaunch','encodeFunctionData','client.call','client.estimateGas',
+ 'client.getTransactionCount','client.waitForTransactionReceipt','address','recipient','amount','uint']);
+const actionAttributes=new Set(['onClick','onSubmit','onChange','getSpec','disabled','disabledReason','label','scope','role','primary','value','claim','reason','build']);
+// A6 G-A: wallet sends and raw provider requests in any form are guarded calls too: a callee ending in one of these
+// names (any receiver, optional chaining, casts, element access, string folding) or naming the provider (ethereum).
+const SEND=new Set(['send','request','sendTransaction','sendRawTransaction','writeContract','sendAsync','sendCalls','signTransaction','sendUnsignedTransaction']);
+const lit=n=>ts.isStringLiteralLike(n)?n.text:ts.isParenthesizedExpression(n)?lit(n.expression):ts.isBinaryExpression(n)&&n.operatorToken.kind===ts.SyntaxKind.PlusToken&&lit(n.left)!==undefined&&lit(n.right)!==undefined?lit(n.left)+lit(n.right):undefined;
+const unwrap=n=>ts.isParenthesizedExpression(n)||ts.isNonNullExpression(n)||ts.isAsExpression(n)||ts.isTypeAssertionExpression(n)||ts.isSatisfiesExpression?.(n)?unwrap(n.expression):n;
+const calleeName=c=>{const e=unwrap(c.expression);return ts.isIdentifier(e)?e.text:ts.isPropertyAccessExpression(e)?e.name.text:ts.isElementAccessExpression(e)?lit(e.argumentExpression):undefined;};
+const isGuarded=(n,tree)=>ts.isCallExpression(n)&&(names.has(n.expression.getText(tree))||SEND.has(calleeName(n))||/ethereum|(^|[.?\]])\s*(send|request|sendTransaction|sendRawTransaction|writeContract)\b/.test(n.expression.getText(tree)));
function calls(text,file) {
- const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),found=[];
+ const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),found=[],all=[],attributes=[];
const printer=ts.createPrinter({removeComments:true});
- function visit(n){if(ts.isCallExpression(n)&&names.has(n.expression.getText(tree)))found.push(printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' '));ts.forEachChild(n,visit);}
- visit(tree);return found;
+ const print=n=>printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');
+ const guardedIn=node=>{const g=[];(function v(n){if(isGuarded(n,tree))g.push(print(n));ts.forEachChild(n,v);})(node);return g;};
+ function where(attr){const owner=attr.parent.parent,tag=owner.tagName?.getText(tree)??'?';const named=attr.parent.properties.find(p=>ts.isJsxAttribute(p)&&['title','label','scope'].includes(p.name.getText(tree))&&p.initializer);return tag+(named?' '+named.name.getText(tree)+'='+named.initializer.getText(tree).replace(/\s+/g,' ').slice(0,60):'');}
+ function visit(n){
+ if(ts.isCallExpression(n)){all.push(print(n));if(isGuarded(n,tree))found.push(print(n));}
+ if(ts.isJsxAttribute(n)&&actionAttributes.has(n.name.getText(tree)))attributes.push({name:n.name.getText(tree),text:print(n),guarded:guardedIn(n),where:where(n)});
+ ts.forEachChild(n,visit);
+ }
+ visit(tree);return {found,all,attributes};
+}
+// Remove the first occurrence of each listed item; every listed item must be present.
+function without(list,items,what){const rest=[...list];for(const x of items){const i=rest.indexOf(x);assert.ok(i>=0,what+': expected '+x);rest.splice(i,1);}return rest;}
+// Exact, documented differences of the guarded calls (everything else must equal HEAD, in order).
+const expected={
+ 'governance.ts':{fix:'F1, F15, F16',why:'F1 minLiquidity parsed as uint128 (uint(v, 128) in minLiquidityOf); F16 address(v) in newOwner/newGuardian/feeRecipient (address() plus a refusal of the vault); F15 the current settings() passed down only to refuse a Sunday 2:00-2:59 am Hours start under Dst 0. The Action fields and the propose call are otherwise unchanged (see the differential encoding below).',
+ removed:['uint(v.minLiquidity)','address(v.next)','recipient(v.recipient)','settingValues(v)','vault("propose", [proposalAction(kind, v)])','proposalAction(kind, v)'],
+ added:['uint(v, 128)','address(v)','address(v)','address(v)','settingValues(v, settings)','vault("propose", [proposalAction(kind, v, settings)])','proposalAction(kind, v, settings)']},
+ 'Owner.tsx':{fix:'F15, F16',why:'F15 the Setting preview and build pass the current settings() (Dst) to settingValues/proposalSpec; F16 Transfer ownership parses the address with newOwner (address() plus a refusal of the vault) before the unchanged guardian read and vault("transferOwnership", [next]).',
+ removed:['settingValues({ ...v, setting: String(key), })','proposalSpec(10, { ...v, setting: String(key) })','address(v.next)'],
+ added:['settingValues({ ...v, setting: String(key), }, current)','proposalSpec(10, { ...v, setting: String(key) }, current)']},
+ 'wallet.tsx':{fix:'F13',why:'waitForTransactionReceipt gains onReplaced to learn whether the wallet cancelled or replaced the transaction (then the page says so instead of Confirmed). The eth_sendTransaction request, its data (encode(s)), gas, nonce read and simulation are unchanged.',
+ removed:['client.waitForTransactionReceipt({ hash: tx, timeout: 180000, pollingInterval: 3000, })'],
+ added:['client.waitForTransactionReceipt({ hash: tx, timeout: 180000, pollingInterval: 3000, onReplaced: (r) => { replaced = r.reason; }, })']},
+};
+// Owner.tsx action attributes that change, in page order, each with its send calls compared below. A6 G-B: each one,
+// printed without comments, must also equal HEAD's text once its documented pieces [v9, HEAD] are put back (each
+// exactly once), so the listing loop's row (checkedRows[i]), the listGenesis arguments, the new owner
+// (newOwner(v.next)) and everything else around the sends are pinned, not only the send calls.
+const ownerAttributes=[
+ ['onClick','List stocks / Resume listing button: F13 cancelled-in-wallet and not-listed-after-receipt words; same listingPending, inspectListing and w.send(vault("listGenesis", ...)) calls',[
+ ['let rowName = "Listing", rowNumber = 0;','let rowName = "Listing";'],['rowNumber = i + 1; ',''],
+ ['if (!after.listed) throw new TransactionCancelled("Not listed after the receipt."); if (after.error) throw new InputError(after.error);','if (!after.listed || after.error) throw new InputError(after.error || "Listing confirmation unreadable. Re-check before continuing.");'],
+ ['if (e instanceof TransactionCancelled) { const text = `Row ${rowNumber}: the transaction was cancelled in the wallet; nothing was listed. Press Resume listing.`; setError(text); w.notice("listing", text); } else setError(','setError(']]],
+ ['onClick','Wait for pending transaction button: F13 also clears the stale error',[['setPending(""); setError(""); setResume(true);','setPending(""); setResume(true);']]],
+ ['build','Set form: F15 passes the current settings() to proposalSpec(10, ...)',[['String(key) }, current)','String(key) })']]],
+ ['disabledReason','Finalize genesis: F14 "Paste the listing rows first..." and the listed-with-other-values words',[['{!listing.trim() ? PASTE_FIRST : listingError','{listingError'],['. List them first.` : differing.length ? differWords(differing) : "At least','. List them first.` : "At least']]],
+ ['disabled','Finalize genesis: F14 also disabled while the paste box is empty or a pasted row is listed with other values',[['finalized !== false || !listing.trim() || !!listingError','finalized !== false || !!listingError'],['!!unlisted.length || !!differing.length ||','!!unlisted.length ||']]],
+ ['build','Finalize genesis: F14 refuses an empty paste box and listed-with-other-values rows before the unchanged vault("finalizeGenesis")',[
+ ['{ if (!listing.trim()) throw new InputError(PASTE_FIRST); await w.listingPending();','{ await w.listingPending();'],
+ ['const pasted = parseLaunch(listing); const missing = pasted.filter((r) => !fresh.assets.some((a) => same(a.token, r.token)));','const missing = listing.trim() ? parseLaunch(listing).filter((r) => !fresh.assets.some((a) => same(a.token, r.token))) : [];'],
+ ['const changed = pasted.filter((r) => listedDiffers(fresh.assets, r)); if (changed.length) throw new InputError(differWords(changed.map((r) => r.ticker))); ',''],
+ ['${reasonWords(a.reason, fresh.globals.settings)}','${a.reason === undefined ? "unreadable" : reasons[a.reason]}']]],
+ ['build','Transfer ownership: F16 newOwner(v.next) refuses the vault before the unchanged vault("transferOwnership", [next])',[['const next = newOwner(v.next);','const next = address(v.next);']]],
+];
+const putBack=(text,pieces,what)=>{for(const [n,o] of pieces){assert.equal(text.split(n).length,2,what+': expected once: '+n);text=text.split(n).join(o);}return text;};
+// Every call expression must equal HEAD in these files, except the listed exact pairs (F11: Redeem legs by assetTokens index).
+const allCallsEqual={'components.tsx':[], 'Docs.tsx':[], 'Flows.tsx':[
+ ['s.assets.map((a, i) => (
))'],
+ ['fmt(quote.amounts[i], a.tokenDecimals)','fmt(quote.amounts[a.index], a.tokenDecimals)']]};
+// Vault.tsx (v9 with the Stock shelves labels): no read, check or send call at all (no guarded call here, and no send
+// or request in the scan below). Its action attributes are pinned in page order: HEAD's Retry vault button and the
+// vault, Stock Token and price feed address links, plus the shelves' controls (label Details, drawer Close, filters,
+// search, Show all), which only change what is shown; HEAD's search box value/onChange pair is replaced by the
+// shelves' search. The StockShelves handlers behind those controls are pinned too.
+const vaultAttributes={
+ head:['onClick={s.retry}','value={VAULT}','value={search}','onChange={(e) => setSearch(e.target.value)}','value={a.token}','value={a.feed}'],
+ now:['onClick={s.retry}','value={VAULT}','onClick={onToggle}','onClick={onClose}','value={a.token}','value={a.feed}','role="list"','role="group"','onClick={() => show(f, search)}','value={search}','onChange={(e) => show(filter, e.target.value)}','role="status"','onClick={() => show("all", "")}','onClick={() => show("all", "")}']};
+const vaultHandlers={
+ show:'const show = (f: ShelfFilter, text: string) => { stopRestock(); setOpenToken(undefined); setFilter(f); setSearch(text); };',
+ toggle:'const toggle = (token: string) => setOpenToken(openToken === token ? undefined : token);',
+ close:'const close = (token: string) => { setOpenToken(undefined); requestAnimationFrame(() => document.getElementById(`sa-more-${token}`)?.focus()); };',
+ group:'const group = { nav, settings: s.globals.settings, openToken, onToggle: toggle, onClose: close };'};
+// The variable statements declaring `list` inside the top-level function `fn`, printed without comments (one each).
+function localDeclarations(file,text,fn,list){
+ const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX),printer=ts.createPrinter({removeComments:true}),out={};
+ const f=tree.statements.filter(st=>ts.isFunctionDeclaration(st)&&st.name?.text===fn);
+ assert.equal(f.length,1,file+': one '+fn);
+ (function v(n){if(ts.isVariableStatement(n))for(const d of n.declarationList.declarations)if(ts.isIdentifier(d.name)&&list.includes(d.name.text)){assert.ok(!out[d.name.text],file+': one '+d.name.text+' in '+fn);out[d.name.text]=printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');}ts.forEachChild(n,v);})(f[0]);
+ return out;
+}
+const sources=git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8));
+assert.deepEqual(git(['ls-files','--cached','--others','--exclude-standard','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)).sort(),[...sources].sort(),'No source file added or removed');
+const counts={},ownerChanges=[];
+for(const f of sources){
+ const old=calls(baseline('web/src/'+f),f),now=calls(read('web/src/'+f),f),e=expected[f];
+ if(e) assert.deepEqual(without(now.found,e.added,f),without(old.found,e.removed,f),f+' changed guarded calls beyond the documented ones');
+ else assert.deepEqual(now.found,old.found,f+' changed calldata/read/check call expressions');
+ if(f==='Vault.tsx'){
+ assert.deepEqual(now.found,[],'Vault.tsx makes no read, check or send call');
+ assert.deepEqual(old.attributes.map(a=>a.text),vaultAttributes.head,'Vault.tsx HEAD action attributes');
+ assert.deepEqual(now.attributes.map(a=>a.text),vaultAttributes.now,'Vault.tsx changed an action attribute beyond the pinned Stock shelves controls');
+ for(const a of now.attributes) assert.deepEqual(a.guarded,[],'Vault.tsx '+a.where+': guarded call in an attribute');
+ const h=localDeclarations('web/src/Vault.tsx',read('web/src/Vault.tsx'),'StockShelves',Object.keys(vaultHandlers));
+ for(const [k,v] of Object.entries(vaultHandlers)) assert.equal(h[k],v,'Vault.tsx StockShelves '+k+' differs from the pinned text');
+ } else {
+ assert.equal(now.attributes.length,old.attributes.length,f+' action attribute count');
+ const changed=now.attributes.map((a,i)=>[old.attributes[i],a]).filter(([o,a])=>o.text!==a.text);
+ if(f==='Owner.tsx'){
+ assert.deepEqual(changed.map(([,a])=>a.name),ownerAttributes.map(x=>x[0]),'Owner.tsx changed action attributes');
+ changed.forEach(([o,a],i)=>{
+ assert.equal(o.name,a.name);
+ // The send calls inside each changed attribute: equal, apart from the documented Owner.tsx differences.
+ const removed=o.guarded.filter(x=>!a.guarded.includes(x)),added=a.guarded.filter(x=>!o.guarded.includes(x));
+ for(const x of removed) assert.ok(e.removed.includes(x),'Owner.tsx '+a.where+': removed '+x);
+ for(const x of added) assert.ok(e.added.includes(x),'Owner.tsx '+a.where+': added '+x);
+ assert.deepEqual(a.guarded.filter(x=>!added.includes(x)),o.guarded.filter(x=>!removed.includes(x)),'Owner.tsx '+a.where+' send call order');
+ assert.equal(putBack(a.text,ownerAttributes[i][2],'Owner.tsx '+a.where),o.text,'Owner.tsx '+a.where+' changed beyond its documented pieces');
+ ownerChanges.push({attribute:a.name,element:a.where,change:ownerAttributes[i][1],pieces:ownerAttributes[i][2].length,sendCallsBefore:o.guarded,sendCallsAfter:a.guarded});
+ });
+ } else assert.deepEqual(changed.map(([,a])=>a.text),[],f+' changed an action, send or check attribute');
+ }
+ if(allCallsEqual[f]){
+ const pairs=allCallsEqual[f];
+ assert.deepEqual(now.all,old.all.map(x=>pairs.find(p=>p[0]===x)?.[1]??x),f+' changed a call expression');
+ }
+ counts[f]={guarded:now.found.length,calls:now.all.length,attributes:now.attributes.length};
+}
+// A6 G-A: no web/src file gains, loses or changes a wallet send or raw request, with no exception. Listed in order per
+// file: every use of a send/request name or of the provider (ethereum), with the call around it; every string that
+// names a sending or signing method (folded across +); computed access to the global object; eval, Function, Reflect.
+const SEND_TEXT=/eth_send|eth_sign|personal_sign|wallet_send|signtypeddata|sendtransaction|sendrawtransaction|writecontract|^(send|request|ethereum)$/i;
+const ESCAPES=new Set(['ethereum','eval','Function','Reflect','globalThis']),GLOBAL=new Set(['window','globalThis','self','top','parent','frames']);
+function sendsOf(text,file){
+ const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),printer=ts.createPrinter({removeComments:true}),out=[];
+ const print=n=>printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');
+ const wraps=(p,n)=>(ts.isPropertyAccessExpression(p)||ts.isElementAccessExpression(p)||ts.isCallExpression(p))&&p.expression===n||ts.isNonNullExpression(p)||ts.isParenthesizedExpression(p)||ts.isAsExpression(p)||ts.isTypeAssertionExpression(p)||!!ts.isSatisfiesExpression?.(p);
+ const top=n=>{if(n.parent&&ts.isPropertyAccessExpression(n.parent)&&n.parent.name===n)n=n.parent;while(n.parent&&wraps(n.parent,n))n=n.parent;return n;};
+ (function v(n){
+ const s=lit(n);
+ if(ts.isIdentifier(n)&&(SEND.has(n.text)||ESCAPES.has(n.text)))out.push(print(top(n)));
+ else if(ts.isElementAccessExpression(n)&&GLOBAL.has(unwrap(n.expression).getText(tree))&&lit(n.argumentExpression)===undefined)out.push(print(n));
+ else if(s!==undefined&&!(n.parent&&lit(n.parent)!==undefined)&&SEND_TEXT.test(s))out.push(print(top(n)));
+ else if((ts.isTemplateExpression(n)||ts.isNoSubstitutionTemplateLiteral(n))&&SEND_TEXT.test(ts.isTemplateExpression(n)?n.head.text+n.templateSpans.map(x=>x.literal.text).join(''):n.text))out.push(print(n));
+ ts.forEachChild(n,v);
+ })(tree);
+ return out;
+}
+const NO_SEND=['Vault.tsx','Scenery.tsx','main.tsx','Docs.tsx'],sendUses={};
+for(const f of sources){
+ const h=sendsOf(baseline('web/src/'+f),f),n=sendsOf(read('web/src/'+f),f);
+ assert.deepEqual(n,h,f+': a wallet send, provider request or signing method was added, removed or changed');
+ if(NO_SEND.includes(f))assert.ok(n.every(x=>x==='window.ethereum'),f+' must contain no send or request (only the read of window.ethereum in main.tsx)');
+ if(n.length)sendUses[f]=n;
+}
+// components.tsx (the shared transaction button) keeps exactly HEAD's single wallet.send.
+assert.deepEqual(sendUses['components.tsx'].filter(x=>/\(/.test(x)),['wallet.send(await getSpec(), scope, scope)'],'components.tsx sends');
+// wallet.tsx send(), the only function that prompts the wallet: printed without comments it must equal HEAD once the
+// documented pieces are put back, so from, to, data, gas (estimate x 1.3), nonce, the chain and account rechecks,
+// the simulation and the eth_sendTransaction request are unchanged. F13: learn a cancel/replacement from
+// waitForTransactionReceipt and say so (no "Confirmed", no link); F7/A5: the proposal detail wording (its end rounded down).
+const sendEdits=[
+ ['let replaced: string | undefined; ',''],
+ ['onReplaced: (r) => { replaced = r.reason; }, ',''],
+ ['if (replaced === "cancelled" || replaced === "replaced") throw new TransactionCancelled(replaced === "cancelled" ? "The transaction was cancelled in the wallet; it did not run. Refresh the vault before retrying." : "The transaction was replaced in the wallet by a different one; it did not run. Refresh the vault before retrying."); ',''],
+ ['until just before ${date(expiresAt, "end")}, from the Proposed event.','until ${date(expiresAt)} (exclusive), from the Proposed event.'],
+ ['if (e instanceof TransactionCancelled) { notice(key, e.message); throw e; } ',''],
+ ['if (sent && !settled && s.functionName === "listGenesis") pendingShown.current = true; ',''],
+];
+function sendText(text){
+ const tree=ts.createSourceFile('wallet.tsx',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX),printer=ts.createPrinter({removeComments:true});let out;
+ (function v(n){if(ts.isFunctionDeclaration(n)&&n.name?.text==='send')out=printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');ts.forEachChild(n,v);})(tree);
+ assert.ok(out,'wallet.tsx send() found');return out;
+}
+let sendNow=sendText(read('web/src/wallet.tsx'));
+for(const [n,o] of sendEdits){assert.equal(sendNow.split(n).length,2,'wallet.tsx send(): expected once: '+n);sendNow=sendNow.split(n).join(o);}
+assert.equal(sendNow,sendText(baseline('web/src/wallet.tsx')),'wallet.tsx send() changed beyond the documented F13/F7/A5 pieces');
+// Differential encoding: HEAD and current governance/model/chain bundled side by side. For the same inputs the
+// calldata must be byte-identical whenever the current page accepts; the current page may only add the
+// documented refusals (made before any wallet prompt), and never accepts what HEAD refused.
+const scratch=path.join(root,'test/scratch/calldata');
+fs.rmSync(scratch,{recursive:true,force:true});
+for(const p of git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').filter(p=>/\.(tsx?|json)$/.test(p))){
+ const out=path.join(scratch,'head',p.slice(4));fs.mkdirSync(path.dirname(out),{recursive:true});fs.writeFileSync(out,baseline(p));
+}
+const entry=src=>`export { proposalSpec, proposalAction } from "${src}/governance";\nexport { parseLaunch, address, recipient, amount, uint, deadline, depositArgs, redeemArgs } from "${src}/model";\nexport { encode, vault, token, VAULT } from "${src}/chain";\n`;
+fs.writeFileSync(path.join(scratch,'head-entry.ts'),entry('./head/src'));
+fs.writeFileSync(path.join(scratch,'now-entry.ts'),entry('../../../web/src')+'export { newOwner } from "../../../web/src/governance";\n');
+const bundle=async name=>{const outfile=path.join(scratch,name+'.mjs');await build({entryPoints:[path.join(scratch,name+'-entry.ts')],bundle:true,format:'esm',platform:'node',outfile,nodePaths:[path.resolve('node_modules')],logLevel:'error'});return import(pathToFileURL(outfile).href);};
+const head=await bundle('head'),now=await bundle('now');
+const SPRING='Sunday 2:00-2:59 am New York is skipped on the spring-forward Sunday; choose another start.';
+const U128='Minimum raw pool liquidity: enter an unsigned 128-bit whole number.';
+const V=now.VAULT,A='0x1111111111111111111111111111111111111111',B='0x2222222222222222222222222222222222222222',Z='0x0000000000000000000000000000000000000000';
+const badChecksum='0x'+V.slice(2).replace(/[a-f]/,c=>c.toUpperCase());
+const addrs=[A,B,V,V.toUpperCase().replace('0X','0x'),Z,'bad','',badChecksum];
+const liqs=['0','1','1000000',String(2n**128n-1n),String(2n**128n),String(2n**200n),String(2n**256n-1n),String(2n**256n),'-1','1.5',' 7',''];
+const hourTexts=['Monday 9:30 am','Friday 4:00 pm','Sunday 8:00 pm','Saturday 24:00','Sunday 12:00 am','Sunday 1:59 am','Sunday 2:00 am','Sunday 2:30 am','Sunday 2:59 am','Sunday 3:00 am','Always open','Monday 24:00','junk'];
+const values=['0','1','2','3','4','10','48','49','50','299','300','2000','3600','20000','86400','500000','500001',String(2n**256n-1n),String(2n**256n),'1.5',''];
+const settingsList=[undefined,{dst:0n},{dst:1n},{dst:2n}];
+const cases=[];
+for(const token of [A,V,Z,'bad'])for(const feed of [B,V,Z])for(const pool of [A,V,Z])for(const quoteFeed of [B,Z])for(const minLiquidity of liqs)cases.push([0,{token,feed,pool,quoteFeed,minLiquidity}]);
+for(const token of addrs)for(const feed of addrs)cases.push([1,{token,feed}]);
+for(const k of [2,3,4,6])for(const token of addrs)cases.push([k,{token}]);
+for(const token of [A,V,'bad'])for(const pool of [A,V,Z])for(const quoteFeed of [B,Z])for(const minLiquidity of liqs)cases.push([5,{token,pool,quoteFeed,minLiquidity}]);
+for(const next of addrs)cases.push([7,{next}]);
+for(const cap of ['0','1','1.5','10000000000','10000000000.000000000000000001','x',''])cases.push([8,{cap}]);
+for(const recipient of addrs)cases.push([9,{recipient}]);
+for(let k=0;k<=16;k++)if(k!==5)for(const value of values)cases.push([10,{setting:String(k),value}]);
+for(const from of hourTexts)for(const to of hourTexts)for(const settings of settingsList)cases.push([10,{setting:'5',from,to},settings]);
+for(const k of [11,-1])cases.push([k,{}]);
+const attempt=f=>{try{return {hex:f()};}catch(e){return {error:e.message};}};
+const tally={sameCalldata:0,bothRefused:0,newRefusals:{}};
+function compare(label,input,h,n,newRefusalAllowed,t=tally){
+ if(n.hex!==undefined){assert.ok(h.hex!==undefined,label+' accepted input HEAD refused: '+JSON.stringify(input));assert.equal(n.hex,h.hex,label+' calldata changed for '+JSON.stringify(input));t.sameCalldata++;}
+ else if(h.hex!==undefined){assert.ok(newRefusalAllowed(n.error),label+' new refusal not documented: '+n.error+' for '+JSON.stringify(input));t.newRefusals[n.error]=(t.newRefusals[n.error]||0)+1;}
+ else t.bothRefused++;
+}
+const isV=x=>typeof x==='string'&&x.toLowerCase()===V.toLowerCase();
+const json=x=>JSON.parse(JSON.stringify(x,(k,v)=>typeof v==='bigint'?String(v):v));
+for(const [kind,v,settings] of cases){
+ const h=attempt(()=>head.encode(head.proposalSpec(kind,v))),n=attempt(()=>now.encode(now.proposalSpec(kind,v,settings)));
+ compare('propose',json({kind,v,settings}),h,n,error=>{
+ if(error===U128)return [0,5].includes(kind)&&/^\d+$/.test(v.minLiquidity)&&BigInt(v.minLiquidity)>=2n**128n&&BigInt(v.minLiquidity)<2n**256n;
+ if(error==='The new guardian must not be the vault.')return kind===7&&isV(v.next);
+ if(error===SPRING){const from=kind===10&&v.setting==='5'?head.proposalAction(kind,v).value:-1n;return from>=7200n&&from<10800n&&(!settings||settings.dst===0n);}
+ return false;
+ });
+}
+for(const next of addrs)compare('transferOwnership',{next},attempt(()=>head.encode(head.vault('transferOwnership',[head.address(next)]))),attempt(()=>now.encode(now.vault('transferOwnership',[now.newOwner(next)]))),error=>error==='The new owner must not be the vault.'&&isV(next));
+for(const pool of [A,Z])for(const quoteFeed of [B,Z])for(const minLiquidity of liqs){
+ const text=`AAA ${A} ${B} ${pool} ${quoteFeed} ${minLiquidity}\nBBB ${B} ${A} ${Z} ${Z} 0`;
+ const listing=m=>m.parseLaunch(text).map(r=>m.encode(m.vault('listGenesis',[r.token,r.feed,r.pool,r.quoteFeed,r.minLiquidity]))).join();
+ compare('listGenesis',{text},attempt(()=>listing(head)),attempt(()=>listing(now)),()=>false);
+}
+// Deposit, redeem, claim, approve and owner-button calls built from the model parsers, compared under fixed clocks so
+// deadline() (the clock plus 600 s, the last argument of every deposit and redeem) is compared too. Here no new
+// refusal is allowed: every input must give the same calldata as HEAD or be refused by both.
+const sends={},clock=Date.now;
+const cmp=(label,input,f)=>compare(label,input,attempt(()=>f(head)),attempt(()=>f(now)),()=>false,sends[label]??={sameCalldata:0,bothRefused:0,newRefusals:{}});
+const clocks=[0,999,1791000000000,1791000000999,1791006123456.7,4102444800000];
+const amountTexts=['0','1','1.5','0.000001','0.0000001','1.000000000000000001','1.0000000000000000001','10000000000',
+ '115792089237316195423570985008687907853269984665640564039457.584007913129639935','115792089237316195423570985008687907853269984665640564039457.584007913129639936',
+ String(2n**256n-1n),String(2n**256n),' 2 ','2.','.5','-1','1e18','x',''];
+const legsList=[[],[0n],[0n,3333n],[1n,999n,1000n,10n**18n,2n**200n]];
+const depositInputs=[[[A,B],[1n,2n],A,1000n],[[A],[0n],B,0n],[[B,A,V],[10n**18n,3n,2n**255n],A,2n**256n-1n],[[],[],A,999n]];
+try{
+ for(const t of clocks){
+ Date.now=()=>t;
+ assert.equal(String(now.deadline()),String(head.deadline()),'deadline() at clock '+t);
+ for(const args of depositInputs){
+ assert.deepEqual(json(now.depositArgs(...args)),json(head.depositArgs(...args)),'depositArgs (all five arguments, deadline included) at clock '+t);
+ cmp('deposit',json({clock:t,args}),m=>m.encode(m.vault('deposit',m.depositArgs(...args))));
+ }
+ for(const shares of [0n,10n,2n**256n-1n])for(const legs of legsList){
+ assert.deepEqual(json(now.redeemArgs(shares,A,legs)),json(head.redeemArgs(shares,A,legs)),'redeemArgs (all four arguments, deadline included) at clock '+t);
+ cmp('redeemArgs',json({clock:t,shares,legs}),m=>m.encode(m.vault('redeem',m.redeemArgs(shares,A,legs))));
+ }
+ }
+ Date.now=()=>1791000000000;
+ // Redeem as Flows builds it: amount(input) shares, recipient(to), redeemArgs(shares, receiver, previewRedeem amounts).
+ for(const text of amountTexts)for(const to of addrs)for(const legs of legsList.slice(1,3))
+ cmp('redeem',json({text,to,legs}),m=>m.encode(m.vault('redeem',m.redeemArgs(m.amount(text),m.recipient(to),legs))));
+ // Deposit amounts (amount(input, false, tokenDecimals)) and the approve built from them.
+ for(const text of amountTexts)for(const d of [0,6,8,18])cmp('approve',{text,d},m=>m.encode(m.token(A,'approve',[m.VAULT,m.amount(text,false,d)])));
+ // Claim and Claim all: vault("claim", [tokens, recipient(to)]).
+ for(const to of addrs)for(const tokens of [[A],[A,B],Array(10).fill(B)])cmp('claim',{to,tokens},m=>m.encode(m.vault('claim',[tokens,m.recipient(to)])));
+ // Owner buttons: lowerNAVCap(amount(cap, true)), close and removeRetired(address(token)).
+ for(const text of amountTexts)cmp('lowerNAVCap',{text},m=>m.encode(m.vault('lowerNAVCap',[m.amount(text,true)])));
+ for(const fn of ['close','removeRetired'])for(const v of addrs)cmp(fn,{v},m=>m.encode(m.vault(fn,[m.address(v)])));
+ // The parsers' values themselves.
+ for(const v of [...liqs,...amountTexts])for(const bits of [128,256])cmp('uint',{v,bits},m=>String(m.uint(v,bits)));
+ for(const v of addrs)for(const zero of [false,true])cmp('address',{v,zero},m=>m.address(v,zero));
+}finally{Date.now=clock;}
+for(const [label,t] of Object.entries(sends)){
+ assert.ok(t.sameCalldata>0,label+': corpus has accepted inputs');
+ if(!['deposit','redeemArgs'].includes(label))assert.ok(t.bothRefused>0,label+': corpus has refused inputs');
+}
+// The transaction-argument builders and parsers, printed without comments, also equal HEAD.
+const pinned={'web/src/model.ts':['same','deadline','depositArgs','redeemArgs','amount','address','recipient','uint','parseLaunch'],'web/src/chain.ts':['vault','token','encode']};
+function declarations(file,text,list){
+ const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),printer=ts.createPrinter({removeComments:true}),out={};
+ for(const st of tree.statements){
+ if(ts.isFunctionDeclaration(st)&&st.name&&list.includes(st.name.text))out[st.name.text]=printer.printNode(ts.EmitHint.Unspecified,st,tree);
+ if(ts.isVariableStatement(st))for(const d of st.declarationList.declarations)if(ts.isIdentifier(d.name)&&list.includes(d.name.text))out[d.name.text]=printer.printNode(ts.EmitHint.Unspecified,st,tree);
+ }
+ // Functions inside components (checkRows in LaunchListing): any depth, exactly one declaration.
+ (function v(n){if(n!==tree&&ts.isFunctionDeclaration(n)&&n.name&&list.includes(n.name.text)&&n.parent!==tree){assert.ok(!out[n.name.text],file+': one '+n.name.text);out[n.name.text]=printer.printNode(ts.EmitHint.Unspecified,n,tree);}ts.forEachChild(n,v);})(tree);
+ for(const n of list)assert.ok(out[n],file+': '+n+' found');
+ return out;
+}
+for(const [file,list] of Object.entries(pinned)){
+ const n=declarations(file,read(file),list),h=declarations(file,baseline(file),list);
+ for(const k of list)assert.equal(n[k],h[k],file+': '+k+' changed');
+}
+// A6 G-B: where the send arguments come from. Printed without comments (whitespace folded), each declaration equals
+// HEAD once its documented pieces [v9, HEAD] are put back, or equals the pinned v9 text (new in v9): the Guardian and
+// FeeRecipient proposal targets (newGuardian(v.next), feeRecipient(v.recipient)), the parsers they use, and the
+// listing rows (checkRows, unchanged) and the Finalize helpers.
+const pinnedV9={
+ 'web/src/governance.ts':{
+ proposalAction:[['v: Record, settings?: any): Action','v: Record): Action'],['target: newGuardian(v.next)','target: address(v.next)'],['target: feeRecipient(v.recipient)','target: recipient(v.recipient)'],['settingValues(v, settings)','settingValues(v)']],
+ proposalSpec:[['v: Record, settings?: any) { return vault("propose", [proposalAction(kind, v, settings)]); }','v: Record) { return vault("propose", [proposalAction(kind, v)]); }']],
+ settingValues:[['v: Record, settings?: any): [','v: Record): ['],['if (springForwardStart(from, settings ? BigInt(settings.dst) : 0n)) throw new InputError(SPRING_FORWARD_START); ','']],
+ poolValues:[['l = minLiquidityOf(v.minLiquidity);','l = uint(v.minLiquidity);']],
+ newOwner:'export function newOwner(v: string): Address { const next = address(v); if (same(next, VAULT)) throw new InputError("The new owner must not be the vault."); return next; }',
+ newGuardian:'function newGuardian(v: string): Address { const next = address(v); if (same(next, VAULT)) throw new InputError("The new guardian must not be the vault."); return next; }',
+ feeRecipient:'function feeRecipient(v: string): Address { const target = address(v); if (same(target, VAULT)) throw new InputError("The fee recipient must not be the vault."); return target; }',
+ minLiquidityOf:'function minLiquidityOf(v: string) { try { return uint(v, 128); } catch { throw new InputError("Minimum raw pool liquidity: enter an unsigned 128-bit whole number."); } }',
+ springForwardStart:'export function springForwardStart(from: bigint, dst: bigint) { return dst === 0n && from >= 7200n && from < 10800n; }',
+ SPRING_FORWARD_START:'export const SPRING_FORWARD_START = "Sunday 2:00-2:59 am New York is skipped on the spring-forward Sunday; choose another start.";'},
+ 'web/src/Owner.tsx':{
+ checkRows:[],
+ listedDiffers:'function listedDiffers(assets: Asset[], r: ReturnType[number]) { const a = assets.find((x) => same(x.token, r.token)); return (!!a && (!same(a.feed, r.feed) || !same(a.pool, r.pool) || !same(a.quoteFeed, r.quoteFeed) || a.minLiquidity !== r.minLiquidity)); }',
+ differWords:'const differWords = (tickers: string[]) => `Listed with other values than the pasted lines: ${tickers.join(", ")}. Check pairings shows which field; paste the lines that were listed.`;',
+ PASTE_FIRST:'const PASTE_FIRST = "Paste the listing rows first so the page can check none is missing.";'},
+};
+const one=s=>s.replace(/\s+/g,' ');
+for(const [file,map] of Object.entries(pinnedV9)){
+ const list=Object.keys(map),n=declarations(file,read(file),list),old=list.filter(k=>typeof map[k]!=='string'),h=declarations(file,baseline(file),old);
+ for(const k of list)assert.equal(typeof map[k]==='string'?one(n[k]):putBack(one(n[k]),map[k],file+' '+k),typeof map[k]==='string'?map[k]:one(h[k]),file+': '+k+' differs from the pinned v9 text');
+}
+// The names those closures call are bound once, by the import from the module that defines them (no local
+// declaration in any scope can stand in for newOwner, vault, read, ...).
+const imports={'web/src/Owner.tsx':{newOwner:'./governance',inspectListing:'./governance',proposalSpec:'./governance',vault:'./chain',read:'./chain',InputError:'./chain',address:'./model',same:'./model',parseLaunch:'./model',loadSnapshot:'./model',TransactionCancelled:'./wallet'},
+ 'web/src/governance.ts':{vault:'./chain',VAULT:'./chain',InputError:'./chain',address:'./model',same:'./model',uint:'./model',amount:'./model'}};
+for(const [file,map] of Object.entries(imports)){
+ const tree=ts.createSourceFile(file,read(file),ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),bound={};
+ (function v(n){
+ const id=n.name&&ts.isIdentifier(n.name)&&(ts.isVariableDeclaration(n)||ts.isFunctionDeclaration(n)||ts.isFunctionExpression(n)||ts.isParameter(n)||ts.isBindingElement(n)||ts.isClassDeclaration(n)||ts.isImportSpecifier(n)||ts.isNamespaceImport(n)||ts.isImportClause(n))?n.name.text:undefined;
+ if(id&&map[id])(bound[id]??=[]).push(ts.isImportSpecifier(n)&&!n.propertyName?n.parent.parent.parent.moduleSpecifier.text:'local '+ts.SyntaxKind[n.kind]);
+ ts.forEachChild(n,v);
+ })(tree);
+ for(const [k,m] of Object.entries(map))assert.deepEqual(bound[k],[m],file+': '+k+' is bound only by its import from '+m);
}
-const files=['Flows.tsx','Vault.tsx'],counts={};
-for(const f of files){const old=calls(baseline('web/src/'+f),f),now=calls(read('web/src/'+f),f);assert.deepEqual(now,old,f+' changed calldata/read/check call expressions');counts[f]=now.length;}
-fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:execFileSync('git',['rev-parse','HEAD'],{cwd:root,encoding:'utf8'}).trim(),protectedFiles,counts,checks:['Losses.tsx, main.tsx, components.tsx and Scenery.tsx byte-identical to HEAD','All vault/token/feed calls, transaction arguments, encodings, reads and simulations in Flows.tsx and Vault.tsx equal the HEAD AST; only the VAULT constant and wording differ','chain.ts, model.ts, governance.ts, Owner.tsx, wallet.tsx, Docs.tsx, poolMath.ts and newYork.ts carry the vault 6 ABI, names and New York hours']},null,2)+'\n');
-console.log('PASS baseline call-expression comparison',counts);
+assert.equal(now.VAULT,head.VAULT,'the vault address the encoders use');
+for(const m of [U128,'The new guardian must not be the vault.','The new owner must not be the vault.',SPRING]) assert.ok(tally.newRefusals[m]>0,'corpus exercises: '+m);
+assert.ok(tally.sameCalldata>400,'corpus size '+JSON.stringify(tally));
+fs.rmSync(scratch,{recursive:true,force:true});
+fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:git(['rev-parse','HEAD']).trim(),protectedFiles,editedFiles:{'main.tsx':'A4: the footer tagline span removed','Scenery.tsx':'A2: eight slogan lines per strip half instead of four'},counts,
+ documentedGuardedCallChanges:expected,ownerActionAttributeChanges:ownerChanges,vaultStockShelves:{attributes:vaultAttributes,handlers:vaultHandlers},walletSendDocumentedPieces:sendEdits.map(([now,head])=>({now,head})),
+ differentialEncoding:{cases:cases.length,...tally},sendBuilders:{clocks,...sends},pinnedDeclarations:pinned,
+ sendsAndRequests:{sendFreeFiles:NO_SEND,usesPerFile:Object.fromEntries(Object.entries(sendUses).map(([f,x])=>[f,x.length])),components:sendUses['components.tsx'].filter(x=>/\(/.test(x))},
+ pinnedV9Declarations:Object.fromEntries(Object.entries(pinnedV9).map(([f,m])=>[f,Object.fromEntries(Object.entries(m).map(([k,x])=>[k,typeof x==='string'?'v9 text':x.length+' pieces from HEAD']))])),importBindings:imports,
+ checks:['Losses.tsx, poolMath.ts, deployment.ts and vault.abi.json byte-identical to HEAD; main.tsx and Scenery.tsx byte-identical to HEAD once the one documented edit is put back (A4 footer tagline removed; A2 eight slogan lines per half); no source file added or removed',
+ 'Every vault/token/feed/read call, transaction argument parser, encoding, simulation and send (guarded names) in every web/src source equals the HEAD AST in order, apart from the exact documented changes listed here',
+ 'Action/send/check JSX attributes equal HEAD in every file except the seven listed Owner.tsx attributes, whose send calls are compared one by one and whose whole printed text equals HEAD once their documented pieces are put back (the listing row checkedRows[i], the listGenesis arguments, newOwner(v.next) included), and Vault.tsx, whose attributes equal the pinned list (HEAD\'s Retry vault button and address links plus the Stock shelves controls, none with a guarded call) and whose StockShelves handlers equal their pinned text; Vault.tsx has no guarded call at all',
+ 'Wallet sends and raw requests: in every web/src file the uses of a send/request name or of the provider (ethereum) with their calls, every string naming a sending or signing method, computed access to the global object and eval/Function/Reflect equal HEAD in order, with no exception; Vault, Scenery, main and Docs have no send or request (main only reads window.ethereum), components keeps the single wallet.send of HEAD',
+ 'Argument sources: governance.ts proposalAction (Guardian target newGuardian(v.next), FeeRecipient target feeRecipient(v.recipient)), proposalSpec, settingValues and poolValues equal HEAD once their documented pieces are put back; newOwner, newGuardian, feeRecipient, minLiquidityOf and the spring-forward rule equal the pinned v9 text; Owner.tsx checkRows equals HEAD and the Finalize helpers equal the pinned v9 text; the names these call are bound only by their imports; the encoders use the same vault address',
+ 'wallet.tsx send() (the only wallet prompt) equals HEAD once the six documented F13/F7/A5 pieces are put back: from, to, data, gas, nonce, chain and account rechecks, simulation and the eth_sendTransaction request unchanged',
+ 'Every call expression in components.tsx, Docs.tsx and Flows.tsx equals HEAD, apart from the F11 Redeem-leg index pair',
+ 'Differential encoding of HEAD and current governance/model/chain: identical calldata for every input the page accepts (proposals of every kind, transfer ownership, genesis listing); the only new refusals are the documented ones (uint128 minLiquidity, vault as guardian or new owner, Sunday 2:00-2:59 am start under Dst 0); nothing HEAD refused is accepted',
+ 'Under six fixed clocks: deadline() and the full depositArgs and redeemArgs tuples (deadline included) equal HEAD, and so does the encoded deposit and redeem calldata; redeem built as Flows does (amount, recipient, redeemArgs), approve (amount with 0/6/8/18 decimals), claim (recipient), lowerNAVCap (amount, zero allowed), close and removeRetired (address), and the uint and address parsers give the same value or are refused by both, with no new refusal',
+ 'The printed text (no comments) of same, deadline, depositArgs, redeemArgs, amount, address, recipient, uint and parseLaunch in model.ts and of vault, token and encode in chain.ts equals HEAD',
+ 'Only the look and fix files change in web/src (check-bundle.mjs)']},null,2)+'\n');
+console.log('PASS baseline call-expression comparison',counts,'differential encoding',tally,'send builders',sends);
diff --git a/web/scripts/check-interface.ts b/web/scripts/check-interface.ts
index 90501cd..23cd036 100644
--- a/web/scripts/check-interface.ts
+++ b/web/scripts/check-interface.ts
@@ -6,10 +6,23 @@ import {
parseLaunch,
depositArgs,
redeemArgs,
+ date,
+ hoursWords,
} from "../src/model";
-import { settingWords, proposalAction, poolValues } from "../src/governance";
+import {
+ settingWords,
+ proposalAction,
+ poolValues,
+ settingValues,
+ gasMaximum,
+ settingBoundsWords,
+ freshWords,
+ proposalWords,
+ newOwner,
+ SPRING_FORWARD_START,
+} from "../src/governance";
import { sqrtAtTick, poolMetrics } from "../src/poolMath";
-import { VAULT, zeroAddress } from "../src/chain";
+import { VAULT, zeroAddress, reasonWords, minutesOf } from "../src/chain";
import {
inside,
nextOpening,
@@ -17,7 +30,15 @@ import {
weekdayWords,
hoursWordsOf,
dstWords,
+ dateWords,
} from "../src/newYork";
+import {
+ closedWords,
+ reopenAt,
+ mayBeOpen,
+ localWords,
+ hoursLine,
+} from "../src/Vault";
// A wrong-checksum address built from VAULT itself: flip the case of its letters.
const badChecksum = VAULT.replace(/[a-f]/g, (c) => c.toUpperCase());
assert.notEqual(badChecksum, VAULT);
@@ -110,6 +131,365 @@ assert.throws(() =>
);
assert.equal(depositArgs([VAULT], [1n], VAULT, 1000n)[3], 995n);
assert.deepEqual(redeemArgs(1n, VAULT, [0n, 3333n])[2], [0n, 3329n]);
+// ---- v9 low fixes ----
+const other = "0x1111111111111111111111111111111111111111";
+// F1: minLiquidity of a Pool or List proposal is a 128-bit number, like the listing parser.
+const u128 = 2n ** 128n - 1n;
+assert.equal(
+ poolValues({ pool: VAULT, quoteFeed: VAULT, minLiquidity: String(u128) })[2],
+ u128,
+);
+for (const big of [2n ** 128n, 2n ** 256n - 1n])
+ assert.throws(
+ () =>
+ poolValues({ pool: VAULT, quoteFeed: VAULT, minLiquidity: String(big) }),
+ /Minimum raw pool liquidity: enter an unsigned 128-bit whole number\./,
+ );
+assert.throws(
+ () => poolValues({ pool: VAULT, quoteFeed: VAULT, minLiquidity: "0" }),
+ /above zero/,
+);
+assert.equal(
+ proposalAction(5, {
+ token: VAULT,
+ pool: VAULT,
+ quoteFeed: VAULT,
+ minLiquidity: String(u128),
+ }).value,
+ u128,
+);
+assert.throws(
+ () =>
+ proposalAction(0, {
+ token: VAULT,
+ feed: VAULT,
+ pool: VAULT,
+ quoteFeed: VAULT,
+ minLiquidity: String(2n ** 128n),
+ }),
+ /128-bit/,
+);
+assert.throws(
+ () => parseLaunch(`BASK ${VAULT} ${VAULT} ${VAULT} ${VAULT} ${2n ** 128n}`),
+ /128-bit/,
+);
+// F2: BalanceGas/PayGas maximum, checked against the combined rule in
+// BaskVault._changedSettings (bounds 20,000-500,000 too).
+const allowed = (c: any) =>
+ c.balanceGas >= 20_000n &&
+ c.balanceGas <= 500_000n &&
+ c.payGas >= 20_000n &&
+ c.payGas <= 500_000n &&
+ c.maxAssets <= 28_000_000n / (c.balanceGas + 60_000n) &&
+ c.directLimit <= 28_000_000n / (c.balanceGas + c.payGas + 70_000n);
+const gasCases = [
+ { maxAssets: 100n, directLimit: 100n, balanceGas: 100_000n, payGas: 100_000n },
+ { maxAssets: 40n, directLimit: 3n, balanceGas: 50_000n, payGas: 300_000n },
+ { maxAssets: 25n, directLimit: 0n, balanceGas: 50_000n, payGas: 50_000n },
+ { maxAssets: 350n, directLimit: 254n, balanceGas: 20_000n, payGas: 20_000n },
+];
+for (const c of gasCases) {
+ // Current settings always pass the vault's own check.
+ assert.ok(allowed(c));
+ for (const [key, field] of [
+ [12, "balanceGas"],
+ [13, "payGas"],
+ ] as const) {
+ const max = gasMaximum(key, c)!;
+ if (max >= 20_000n)
+ assert.ok(allowed({ ...c, [field]: max }), `${field} ${max} allowed`);
+ assert.ok(!allowed({ ...c, [field]: max + 1n }), `${field} ${max + 1n} refused`);
+ }
+}
+assert.equal(gasMaximum(12, gasCases[0]), 110_000n);
+assert.equal(gasMaximum(13, gasCases[0]), 110_000n);
+assert.equal(gasMaximum(13, gasCases[2]), 500_000n);
+assert.match(
+ settingBoundsWords(12, gasCases[0]),
+ /maxAssets × \(balanceGas \+ 60,000\) ≤ 28,000,000 and directLimit × \(balanceGas \+ payGas \+ 70,000\) ≤ 28,000,000\. Current maximum 110,000 /,
+);
+assert.match(
+ settingBoundsWords(13, gasCases[0]),
+ /directLimit × \(balanceGas \+ payGas \+ 70,000\) ≤ 28,000,000\. Current maximum 110,000 /,
+);
+assert.equal(gasMaximum(12, gasCases[3]), 20_000n);
+assert.equal(gasMaximum(13, gasCases[3]), 20_236n);
+// Words for a maximum under 20,000 (settings the vault would not hold).
+assert.match(
+ settingBoundsWords(12, { ...gasCases[3], maxAssets: 400n }),
+ /no value fits: lower MaxAssets or DirectLimit first/,
+);
+assert.match(settingBoundsWords(13, undefined), /Current maximum unreadable/);
+assert.equal(gasMaximum(11, gasCases[0]), undefined);
+// F3: Dst 1 or 2 hours are a fixed UTC-5 / UTC-4, not New York time.
+assert.equal(
+ hoursWordsOf(72000n, 504000n, 1n),
+ "Sunday 8:00 pm to Friday 8:00 pm UTC-5",
+);
+assert.equal(
+ hoursWordsOf(72000n, 504000n, 2n),
+ "Sunday 8:00 pm to Friday 8:00 pm UTC-4",
+);
+assert.equal(
+ hoursWordsOf(72000n, 504000n, 0n),
+ "Sunday 8:00 pm to Friday 8:00 pm New York time",
+);
+const hoursSettings = (dst: bigint) => ({
+ hoursFrom: 72000n,
+ hoursTo: 504000n,
+ dst,
+ freshCount: 1n,
+ freshHours: 1n,
+ poolWindow: 1800n,
+ poolDeviation: 300n,
+});
+assert.equal(
+ hoursWords(hoursSettings(1n)),
+ "Sunday 8:00 pm to Friday 8:00 pm UTC-5",
+);
+assert.equal(
+ settingWords(5, 72000n, 504000n, hoursSettings(2n)),
+ "Sunday 8:00 pm to Friday 8:00 pm UTC-4",
+);
+// F9: the Vault and Deposit pages show the hours in words only.
+assert.equal(
+ hoursLine(hoursSettings(0n)),
+ "Sunday 8:00 pm to Friday 8:00 pm New York time",
+);
+// F6: the countdown follows now (block time plus the seconds since the read)
+// and never shows next week's time at the boundary.
+const saturdayNoon = BigInt(Date.UTC(2026, 9, 10, 16, 0) / 1000); // Sat 10 Oct 2026, 12:00 New York
+for (const dst of [0n, 1n, 2n]) {
+ const s = hoursSettings(dst),
+ t = nextOpening(saturdayNoon, 72000n, dst);
+ assert.equal(reopenAt(s, t - 1n), t);
+ assert.equal(reopenAt(s, t), undefined, "read block already inside the hours");
+ const zone = dst === 0n ? "New York time" : dst === 1n ? "UTC-5" : "UTC-4";
+ assert.match(
+ closedWords(3, s, t - 7260n, t - 7260n)!,
+ new RegExp(
+ `^Deposits reopen Sunday 8:00 pm ${zone} \\(.*, in 2 h 1 min; your time .*\\)\\.`,
+ ),
+ );
+ assert.match(closedWords(3, s, t - 7260n, t - 7200n)!, /, in 2 h 0 min; /);
+ assert.match(closedWords(3, s, t - 7260n, t - 1n)!, /, in 0 h 0 min; /);
+ assert.equal(closedWords(3, s, t - 7260n, t), mayBeOpen);
+ assert.equal(
+ closedWords(3, s, t, t),
+ mayBeOpen,
+ "status reason 3 read on an earlier block",
+ );
+ assert.equal(closedWords(3, s, t + 3600n, t + 3600n), mayBeOpen);
+}
+assert.equal(mayBeOpen, "Deposits may be open now. Press Refresh vault.");
+// F7: dates in words, never a numeric date.
+const oct11 = BigInt(Date.UTC(2026, 9, 11, 4, 40) / 1000);
+assert.equal(dateWords(oct11), "Sun 11 Oct 2026, 04:40 UTC (00:40 New York)");
+assert.equal(date(oct11), "Sun 11 Oct 2026, 04:40 UTC (00:40 New York)");
+assert.equal(
+ date(BigInt(Date.UTC(2026, 9, 11, 2, 5) / 1000)),
+ "Sun 11 Oct 2026, 02:05 UTC (Sat 22:05 New York)",
+);
+assert.equal(
+ date(BigInt(Date.UTC(2027, 0, 4, 15, 0) / 1000)),
+ "Mon 4 Jan 2027, 15:00 UTC (10:00 New York)",
+);
+assert.equal(date(0n), "Not set");
+assert.equal(date(undefined), "unreadable");
+assert.equal(date(2n ** 64n), "unreadable");
+// v9 A5: a window start ("executable from", "Wait until", Losses' recognition date; the default) is rounded up to
+// the next whole minute when it has seconds, an end ("until just before") down; whole minutes stay as they are.
+const at0440 = "Sun 11 Oct 2026, 04:40 UTC (00:40 New York)",
+ at0441 = "Sun 11 Oct 2026, 04:41 UTC (00:41 New York)";
+assert.equal(date(oct11, "start"), at0440);
+assert.equal(date(oct11, "end"), at0440);
+for (const s of [1n, 30n, 59n]) {
+ assert.equal(date(oct11 + s), at0441, `start + ${s} s rounds up`);
+ assert.equal(date(oct11 + s, "start"), at0441);
+ assert.equal(date(oct11 + s, "end"), at0440, `end + ${s} s rounds down`);
+}
+assert.equal(date(oct11 + 60n, "end"), at0441);
+assert.equal(date(oct11 - 1n, "end"), "Sun 11 Oct 2026, 04:39 UTC (00:39 New York)");
+// Rounding up crosses the hour, the day (and the New York weekday) and the year.
+assert.equal(
+ date(BigInt(Date.UTC(2026, 9, 11, 3, 59, 1) / 1000)),
+ "Sun 11 Oct 2026, 04:00 UTC (00:00 New York)",
+);
+assert.equal(
+ date(BigInt(Date.UTC(2026, 9, 11, 3, 59, 1) / 1000), "end"),
+ "Sun 11 Oct 2026, 03:59 UTC (Sat 23:59 New York)",
+);
+assert.equal(
+ date(BigInt(Date.UTC(2026, 11, 31, 23, 59, 30) / 1000)),
+ "Fri 1 Jan 2027, 00:00 UTC (Thu 19:00 New York)",
+);
+// Never earlier than the real start, never later than the real end, at every second of a sample hour.
+for (let s = 0n; s < 3600n; s += 7n) {
+ const t = oct11 + s,
+ words = (x: string) => {
+ const m = x.match(/^\w{3} (\d+) \w{3} 2026, (\d{2}):(\d{2}) UTC/)!;
+ return BigInt(Date.UTC(2026, 9, Number(m[1]), Number(m[2]), Number(m[3])) / 1000);
+ };
+ const up = words(date(t)),
+ down = words(date(t, "end"));
+ assert.ok(up >= t && up - t < 60n && up % 60n === 0n, `start ${t}`);
+ assert.ok(down <= t && t - down < 60n && down % 60n === 0n, `end ${t}`);
+}
+assert.equal(date(1n), "Thu 1 Jan 1970, 00:01 UTC (Wed 19:01 New York)");
+assert.equal(date(253402300740n), "Fri 31 Dec 9999, 23:59 UTC (18:59 New York)");
+assert.equal(date(253402300741n), "unreadable", "rounding up past year 9999");
+assert.equal(date(253402300799n, "end"), "Fri 31 Dec 9999, 23:59 UTC (18:59 New York)");
+// Every date() shown on the pages: "until just before" (the end of a proposal's window) rounds down, every other one
+// (executable from, Wait until, Losses' recognition date) is a start and rounds up.
+{
+ const shown: string[] = [];
+ for (const f of ["Owner.tsx", "wallet.tsx", "Losses.tsx", "Vault.tsx", "Flows.tsx", "main.tsx", "components.tsx", "Docs.tsx"]) {
+ const text = fs.readFileSync(`src/${f}`, "utf8");
+ for (const m of text.matchAll(/(.{0,40})\bdate\(([^()]*)\)/g)) shown.push(`${f}: ${m[1].trim()} date(${m[2]})`);
+ }
+ assert.deepEqual(shown.filter((x) => /"end"/.test(x)).map((x) => /until just before \$\{ ?date\(/.test(x)), [true, true], "the two window ends round down");
+ for (const x of shown.filter((x) => !/"end"/.test(x))) assert.ok(!/until just before/.test(x), "an end rounded up: " + x);
+ assert.equal(shown.length, 7, "date() uses on the pages: " + JSON.stringify(shown));
+}
+const local = localWords(oct11);
+assert.equal(
+ local,
+ new Date(Number(oct11) * 1000).toLocaleString(undefined, {
+ weekday: "short",
+ day: "numeric",
+ month: "short",
+ hour: "numeric",
+ minute: "2-digit",
+ timeZoneName: "short",
+ }),
+);
+assert.doesNotMatch(local, /\d{1,2}:\d{2}:\d{2}/, "no seconds");
+assert.doesNotMatch(
+ local,
+ /\d{1,4}[/.-]\d{1,2}[/.-]\d{1,4}/,
+ "no numeric date",
+);
+assert.ok(
+ closedWords(3, hoursSettings(0n), oct11 - 90000n, oct11 - 90000n)!.includes(
+ `your time ${localWords(nextOpening(oct11 - 90000n, 72000n, 0n))})`,
+ ),
+);
+// F8: FreshCount in words, with singular and plural.
+assert.equal(freshWords(0n, 1n), "off: deposits stay open when prices stop");
+assert.equal(
+ freshWords(1n, 1n),
+ "at least 1 listed stock price updated within the last 1 hour; deposits close when prices stop (US market holidays) until one updates",
+);
+assert.equal(
+ freshWords(3n, 2n),
+ "at least 3 listed stock prices updated within the last 2 hours; deposits close when prices stop (US market holidays) until one updates",
+);
+assert.equal(
+ settingWords(3, 2n, 0n, { freshHours: 1n }),
+ "at least 2 listed stock prices updated within the last 1 hour; deposits close when prices stop (US market holidays) until one updates",
+);
+assert.equal(settingWords(3, 0n), "off: deposits stay open when prices stop");
+assert.match(settingWords(4, 1n), /^1 hour; /);
+assert.match(settingWords(4, 48n), /^48 hours; /);
+// F12: reason 12 and the pairing label use settings().poolWindow and poolDeviation.
+assert.equal(
+ reasonWords(12, hoursSettings(0n)),
+ "pool check failed (30-minute pool price over 3% off the feed, unreadable, under its floor or quote feed bad)",
+);
+assert.equal(
+ reasonWords(12, { poolWindow: 900n, poolDeviation: 150n }),
+ "pool check failed (15-minute pool price over 1.5% off the feed, unreadable, under its floor or quote feed bad)",
+);
+assert.doesNotMatch(reasonWords(12, null), /30-minute|3%/);
+assert.equal(reasonWords(3, hoursSettings(0n)), "outside deposit hours");
+assert.equal(minutesOf(1800n), "30-minute");
+assert.equal(minutesOf(600n), "10-minute");
+// F15: under Dst 0 an Hours start on Sunday 2:00-2:59 am is refused (skipped
+// on the spring-forward Sunday); unreadable settings count as Dst 0.
+const springForward = new RegExp(
+ SPRING_FORWARD_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+);
+assert.equal(
+ SPRING_FORWARD_START,
+ "Sunday 2:00-2:59 am New York is skipped on the spring-forward Sunday; choose another start.",
+);
+for (const from of [
+ "Sunday 2:00 am",
+ "Sunday 2:30 am",
+ "Sunday 2:59 am",
+ "Sunday 02:15",
+])
+ for (const settings of [{ dst: 0n }, undefined]) {
+ assert.throws(
+ () => settingValues({ setting: "5", from, to: "Friday 4:00 pm" }, settings),
+ springForward,
+ );
+ assert.throws(
+ () =>
+ proposalAction(10, { setting: "5", from, to: "Friday 4:00 pm" }, settings),
+ springForward,
+ );
+ }
+for (const dst of [1n, 2n])
+ assert.deepEqual(
+ settingValues(
+ { setting: "5", from: "Sunday 2:30 am", to: "Friday 4:00 pm" },
+ { dst },
+ ),
+ [5, 9000n, 489600n],
+ );
+for (const from of ["Sunday 1:59 am", "Sunday 3:00 am", "Monday 2:30 am"])
+ assert.equal(
+ settingValues({ setting: "5", from, to: "Friday 4:00 pm" }, { dst: 0n })[0],
+ 5,
+ );
+assert.deepEqual(
+ settingValues(
+ { setting: "5", from: "Sunday 1:00 am", to: "Sunday 2:30 am" },
+ { dst: 0n },
+ ),
+ [5, 3600n, 9000n],
+ "only the start is refused",
+);
+assert.deepEqual(
+ settingValues(
+ { setting: "5", from: "Always open", to: "Always open" },
+ { dst: 0n },
+ ),
+ [5, 0n, 0n],
+);
+// F16: the vault is refused as new owner, guardian and fee recipient (zero was already refused).
+for (const v of [VAULT, VAULT.toLowerCase()]) {
+ assert.throws(() => newOwner(v), /The new owner must not be the vault\./);
+ assert.throws(
+ () => proposalAction(7, { next: v }),
+ /The new guardian must not be the vault\./,
+ );
+ assert.throws(
+ () => proposalAction(9, { recipient: v }),
+ /The fee recipient must not be the vault\./,
+ );
+}
+for (const k of [7, 9])
+ assert.throws(
+ () => proposalAction(k, { next: zeroAddress, recipient: zeroAddress }),
+ /valid nonzero address/,
+ );
+assert.throws(() => newOwner(zeroAddress), /valid nonzero address/);
+assert.equal(newOwner(other), other);
+assert.equal(proposalAction(7, { next: other }).target, other);
+assert.equal(proposalAction(9, { recipient: other }).target, other);
+assert.match(
+ proposalWords(
+ proposalAction(10, {
+ setting: "5",
+ from: "Monday 9:30 am",
+ to: "Friday 4:00 pm",
+ }),
+ hoursSettings(1n),
+ ),
+ /Monday 9:30 am to Friday 4:00 pm UTC-5$/,
+);
fs.mkdirSync("../artifacts", { recursive: true });
fs.writeFileSync(
"../artifacts/interface-unit.json",
@@ -124,6 +504,16 @@ fs.writeFileSync(
"Action struct per proposal kind; set pool refuses minLiquidity 0",
"TickMath boundaries and harmonic liquidity formula",
"unchanged deposit/redeem minimum arithmetic",
+ "v9 F1: Pool/List minLiquidity limited to 128 bits; a set pool with 0 still refused",
+ "v9 F2: BalanceGas/PayGas maximum equals the vault's combined rule at max and max+1; bounds words",
+ "v9 F3/F9: Dst 1 and 2 hours in UTC-5/UTC-4 words; Vault/Deposit hours in words only",
+ "v9 F6: countdown follows the vault clock; boundary and inside-hours cases say Deposits may be open now",
+ "v9 F7: dates in words (UTC and New York, US rule), your time without seconds or a numeric date",
+ "v9 A5: a waiting window's start rounded up to the next whole minute, its end ('until just before') down",
+ "v9 F8: FreshCount words with singular and plural; 0 is off",
+ "v9 F12: reason 12 and the pairing label from settings().poolWindow and poolDeviation",
+ "v9 F15: Sunday 2:00-2:59 am Hours start refused under Dst 0 (and unreadable Dst), allowed under Dst 1 and 2",
+ "v9 F16: the vault refused as new owner, guardian and fee recipient",
],
},
null,
diff --git a/web/scripts/check-motion.mjs b/web/scripts/check-motion.mjs
index 7621650..1e4d93d 100644
--- a/web/scripts/check-motion.mjs
+++ b/web/scripts/check-motion.mjs
@@ -49,6 +49,11 @@ try {
"losses",
]) {
await page.goto(url + "#" + name);
+ // A hash change re-renders asynchronously: wait until the requested route is the current page.
+ await page.waitForFunction(
+ (n) => !!document.querySelector(`a[aria-current="page"][href="#${n}"]`),
+ name,
+ );
await page.evaluate(() => document.fonts.ready);
await page.waitForFunction(
() => !document.querySelector("button.refresh").disabled,
@@ -57,7 +62,7 @@ try {
page.evaluate(() =>
[
...document.querySelectorAll(
- ".page-title > p, .page-title h1, .brand, .burst",
+ ".page-title > p, .page-title h1, .page-title .title-character > *, .brand, .burst",
),
].map((e) => {
const r = e.getBoundingClientRect(),
@@ -83,14 +88,75 @@ try {
checks.push(
`${name}: title/brand/sticker geometry stable while scrolling at Linux Chromium DPR ${scale}`,
);
+ if (name === "vault") {
+ // v9 Stock shelves: the restocking swing is the panel's only motion and runs once per label: every animation
+ // there has one iteration and ends within 2 s (delay included); after scrolling through the whole page
+ // nothing in the panel is still moving, and scrolling back up starts nothing again.
+ const shelves = () =>
+ page.evaluate(() =>
+ document
+ .getAnimations()
+ .filter((a) => a.effect?.target?.closest?.(".stock-section"))
+ .map((a) => ({
+ label: a.effect.target.querySelector?.("h4")?.textContent ?? "",
+ iterations: a.effect.getComputedTiming().iterations,
+ end: a.effect.getComputedTiming().endTime,
+ running: a.playState === "running",
+ })),
+ );
+ const labels = await page.locator(".stock-section article.sa-label").count();
+ const height = await page.evaluate(() => document.documentElement.scrollHeight);
+ const seen = [];
+ for (let y = 0; y <= height; y += 200) {
+ await page.evaluate((y) => scrollTo(0, y), y);
+ await page.waitForTimeout(50);
+ seen.push(...(await shelves()));
+ }
+ assert.ok(
+ seen.every((a) => a.iterations === 1 && a.end <= 2000),
+ `Stock shelves: an endless or long animation at DPR ${scale}`,
+ );
+ const swung = new Set(seen.map((a) => a.label));
+ if (labels) assert.ok(swung.size > 0, `Stock shelves: no restocking swing at DPR ${scale}`);
+ await page.waitForTimeout(2100);
+ assert.equal(
+ (await shelves()).filter((a) => a.running).length,
+ 0,
+ `Stock shelves still moving at DPR ${scale}`,
+ );
+ for (let y = height; y >= 0; y -= 400) {
+ await page.evaluate((y) => scrollTo(0, y), y);
+ await page.waitForTimeout(50);
+ }
+ assert.equal(
+ (await shelves()).length,
+ 0,
+ `Stock shelves moved again on scrolling back at DPR ${scale}`,
+ );
+ checks.push(
+ `vault: Stock shelves restocking swing once per label (${swung.size} of ${labels} labels seen swinging), one iteration within 2 s, nothing moving afterwards or on scrolling back, DPR ${scale}`,
+ );
+ }
+ // Look update: the shot covers the title panel up to the title character; her detailed picture would
+ // multiply these tracked PNGs and break the 8 MiB bundle budget (check-bundle.mjs).
if (name === "redeem")
- await page
- .locator(".page-title")
- .screenshot({
- path: path.join(out, `redeem-title-dpr-${scale}.png`),
- });
+ await page.screenshot({
+ path: path.join(out, `redeem-title-dpr-${scale}.png`),
+ fullPage: true,
+ clip: await page.locator(".page-title").evaluate((e) => {
+ const r = e.getBoundingClientRect(),
+ c = e.querySelector(".title-character"),
+ right = c ? c.getBoundingClientRect().left - 8 : r.right;
+ return {
+ x: r.left,
+ y: r.top + scrollY,
+ width: right - r.left,
+ height: r.height,
+ };
+ }),
+ });
}
- for (const width of [375, 661, 1440, 1920, 2560]) {
+ for (const width of [375, 661, 1440, 1920, 2560, 3440]) {
await page.setViewportSize({ width, height: 1000 });
await page.goto(url + "#deposit");
await page.evaluate(() => document.fonts.ready);
@@ -98,11 +164,27 @@ try {
const lengths = await track.evaluate((e) => ({
track: e.getBoundingClientRect().width,
frame: e.parentElement.getBoundingClientRect().width,
+ halves: [...e.children].map((h) => ({
+ width: h.getBoundingClientRect().width,
+ shown: [...h.children].filter(
+ (s) => getComputedStyle(s).display !== "none",
+ ).length,
+ })),
+ willChange: getComputedStyle(e).willChange,
}));
+ // v9 slogan strip: each half is k whole 720 px lines, k the fewest that cover the viewport; the moving block
+ // is at most two screens plus two lines wide and has no will-change.
+ const k = Math.min(8, Math.max(1, Math.ceil(width / 720)));
+ for (const h of lengths.halves) {
+ assert.equal(h.shown, k, `${k} lines per half at ${width}`);
+ assert.ok(Math.abs(h.width - 720 * k) < 0.5 * k, `Half width ${width}`);
+ }
assert.ok(
- lengths.track / 2 >= lengths.frame,
+ 720 * k >= width && lengths.track / 2 >= lengths.frame - 0.01,
`No empty stretch ${width}`,
);
+ assert.ok(lengths.track <= 2 * (width + 720) + 0.5, `Track ${width}`);
+ assert.equal(lengths.willChange, "auto", `No will-change ${width}`);
for (const position of [0, 0.25, 0.49999, 0.75, 0.99999]) {
const gap = await track.evaluate((e, fraction) => {
const a = e.getAnimations()[0];
@@ -115,7 +197,7 @@ try {
assert.equal(gap, false);
}
checks.push(
- `Marquee covers ${width}px at every sampled loop position, DPR ${scale}`,
+ `Marquee (${k} whole lines per half, no will-change) covers ${width}px at every sampled loop position, DPR ${scale}`,
);
}
await context.close();
diff --git a/web/scripts/check-visibility.mjs b/web/scripts/check-visibility.mjs
index d1fa3c9..9b43d1d 100644
--- a/web/scripts/check-visibility.mjs
+++ b/web/scripts/check-visibility.mjs
@@ -2,7 +2,33 @@ import fs from 'node:fs';import http from 'node:http';import path from 'node:pat
const server=http.createServer((req,res)=>{const ps=req.url.split('?')[0].split('/'),root=ps[1]==='old'?process.env.BASKET_BASELINE_DIST:path.resolve('../dist'),p=path.join(root,ps.slice(2).join('/')||'index.html');try{res.setHeader('Content-Type',({'.js':'application/javascript','.css':'text/css','.html':'text/html','.svg':'image/svg+xml','.webp':'image/webp','.woff2':'font/woff2'})[path.extname(p)]||'text/plain');res.end(fs.readFileSync(p));}catch{res.writeHead(404).end();}});await new Promise(r=>server.listen(0,'127.0.0.1',r));
const b=await chromium.launch(),page=await b.newPage({viewport:{width:375,height:812},reducedMotion:'reduce'});const results=[];
const owner=JSON.parse(fs.readFileSync('../artifacts/live-validation.json')).globals.owner;
+// v9 look (final, owner's phone mockup): on phones the pop-out character stands in the title panel above the title
+// words of Vault, Deposit and Redeem, and the no-wallet hint puts "Copy site link" on its own row (same words, 32 px
+// taller). Only these moves are allowed below the first screen; every other element visible on the first screen
+// of the previous build must still be visible there, with the same words (line breaks aside).
+const characterPages=['vault','deposit','redeem'];
+// v9 A4: the Vault title's eyebrow (the old tagline) is removed on purpose; only that element may vanish, only on Vault.
+const taglineWords=/^stocktokens.onebasket.$/i;
await page.addInitScript(({owner})=>{if(location.search.includes('connected')) window.ethereum={request:async({method})=>method==='eth_chainId'?'0x1237':[owner]};},{owner});
try{await page.route(/https:\/\//,async route=>{try{const r=await fetch(route.request().url(),{method:'POST',headers:{'content-type':'application/json'},body:route.request().postData()});await route.fulfill({body:await r.text(),contentType:'application/json'});}catch{await route.abort();}});
-for(const variant of ['no-wallet','connected']) for(const name of ['vault','deposit','redeem','docs','owner','losses']){const snapshots=[];for(const ver of ['old','new']){await page.goto(`http://127.0.0.1:${server.address().port}/${ver}/?${variant}#${name}`);await page.evaluate(()=>document.fonts.ready);await page.waitForFunction(()=>!document.querySelector('button.refresh').disabled);await page.evaluate(()=>scrollTo(0,0));snapshots.push(await page.locator('main p, main input, main button, main select, main textarea, main summary, main .empty, main .note').evaluateAll(es=>es.filter(e=>e.getBoundingClientRect().height>0).map(e=>({tag:e.tagName,text:e.innerText||e.getAttribute('aria-label')||e.name||e.type,y:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom}))))}
-const prior=snapshots[0].filter(x=>x.y>=0&&x.bottom<=812);results.push({variant,name,prior,regressions:prior.filter(x=>!snapshots[1].some(y=>x.text===y.text&&y.bottom<=812))});}for(const r of results) if(r.regressions.length) throw Error(JSON.stringify(r));console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected');fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2));}finally{await b.close();await new Promise(r=>server.close(r));}
+for(const variant of ['no-wallet','connected']) for(const name of ['vault','deposit','redeem','docs','owner','losses']){const snapshots=[],layout=[];for(const ver of ['old','new']){await page.goto(`http://127.0.0.1:${server.address().port}/${ver}/?${variant}#${name}`);await page.evaluate(()=>document.fonts.ready);await page.waitForFunction(()=>!document.querySelector('button.refresh').disabled);await page.evaluate(()=>scrollTo(0,0));snapshots.push(await page.locator('main p, main input, main button, main select, main textarea, main summary, main .empty, main .note').evaluateAll(es=>es.filter(e=>e.getBoundingClientRect().height>0).map(e=>({tag:e.tagName,text:e.innerText||e.getAttribute('aria-label')||e.name||e.type,y:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom,inTitle:!!e.closest('.page-title')}))));
+layout.push(await page.evaluate(()=>{const box=s=>{const e=document.querySelector(s);if(!e)return null;const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height};};return {hint:box('main .wallet-help'),title:box('main .page-title'),picture:box('main .page-title .title-character img.character-picture')};}));}
+const words=t=>String(t).replace(/\s+/g,''),[old,now]=snapshots,[,newLayout]=layout,oldLayout=layout[0];
+const hintGrowth=(newLayout.hint?.height??0)-(oldLayout.hint?.height??0);
+// Where an element of the previous first screen went; null if its words are gone.
+const counterpart=x=>now.filter(y=>words(y.text)===words(x.text)).sort((a,c)=>Math.abs(a.y-x.y)-Math.abs(c.y-x.y))[0]??null;
+const prior=old.filter(x=>x.y>=0&&x.bottom<=812),moved=[],removed=[],regressions=[];
+for(const x of prior){const y=counterpart(x);if(y&&y.bottom<=812)continue;
+ if(!y&&name==='vault'&&taglineWords.test(words(x.text))){removed.push({tag:x.tag,oldBottom:x.bottom,reason:'old Vault eyebrow (tagline) removed (v9)'});continue;}
+ // Title words of the character pages: below the character's head, still inside the title panel.
+ const character=!!y&&y.inTitle&&characterPages.includes(name)&&!!newLayout.picture&&y.y>newLayout.picture.top;
+ // Title words pushed down by the taller wallet hint only (no wallet), by no more than its growth.
+ const hint=!!y&&y.inTitle&&variant==='no-wallet'&&hintGrowth>0&&hintGrowth<=40&&y.bottom<=812+hintGrowth+1;
+ if(character||hint)moved.push({tag:x.tag,text:x.text,oldBottom:x.bottom,newBottom:y.bottom,reason:character?'pop-out character above the title words (v9 look)':'wallet hint '+hintGrowth+' px taller (v9 look)'});
+ else regressions.push({...x,now:y});}
+const checks=[];
+if(characterPages.includes(name)){checks.push('character picture starts on the first screen');if(!newLayout.picture||newLayout.picture.height<=0||newLayout.picture.top<0||newLayout.picture.top>=812)regressions.push({tag:'IMG',text:'title character',now:newLayout.picture});}
+// The report leaves the removed tagline's words out.
+results.push({variant,name,prior:prior.map(x=>taglineWords.test(words(x.text))?{...x,text:'(old Vault eyebrow, removed)'}:x),hintGrowth,moved,removed,checks,regressions});}
+for(const r of results) if(r.regressions.length) throw Error(JSON.stringify({variant:r.variant,name:r.name,regressions:r.regressions,moved:r.moved}));
+console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.moved.length,0),'title elements moved by the v9 look (character, wallet hint);',results.reduce((n,r)=>n+r.removed.length,0),'tagline elements removed (Vault)');fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2));}finally{await b.close();await new Promise(r=>server.close(r));}
diff --git a/web/scripts/run-browser.mjs b/web/scripts/run-browser.mjs
index 4a4199e..1de4455 100644
--- a/web/scripts/run-browser.mjs
+++ b/web/scripts/run-browser.mjs
@@ -38,25 +38,130 @@ const freePort = () =>
});
const port = await freePort(),
rpc = `http://127.0.0.1:${port}`;
-const upstreamRpc =
- process.env.BASKET_RPC || "https://rpc.mainnet.chain.robinhood.com";
-const head = await fetch(upstreamRpc, {
- method: "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- jsonrpc: "2.0",
- id: 1,
- method: "eth_blockNumber",
- params: [],
- }),
-}).then((r) => r.json());
-const forkBlock = BigInt(process.env.BASKET_FORK_BLOCK || head.result);
+// The walk-through lists stocks through genesis, so vault 6 must be empty and
+// unfinalized. 83874298 is the last block where it was (same runtime hash);
+// the live vault has since been listed and may be finalized.
+const forkBlock = BigInt(process.env.BASKET_FORK_BLOCK || 83874298);
assert.ok(forkBlock > 83448310n);
+// A pinned past block needs an archive endpoint; the public RPCs prune it.
+const upstreamRpc =
+ process.env.BASKET_RPC || "https://robinhood.api.pocket.network";
+// Anvil forks through this local read-only proxy. It forwards read methods
+// only, asks by block number where Anvil asks by block hash (archive backends
+// refuse hash-addressed historical reads), keeps at most 8 upstream requests
+// in flight (bursts get throttled) and retries transient failures.
+const readMethods = new Set([
+ "eth_chainId",
+ "net_version",
+ "web3_clientVersion",
+ "eth_blockNumber",
+ "eth_getBlockByNumber",
+ "eth_getBlockByHash",
+ "eth_getBlockReceipts",
+ "eth_getBalance",
+ "eth_getTransactionCount",
+ "eth_getCode",
+ "eth_getStorageAt",
+ "eth_getProof",
+ "eth_getAccount",
+ "eth_getAccountInfo",
+ "eth_call",
+ "eth_gasPrice",
+ "eth_maxPriorityFeePerGas",
+ "eth_feeHistory",
+ "eth_getTransactionByHash",
+ "eth_getTransactionByBlockNumberAndIndex",
+ "eth_getTransactionReceipt",
+]);
+let upstreamActive = 0;
+const upstreamQueue = [];
+async function upstream(method, params) {
+ if (upstreamActive < 8) upstreamActive++;
+ else await new Promise((r) => upstreamQueue.push(r));
+ try {
+ for (let attempt = 1; ; attempt++) {
+ try {
+ const r = await fetch(upstreamRpc, {
+ signal: AbortSignal.timeout(60000),
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
+ });
+ if (r.status === 429 || r.status >= 500)
+ throw Error(`HTTP ${r.status}`);
+ const j = await r.json(); // throws on an HTML challenge page
+ const transient = /historical state|rate limit|too many|timeout/i;
+ if (j.error && transient.test(j.error.message))
+ throw Error(j.error.message);
+ return j;
+ } catch (e) {
+ if (attempt === 6) throw e;
+ await new Promise((r) => setTimeout(r, 500 * attempt));
+ }
+ }
+ } finally {
+ const next = upstreamQueue.shift();
+ if (next) next();
+ else upstreamActive--;
+ }
+}
+const blockNumbers = new Map();
+function byNumber(p) {
+ if (!p || typeof p !== "object" || !("blockHash" in p)) return p;
+ if (!blockNumbers.has(p.blockHash))
+ blockNumbers.set(
+ p.blockHash,
+ upstream("eth_getBlockByHash", [p.blockHash, false]).then(
+ (j) => j.result.number,
+ (e) => {
+ blockNumbers.delete(p.blockHash);
+ throw e;
+ },
+ ),
+ );
+ return blockNumbers.get(p.blockHash);
+}
+const proxy = http.createServer(async (req, res) => {
+ let body = "";
+ try {
+ for await (const chunk of req) body += chunk;
+ } catch {
+ return res.destroy(); // Anvil dropped the request
+ }
+ const one = async (q) => {
+ if (!readMethods.has(q?.method))
+ return {
+ jsonrpc: "2.0",
+ id: q?.id ?? null,
+ error: { code: -32601, message: "Read-only fork proxy" },
+ };
+ try {
+ const params = await Promise.all((q.params ?? []).map(byNumber));
+ return { ...(await upstream(q.method, params)), id: q.id };
+ } catch (e) {
+ return {
+ jsonrpc: "2.0",
+ id: q.id,
+ error: { code: -32000, message: `Upstream: ${e.message}` },
+ };
+ }
+ };
+ let q = null;
+ try {
+ q = JSON.parse(body);
+ } catch {}
+ const answer = Array.isArray(q)
+ ? await Promise.all(q.map(one))
+ : await one(q);
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify(answer));
+});
+await new Promise((r) => proxy.listen(0, "127.0.0.1", r));
const proc = spawn(
"anvil",
[
"--fork-url",
- process.env.BASKET_RPC || "https://rpc.mainnet.chain.robinhood.com",
+ `http://127.0.0.1:${proxy.address().port}`,
"--fork-block-number",
String(forkBlock),
"--port",
@@ -100,6 +205,9 @@ try {
}
}
console.log("Fork RPC ready", rpc);
+ // Some Anvil versions refuse eth_call on the forked head ("Excess blob gas
+ // not set"); a locally mined block carries the blob-gas fields.
+ await raw("evm_mine");
const pc = createPublicClient({ chain, transport: transport(rpc) });
assert.equal(keccak256(await pc.getCode({ address: VAULT })), RUNTIME_HASH);
const rv = (name, args = []) =>
@@ -308,7 +416,7 @@ try {
return;
}
const file = path.resolve(dist, p.slice(9));
- if (!file.startsWith(dist + "/")) {
+ if (!file.startsWith(dist + path.sep)) {
res.writeHead(403).end();
return;
}
@@ -589,10 +697,12 @@ try {
`${state} ${name} overflow ${width}`,
);
const file = `${state}-${name.toLowerCase()}-${width}.jpg`;
+ // Quality 36 (40 before v9): the Docs FAQ and the longer Owner texts made
+ // the quality-40 set about 163 KB larger, over the 8 MiB delivery budget.
await page.screenshot({
path: path.join(artifacts, file),
type: "jpeg",
- quality: 40,
+ quality: 36,
fullPage: true,
});
shots.push(file);
@@ -723,6 +833,17 @@ try {
indices.map((i) => stocks[i].toLowerCase()),
);
await page.getByRole("button", { name: "List stocks", exact: true }).click();
+ // The earlier "All rows listed" text stays on screen while this second pass
+ // re-reads the vault; wait for the pass to end so it cannot overlap the
+ // Finalize send (its pending checks would clear that send's status link).
+ await page.waitForFunction(
+ () =>
+ [...document.querySelectorAll("button")].find(
+ (b) => b.textContent === "List stocks",
+ )?.disabled === false,
+ null,
+ { timeout: 60000 },
+ );
await page
.getByText("All rows listed and matched against chain.", { exact: true })
.waitFor({ timeout: 60000 });
@@ -1049,10 +1170,18 @@ try {
{ exact: true },
)
.waitFor({ state: "attached" });
- assert.ok(
- (await page.locator(".stock-card").first().innerText()).includes(
- "unreadable",
- ),
+ // v9 Stock shelves: the stocks are shelf-edge labels; with the aggregate
+ // read failed every label says its price reason is unreadable.
+ const shelfLabels = page.locator(".stock-section article.sa-label");
+ assert.ok((await shelfLabels.first().innerText()).includes("unreadable"));
+ assert.ok((await shelfLabels.count()) > 0);
+ assert.deepEqual(
+ [...new Set(await shelfLabels.locator(".sa-chip").allInnerTexts())],
+ ["price reason: unreadable"],
+ );
+ assert.equal(
+ await shelfLabels.locator(".sa-chip").count(),
+ await shelfLabels.count(),
);
failAggregate = false;
checks.push("Aggregate failure fallback");
@@ -1231,4 +1360,6 @@ try {
await browser?.close();
if (server) await new Promise((r) => server.close(r));
proc.kill("SIGTERM");
+ proxy.closeAllConnections?.();
+ proxy.close();
}
diff --git a/web/src/Docs.tsx b/web/src/Docs.tsx
index 131b25f..77f6279 100644
--- a/web/src/Docs.tsx
+++ b/web/src/Docs.tsx
@@ -46,6 +46,17 @@ export function DocsPage() {
a receiver you control.
+
+
FAQ
+
What if a stock can’t be sent when I redeem?
+
+ Rarely, a stock can’t be sent at that moment (for example its issuer
+ has paused transfers). The vault then keeps it for the receiver, who
+ collects it later with Claim on the Redeem page. Only the receiver
+ wallet can claim, so redeem to a wallet you control, not an exchange
+ deposit address.
+
+
NAV
diff --git a/web/src/Flows.tsx b/web/src/Flows.tsx
index 34eb57c..6ea68dc 100644
--- a/web/src/Flows.tsx
+++ b/web/src/Flows.tsx
@@ -183,7 +183,18 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) {
}
return (
<>
-
+
Choose one or more open stocks. They go into the vault together in one
deposit.
@@ -480,10 +491,6 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) {