diff --git a/DESIGN.md b/DESIGN.md index ceed93a..b824eba 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -31,13 +31,13 @@ H1 is 64px on desktop and 36px on phones. Title eyebrows use bold Arial at 14px/ ## 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, 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. +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 Vault heading sits alone in the left column, centred top to bottom. Deposit and Redeem keep their heading, sentence and eyebrow in that column. 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 no sentence; 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. -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. The Deposit page is one column at every width: the form, then "Before you deposit" under it. A Deposit row is one line (the stock with its balance, the amount box, Max, Remove) and two lines at 660px and below (the stock and its balance, then the amount box, Max and Remove); rows are split by space and a thin line, not boxed. With a wallet connected the header shows the chain label, the short address and a small Disconnect button on the address line; Disconnect is as tall as that line, so the header keeps its height and nothing leaves the first phone screen (measured in Chromium from 343px up; on narrower phones Disconnect takes a second line and the header is 34px taller). 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. +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. Deposit and Redeem are one column at every width. Deposit has the form, then "Before you deposit" under it. Redeem has its form and the conditional claims presentation beneath. A Deposit row is one line (the stock with its balance, the amount box, Max, Remove) and two lines at 660px and below (the stock and its balance, then the amount box, Max and Remove); rows are split by space and a thin line, not boxed. With a wallet connected the header shows the chain label, the short address and a small Disconnect button on the address line; Disconnect is as tall as that line, so the header keeps its height and nothing leaves the first phone screen (measured in Chromium from 343px up; on narrower phones Disconnect takes a second line and the header is 34px taller). 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. 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. +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. The overflow-clipped slogan track and the owner-approved Deposit sign move continuously; the shelf labels swing once (see Components). White receipt/data panels contain no decorative texture. ## Shapes @@ -49,17 +49,24 @@ Panels and receipts are rectangular; controls use 3px corner radii and at least - `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 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 and the text runs on across the seam with the normal word gap. Since v10 the two halves share one grid cell and each moves on its own, as a Web Animation started by `Marquee` (no CSS animation): from translateX(100%) to translateX(−100%) of its own width, linear and endless, the second half half a cycle behind the first. So the halves always sit edge to edge, each jumps back only while it is wholly off screen, and no `animationiteration` event fires at a reset. Each moving block is one half (k lines, at most the window plus one line) with no `will-change` and no 3D transform (a very wide moving block stops being drawn on iPhones); the track itself stays put. Only `overflow: hidden` clips the window. The speed is exactly 16px per second (a half's cycle is 2 × k × 45 seconds), 64px per second at 660px and below; the cycle is computed from the measured half width and re-timed when the width, the phone breakpoint or the web font changes. There is no pause, stop or hide control, per the owner’s decision. Under `prefers-reduced-motion: reduce` both animations are cancelled and the halves stand still side by side from the left edge. - 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 and the status in words; no label shows when its price updated (feeds post only on a 0.5% move or every 24 hours, so a price time looked stale when it was not) (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, without a time; 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 (no 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. +- `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 and the status in words; no label shows when its price updated (feeds post only on a 0.5% move or every 24 hours, so a price time looked stale when it was not) (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, without a time; 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 (no 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. The owner’s final v12 decision removes normal status altogether. Vault shows a compact plain “Vault status: Closed” for known closures, or unreadable plus Retry vault for unknown data. Its empty wrapper is hidden. Deposit shows the Closed LED sign and one approved short sentence, with no status heading or extra details. For the existing Sunday-8pm-to-Friday-8pm schedule, an outside-hours closure reads “Deposits reopen when the stock feed prices update around 8pm on Sundays.” Other closures use “Deposits will be back open when [issue] is resolved.” with the existing short reason substituted in typographic double quotes. The page omits parenthetical detail, stock-at-fault, countdown, local-time and hours paragraphs. Changed schedules use the generic outside-hours reason. The existing clock and refresh-at-opening behavior remain unchanged. Loading and the reopening boundary render no notice; unknown status, incomplete stock data and unknown setup retain unreadable plus Retry without a sign. The capacity figure is labelled “Maximum Basket Capacity”, with the owner’s “(eligible to be raised)” subline in smaller type beneath it. The original value expression and Deposit aside’s Size limit are unchanged. +- The approved Deposit LED sign is a local SVG, 176px wide and capped to the available width, with a black rounded face, red dot lettering and a blue dotted border. Its only visible word is CLOSED and its accessible image name is Closed. Border chase and letter glow each last 3.2 seconds per cycle. Both stop under reduced motion, leaving the static sign readable. No Open sign or normal-operation message is shown; no asset file, read or request is added. - `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`: a small royal-blue tag first in the Deposit and Redeem forms, always shown: "Redemptions are open 24/7. Deposit hours apply to deposits only." (white on royal blue, 15px, the first sentence bold; each sentence starts its own line when it wraps); decimal amount fields, exact balance controls (Max on Deposit, Use full balance on Redeem), previews immediately after selected amounts, named approval steps and owed-first claim cards. Deposit since v11: one status line "Deposits are open." with the hours while the vault takes deposits, the full `DepositState` box otherwise; the wallet's open stocks as rows, largest dollar value first (ticker in Flyer, wallet balance and "about $..." value, amount box, a quiet Max and a quieter Remove), holdings under one cent behind "Show small balances (N)"; a stock the vault refuses right now is a muted row with the reason and a Refresh button; one search box "Add another stock" (ticker or token address, at most six "Add TICKER" matches, Enter adds the first) and under it a closed native details list "All stocks you can deposit (N)" with every open stock by ticker, A to Z; an estimated total of the typed amounts; under a confirmed deposit "You received ... BASK." from that deposit's receipt. The rows are display only: Preview and Deposit read the typed amounts in the vault's own order. Redeem empties its amount box after a confirmed redeem. 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. +- `Flows.tsx`: a small royal-blue tag first in the Deposit and Redeem forms, always shown: "Redemptions are open 24/7. Deposit hours apply to deposits only." (white on royal blue, 15px, the first sentence bold; each sentence starts its own line when it wraps, and the bold sentence can break between words for wide fallback fonts); decimal amount fields, exact balance controls (Max on Deposit, Use full balance on Redeem), previews immediately after selected amounts, named approval steps and owed-first claim cards. Deposit in v12: the Closed sign and one short closure sentence immediately after the 24/7 pill, with only unreadable and Retry for unknown data and no notice during normal operation, loading or the reopening boundary, under the owner’s latest decision. The form retains the wallet's open stocks as rows, largest dollar value first (ticker in Flyer, wallet balance and "about $..." value, amount box, a quiet Max and a quieter Remove), holdings under one cent behind "Show small balances (N)"; a stock the vault refuses right now is a muted row with the reason and a Refresh button; one search box "Add another stock" (ticker or token address, at most six "Add TICKER" matches, Enter adds the first) and under it a closed native details list "All stocks you can deposit (N)" with every open stock by ticker, A to Z; an estimated total of the typed amounts; under a confirmed deposit "You received ... BASK." from that deposit's receipt. The rows are display only: Preview and Deposit read the typed amounts in the vault's own order. Redeem empties its amount box after a confirmed redeem. Redeem is one column with no secondary aside. The owner removed the wallet instruction line beginning “Showing” from both Deposit and Redeem. Claim all precedes the owed cards; zero stocks are omitted. The full Claims panel appears only when there is owed, unreadable or claim-result information and then remains for the account’s visit, including navigation away and back. Hidden Claims effects pause. Before it has appeared, no-wallet and finished-empty states use the approved quiet line. Refreshes retain the previous presentation. The batch note appears only with multiple owed stocks. 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. 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, 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. +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 initially shows “Nothing is owed to this wallet.” without the Claims panel. A panel already shown for the account retains its confirmation or error until the account changes or the page reloads. 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 Deposit sign’s border chase and letter glow, the Stock shelves labels' one-time restocking swing and 4px lift (all off when reduced motion is requested) and color-only control feedback. The static sign and its accessible state remain when reduced motion is requested. Buttons no longer scale text on press. Forced-colors focus uses the system Highlight color. ## Do's and Don'ts Start new content with `PageTitle` and a `.panel`, then reuse the existing grid and card primitives. Keep the banner, full addresses, issuer-prefix cleaning and `check this pairing` text intact. Use Stock Tokens in prose. Keep all real quantities and transaction warnings in plain data surfaces, with the correct on-chain decimals. -Keep the supermarket immediately recognizable on every page. Art must be original SVG/CSS plus the manifest files kept byte for byte: the owner’s character and Fluent Emoji food (MIT). Load manifest art only through local `./art/...` image sources in TSX; never inline, import, re-encode or reference it from CSS. Avoid astronomical imagery, arcade styling, brand/product logos, numerical decoration, performance slogans and implied claims about returns, safety or market opening. Shopping slogans remain about baskets, aisles and carts. Docs may state the published schedule ("deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US market holidays, redemptions always open"); everything else reads `settings()` and `depositStatus` and never the browser time zone. +Keep the supermarket immediately recognizable on every page. Art must be original SVG/CSS plus the manifest files kept byte for byte: the owner’s character and Fluent Emoji food (MIT). Load manifest art only through local `./art/...` image sources in TSX; never inline, import, re-encode or reference those manifest files from CSS. The separately pinned share JPEG composes the unchanged Vault character with the approved headline and project sign. Avoid astronomical imagery, arcade styling, brand/product logos, numerical decoration, performance slogans and implied claims about returns, safety or market opening. Shopping slogans remain about baskets, aisles and carts. Docs may state the published schedule ("deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US market holidays, redemptions always open"); everything else reads `settings()` and `depositStatus` and never the browser time zone. Native Windows Chrome/Edge at 100%, 125% and 150% display scaling and physical phones require external verification; Linux Chromium DPR geometry checks do not establish their rasterization behavior. See the validation report for actual results. + +## v12 presentation details + +The Vault title is “Always in stock”, without a full stop, eyebrow or subline; PageTitle omits an absent paragraph. Its heading occupies the former heading-and-sentence space, in one or two lines, with unchanged character speech and frame. Deposit and Redeem title geometry is unchanged. The Deposit aside shows both approved paragraphs directly; its separate stock list stays collapsed until pressed. The Docs first panel adds the four approved paragraphs without changing Numbers. + +The link preview is a 1200×630 JPEG of the blue framed panel, red Basket Protocol sign and Vault character, with only “ALWAYS IN STOCK” and the project name. Both copies and the exact share-tag shell are pinned by check-bundle; manifest art remains unchanged. diff --git a/README.md b/README.md index 798c57d..38cd09b 100644 --- a/README.md +++ b/README.md @@ -20,14 +20,14 @@ The build writes `dist/` at the repository root. `vite.config.ts` sets `base: '. ## Publish -Publish the **contents of the committed `dist/` directory** to the existing static host or IPFS deployment for `basket-protocol.site.identitymd.eth`. Keep `index.html` with its `assets/`, `art/` (including the food MIT license), fonts/license files and favicon. Test the export at a subpath such as `/preview/`; do not publish `web/`, package caches or test fixtures. The publisher serves the export without rebuilding. This assignment prepares the export and does not publish it. +Publish the **contents of the committed `dist/` directory** to the existing static host or IPFS deployment for `basket-protocol.site.identitymd.eth`. Keep `index.html` with its `assets/`, `art/` (including the food MIT license), fonts/license files, favicon and share-card.jpg. Test the export at a subpath such as `/preview/`; do not publish `web/`, package caches or test fixtures. The publisher serves the export without rebuilding. This assignment prepares the export and does not publish it. ## Integration - Vault reads and simulations use 30,000,000 gas. Asset metadata is batched through Multicall3 only after checking its deployment. Heavy aggregate reads remain independent. - Every send checks wallet account, chain 4663, public RPC chain and vault runtime hash, then simulates. Chain and account are checked again before requesting the transaction. Deposit, redemption and claim use estimated gas increased by 30%. - `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. +- 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 existing clock still computes reopening from `settings()` and the latest block, never the browser time zone, and refreshes when the opening is reached. The visible closure notice uses the owner’s short wording described under v12. - 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 (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. @@ -81,9 +81,9 @@ Fonts are Anton and VT323 under the SIL Open Font License; notices are in `web/p 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. +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 an injected synthetic account whose balances and claims are answered locally. The v12 baseline is the v11 export. -## Site update v9 +## Site update v9 (historical) Look: @@ -123,7 +123,7 @@ What the checks allow in this update: The fork runner's 24 screenshots were set to JPEG quality 36 in this update (40 before; 32 since v11, see below): 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. -## Site update v10 +## Site update v10 (historical) - Docs: the deposits sentence starts with a capital letter: "Deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US market holidays, redemptions always open". - Deposit and Redeem: a small royal-blue tag at the top of the form, shown whether deposits are open or closed: "Redemptions are open 24/7. Deposit hours apply to deposits only." The Redeem title now reads "Receive your share of every stock." The Vault and Deposit closed messages still end "Redemptions are always open." @@ -138,7 +138,7 @@ What the checks allow in this update: - Slogan strip: `check-art.mjs` (Chromium and WebKit, 23 widths 320-5,760px) and `check-motion.mjs` (three display scales) check that the halves are the only moving blocks, each k whole lines, each its own endless Web Animation at exactly the speed, with no CSS animation, `will-change` or 3D and no endless CSS animation on the page; that at loop phases just before, at and just after each half's reset the window is covered with the normal word gap at every join and at the seam; and that real playback across both resets fires no `animationiteration` event. `check-art.mjs`, `inspect-export.mjs`, `run-browser.mjs` and `check-visibility.mjs` check that reduced motion leaves no animation and a still, full strip. - `check-visibility.mjs` (baseline: the v9 build): nothing may leave the first phone screen, and the slogan strip keeps its place and height. -## Site update v11 +## Site update v11 (historical) - Deposit, the form: the page is one column, the form first and "Before you deposit" under it. Once a wallet is connected the form opens with the open listed stocks that wallet holds, largest dollar value first. Each row has the ticker, the wallet balance and its dollar value, an amount box, Max (on this page it was "Use full balance") and Remove. Holdings worth under one cent wait behind "Show small balances (N)". A row whose stock the vault refuses right now is greyed, says why and has a Refresh button; Preview is still what decides. - Deposit, adding a stock: one search box, "Add another stock", replaces the grid of Add buttons. It takes a ticker or a token address and shows at most six matches as "Add TICKER" buttons; Enter adds the first match and never starts a preview. Under it a closed list, "All stocks you can deposit (N)", holds every open stock by ticker, A to Z. A press adds that stock; a stock already in the form stays in the list, switched off. The list is the vault's own list of open stocks, read from the chain with every vault read, so a newly listed stock appears by itself and a closed or retired one drops out. @@ -161,3 +161,21 @@ What the checks allow in this update: - `check-art.mjs`: the built scripts hold the fixed v11 words ("Add another stock", "Disconnect", "Show small balances", "You received", "Deposits are open.", "All stocks you can deposit", "Press a stock to add it to your deposit.") and no longer "Search to add stocks". Its v9 and v10 checks still run, the 24/7 tag first in the Deposit form among them. - `check-visibility.mjs` (baseline: the v10 build): nothing may leave the first phone screen (375x812), with or without a wallet, so at that width the header with its Disconnect button may not grow. - `run-browser.mjs`: the fork walk-through drives the new Deposit form (rows from the wallet without any Add press, Max, Remove, the search and its six-match limit, the list of every stock (closed until pressed, all 25 tickers, a stock in the form switched off, a press adds its row), small balances, a greyed row, the running total against the preview, "You received" against the Deposit event of the receipt, the rows kept after a deposit), the emptied Redeem box, the one-line status and Disconnect (forget, reload, connect again). Every check of the earlier walk-through still runs and its wallet sends are the same, send for send, apart from the deadline argument of deposit and redeem, which follows the clock. It asserts that Disconnect makes no wallet request and no send, and that the whole walk asks the wallet only for accounts, the chain id and sends. Its 24 screenshots are JPEG quality 32 (36 since v9): the Deposit pictures now have a row for every stock in the wallet and are much taller, and the quality-36 set no longer fits the 8 MiB delivery budget. As before, run it before `check-bundle.mjs`. + +## Site update v12 + +The historical update notes above describe their own baselines. The current patch is applied to v11, commit a6e1b4d2eb5988ab8eb7cfc404161f035aa96d84. +- Vault: “Always in stock” replaces the old heading, with no sentence or empty paragraph. The heading alone is centred in the existing framed title panel; the character and speech are unchanged. Deposit and Redeem title panels retain their geometry. Under the owner’s latest decision, Vault shows only “Vault status: Closed” for a known closure, or the existing unreadable wording and Retry vault for an unknown state. No notice appears while deposits operate normally, during loading or at the reopening boundary awaiting a refreshed status. Deposit carries the short closure sentence. The Vault figure is labelled “Maximum Basket Capacity” with “(eligible to be raised)” beneath it in smaller type. The value and Deposit aside’s “Size limit” wording are unchanged. +- Docs: “What Basket is” retains its first paragraph and adds the four approved paragraphs about the changing mix, backing and trading, ITEM fees and ITEM not being live. The Numbers panel and every other Docs panel are unchanged. +- Deposit: “Before you deposit” shows its two approved paragraphs directly, without Transaction details. The complete stock list remains a fold. The owner’s final status design shows only the approved CLOSED LED sign and one short sentence for a known closure, immediately after the first-child 24/7 pill. For the existing Sunday-8pm-to-Friday-8pm schedule, an outside-hours closure reads “Deposits reopen when the stock feed prices update around 8pm on Sundays.” Other closures use “Deposits will be back open when [issue] is resolved.” with the existing short reason substituted in typographic double quotes. The page omits parenthetical detail, stock-at-fault, countdown, local-time and hours paragraphs. Changed schedules use the generic outside-hours reason. The existing clock and refresh-at-opening behavior remain unchanged. There is no OPEN sign, operational line or normal/loading/boundary notice. Unreadable status, incomplete stock data or unknown setup show only the existing unreadable wording and Retry vault, without a sign. The sign’s accessible name is Closed; both animations stop under reduced motion. No read, request or transaction preparation is added. The wallet instruction line beginning “Showing” is removed from both Deposit and Redeem. +- Redeem: one column, no “Take your basket home” box, and no wallet instruction line, following the owner’s latest decision. Claims initially shows only a quiet connection or nothing-owed line, or neither while the first read runs. Owed stocks, failed owed reads, an incomplete list, a running claim or a claim result reveal the full panel. Once shown it remains for the connected account until an account change or reload. The owner approved preserving it across navigation with React Activity: hidden effects pause, so other routes gain no owed reads. Re-reads preserve the visible rows. The zero-stock fold is gone; the approved batch note appears only for multiple owed stocks. +- The 1200×630 share JPEG is at most 45,000 bytes, has no metadata and uses only the approved headline and project name. The source shell adds eight Open Graph/Twitter tags, including the absolute image URL on basket-protocol.sites.imd.fun. check-bundle pins the shell and both JPEG copies by SHA-256, alongside the unchanged favicon pins. +- Speed up: after the receipt wait and cancellation/replacement check, statuses, links and the confirmed-deposit receipt read use receipt.transactionHash. A separate Connect/Disconnect counter drops a late account answer after Disconnect. The existing startup account guard remains unchanged. The bold 24/7 sentence may wrap at word boundaries. +- No transaction inputs or send preparation change. No chain read, wallet request, storage key or application network call is added. send() through its receipt wait is byte-identical to v11; its tail has only the individually pinned mined-hash replacements. Approvals, previews, transaction buttons, Claim all and the owed read remain byte-identical. + +What the checks allow in this update: + +- check-calldata permits exactly Vault, components, Docs, Flows, wallet and the approved main mounting change. Every other TypeScript source and the ABI equal HEAD. Exact pieces restore HEAD, while independent raw protected-block comparisons, connect/disconnect text, counter uses, ordered call hashes, transaction-state writes and reviewed source hashes remain outside regeneration. All chain/parser/encoding/send calls, provider methods, page-load sinks and storage uses equal HEAD. Differential encoding retains 496 identical and 1,773 mutually refused cases. +- check-bundle additionally allows styles, the exact share-tag shell and only share-card.jpg as a new public file. Existing public assets, configuration, packages, lockfile, pinned source and contracts remain protected. The delivery limit remains 8,388,608 bytes. +- check-art retains manifest, pixel collision, title, strip and shelf assertions, adds the exact approved words and a portable widened 24/7 case, and checks the Vault heading and panel through 320–2560px. check-visibility compares v11 at 375×812, retaining header, strip and character geometry and documenting the original v12 removals and Docs displacement together with the later approved issue-only Vault/Deposit status, Closed sign and removal of both wallet instruction lines. Final measurements are recorded in the build evidence. +- check-fixes retains the pending-transaction tests and exercises Claims blank/quiet/full states, failed reads, retry, retained results, refresh, hidden navigation and account resets. The fork keeps all 74 earlier check positions and the same 70 page sends. Two status-result names change. Of 149 inherited assertion calls, 140 remain byte-identical and ordered; nine status-presentation assertions are replaced explicitly to check no normal notice, the Closed sign and exact short sentences, and absence of the removed label/hours detail. All transaction and on-chain Hours/Dst checks remain. The v12 cases also check the compact Vault notice and both removed wallet instruction lines, along with text/layout, served share image, same-nonce deposit repricing, widened label and late Connect. It expects 84 checks, 70 page sends and 24 screenshots; generated artifacts record actual results. The 24 screenshots use JPEG quality 26 (32 in v11); final evidence records the complete delivery budget and the next-higher-quality comparison. diff --git a/web/index.html b/web/index.html index 1f963c3..89dad7e 100644 --- a/web/index.html +++ b/web/index.html @@ -8,6 +8,23 @@ name="description" content="Basket Protocol. View the BASK vault, deposit Stock Tokens, redeem and manage claims." /> + + + + + + + + Basket Protocol diff --git a/web/public/share-card.jpg b/web/public/share-card.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8b0f86e2f32e124c4156b60ca9d988a9616b1519 GIT binary patch literal 44831 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 nU-_f`@_+8He#`R f.ends for (const text of ["Redemptions are open 24/7.", "Deposit hours apply to deposits only.", "Deposits open Sunday 8 pm to Friday 8 pm New York time", "old price", '"sa-old"', "Feed price"]) assert.ok(bundle.includes(text), "v10 text in the bundle: " + text); for (const gone of ["Redemption is always open.", "deposits open Sunday", "Feed price / age", "just now", "Less than 1 min ago", " min ago", "m ago", "days ago", "price time: ", "Future timestamp", "sa-clock", "sa-upd", "sa-when"]) assert.ok(!(bundle + styles).includes(gone), "removed from the bundle (v10): " + gone); assert.ok(styles.includes(".hours-tag"), "v10 tag style"); -// v11: the fixed words of the rebuilt Deposit form (the search label, the small-balances switch, the one-line status), +// v11 form words remain except the owner-removed normal-operation status sentence. // of the line under a confirmed deposit and of the header's Disconnect button are in the scripts; the old search label // is gone from scripts and styles. -for (const text of ["Add another stock", "Disconnect", "Show small balances", "You received", "Deposits are open.", "All stocks you can deposit", "Press a stock to add it to your deposit."]) assert.ok(bundle.includes(text), "v11 text in the bundle: " + text); +for (const text of ["Add another stock", "Disconnect", "Show small balances", "You received", "All stocks you can deposit", "Press a stock to add it to your deposit."]) assert.ok(bundle.includes(text), "v11 text in the bundle: " + text); for (const gone of ["Search to add stocks"]) assert.ok(!(bundle + styles).includes(gone), "removed from the bundle (v11): " + gone); +for(const text of ['Vault status:',"Deposits reopen when the stock feed prices update around 8pm on Sundays.",'Deposits will be back open when “','” is resolved.']) assert.ok(bundle.includes(text),'Owner short-notice wording '+text); +assert.ok(!bundle.includes('Deposits are open.'),'Owner status placement removes the old Deposit open-only sentence'); +assert.ok(styles.includes('.deposit-summary'),'Vault has a scoped compact deposit status'); +assert.ok(!bundle.includes('Vault deposit status'),'Owner replaces the old status label'); +for(const text of ['Maximum Basket Capacity','(eligible to be raised)'])assert.ok(bundle.includes(text),'Owner capacity wording: '+text); +assert.ok(!bundle.includes('To use another wallet, switch the address in your wallet app: this page follows it.'),'Owner removes the Deposit and Redeem wallet-switch instruction'); +// v12 SPEC sections 1-5: exact approved additions and removals; all earlier word pins remain. +const V12_DOCS = [ + "One token, BASK, backed by a basket of Stock Tokens held in a vault on Robinhood Chain. Deposit one or more listed Stock Tokens at once and receive BASK. Redeeming BASK pays a share of every stock in the vault.", + "The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.", + "BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.", + "The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM.", + "ITEM is not live yet. Its address will be listed here when it is." +]; +const V12_DEPOSIT = [ + "You approve each stock for exactly the amount you deposit. Each approval is its own wallet prompt; the deposit prompt comes last.", + "A prepared deposit must be sent within ten minutes. The BASK goes to your connected wallet." +]; +for (const text of ['Always in stock', ...V12_DOCS.slice(1), ...V12_DEPOSIT, + 'Nothing is owed to this wallet.', + 'Claim all collects up to 10 stocks per wallet prompt. If one stock can’t be paid, that batch stays owed: collect the others with their own Claim buttons.']) + assert.ok(bundle.includes(text), 'v12 text in the bundle: ' + text); +for (const gone of ['A basket of stocks.', 'Meet Basket (BASK): your share of the Stock Tokens in this vault.', + 'Each short allowance is set to exactly that stock’s amount.', 'Approvals are separate wallet prompts; the deposit follows after simulation.', + 'The deadline is ten minutes from preparation. Your wallet receives the BASK.', 'Take your basket home', + 'Batches of up to 10 stocks, with one wallet prompt per batch.']) + assert.ok(!bundle.includes(gone), 'removed from the bundle (v12): ' + gone); // Words on the shelves that would be a price time. const PRICE_TIME = /\bago\b|just now|price updated|price time|less than 1 min|future timestamp|\d+h \d+m/i; // v9 A4: the old tagline is gone from the whole built site (page, scripts, styles, icon), in any letter case. @@ -139,7 +166,7 @@ function assertStripStyles(g, label) { } 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 dir = parts[1] === "baseline" ? path.resolve(process.env.BASKET_BASELINE_DIST) : path.join(root,"dist"); const p = path.resolve(dir, parts.slice(2).join("/") || "index.html"); if (!p.startsWith(dir+path.sep)) return res.writeHead(403).end(); try { @@ -175,7 +202,15 @@ function titleGeometry({R, name, pixels}) { 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; + // v12 SPEC 1 removes Vault's sentence. Its heading is now the last title text; + // retain the same centering measurement and tolerance used for every other title. + const lastText = title.querySelector(":scope > p:last-of-type") ?? title.querySelector(":scope > h1"); + out.textBelow = stripTop - parseFloat(after.marginTop) - r(lastText).bottom; + out.titleHeight = T.height; + const headingRange = document.createRange(); headingRange.selectNodeContents(title.querySelector(":scope > h1")); + const headingRects = [...headingRange.getClientRects()].filter(x => x.width > 0); + out.headingLines = new Set(headingRects.map(x => Math.round(x.top))).size; + out.headingInFrame = headingRects.every(x => x.left >= T.left + bw - 0.5 && x.right <= T.right - bw + 0.5 && x.top >= T.top + bw - 0.5 && x.bottom <= stripTop - 12); out.textUnderHolder = r(firstText).top - r(holder).bottom; out.phone = innerWidth <= 660; out.name = name; @@ -248,6 +283,10 @@ function assertGeometry(g, label, pixels) { 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})`); + if (g.name === 'vault') { + assert.ok(g.headingLines >= 1 && g.headingLines <= 2, label+': Vault heading must use one or two lines'); + assert.equal(g.headingInFrame, true, label+': Vault heading outside the frame'); + } 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`); @@ -262,16 +301,98 @@ function assertGeometry(g, label, pixels) { assert.equal(g.textCovers, 0, `${label}: title text on the figure`); } } +// v12 SPEC 6: unchanged text/colour/placement pins plus whole-word geometry. +function wideLabelGeometry() { + const ts=[...document.querySelectorAll('.hours-tag')],t=ts[0],panel=t.parentElement; + const R=e=>e.getBoundingClientRect(),r=R(t),pr=R(panel),cs=getComputedStyle(t),pcs=getComputedStyle(panel),split=[],outside=[],strongTops=new Set(); + const inner={left:r.left+parseFloat(cs.borderLeftWidth)+parseFloat(cs.paddingLeft),right:r.right-parseFloat(cs.borderRightWidth)-parseFloat(cs.paddingRight),top:r.top+parseFloat(cs.borderTopWidth),bottom:r.bottom-parseFloat(cs.borderBottomWidth)}; + const walker=document.createTreeWalker(t,NodeFilter.SHOW_TEXT); + for(let n;(n=walker.nextNode());) for(const m of n.textContent.matchAll(/\S+/g)) { + const rg=document.createRange();rg.setStart(n,m.index);rg.setEnd(n,m.index+m[0].length); + const boxes=[...rg.getClientRects()].filter(b=>b.width>0),tops=new Set(boxes.map(b=>Math.round(b.top))); + if(tops.size!==1)split.push(m[0]); + if(n.parentElement.closest('strong')) for(const top of tops)strongTops.add(top); + const b=rg.getBoundingClientRect(); + if(b.leftinner.right+0.5||b.topinner.bottom+0.5)outside.push(m[0]); + } + const hit=(a,b)=>a.left section.panel'),font:parseFloat(cs.fontSize),color:cs.color,background:cs.backgroundColor, + inside:r.left>=pr.left+parseFloat(pcs.borderLeftWidth)-0.5&&r.right<=pr.right-parseFloat(pcs.borderRightWidth)+0.5&&r.top>=pr.top+parseFloat(pcs.borderTopWidth)-0.5&&r.bottom<=pr.bottom-parseFloat(pcs.borderBottomWidth)+0.5, + split,outside,strongLines:strongTops.size,overlap:[...document.querySelectorAll('.title-character img, .speech-bubble')].filter(e=>hit(R(e),r)).length,overflow:document.documentElement.scrollWidth>innerWidth}; +} +function assertWideLabel(tag,label) { + assert.equal(tag.count,1,label+': one tag');assert.equal(tag.text,V10_TAG,label+': words'); + assert.equal(tag.first&&tag.inForm,true,label+': first in form');assert.ok(tag.font>=14,label+': font'); + assert.deepEqual([tag.color,tag.background],['rgb(255, 255, 255)','rgb(23, 75, 193)'],label+': colours'); + assert.equal(tag.inside,true,label+': pill inside panel');assert.deepEqual(tag.split,[],label+': whole words'); + assert.deepEqual(tag.outside,[],label+': words inside pill');assert.equal(tag.overlap,0,label+': clear of character/bubble');assert.equal(tag.overflow,false,label+': no horizontal scroll'); +} // 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; + +function capacityGeometry() { + const cells=[...document.querySelectorAll('.stats > div')],cell=cells[3],label=cell?.querySelector(':scope > span'),note=label?.querySelector('small'),value=cell?.querySelector(':scope > strong'); + if(!cell||!label||!note||!value)return {count:cells.length,label:label?.textContent??null}; + const r=cell.getBoundingClientRect(),s=getComputedStyle(cell),split=[],outside=[]; + const labelRange=document.createRange();labelRange.selectNode(label.firstChild); + const labelBox=labelRange.getBoundingClientRect(),noteBox=note.getBoundingClientRect(),valueBox=value.getBoundingClientRect(); + const walker=document.createTreeWalker(label,NodeFilter.SHOW_TEXT); + for(let node;(node=walker.nextNode());)for(const word of node.textContent.matchAll(/\S+/g)) { + const range=document.createRange();range.setStart(node,word.index);range.setEnd(node,word.index+word[0].length); + const box=range.getBoundingClientRect(); + if(new Set([...range.getClientRects()].filter(x=>x.width>0).map(x=>Math.round(x.top))).size!==1)split.push(word[0]); + if(box.leftr.right-parseFloat(s.borderRightWidth)+0.5||box.topr.bottom-parseFloat(s.borderBottomWidth)+0.5)outside.push(word[0]); + } + return {count:cells.length,label:label.firstChild.textContent.trim(),note:note.textContent,noteCount:label.querySelectorAll('small').length,labelFont:parseFloat(getComputedStyle(label).fontSize),noteFont:parseFloat(getComputedStyle(note).fontSize),noteBelow:noteBox.top>=labelBox.bottom-0.5,valueBelow:valueBox.top>=noteBox.bottom-0.5,value:value.textContent,split,outside}; +} +function assertCapacity(g) { + assert.equal(g.count,4,'Vault keeps all four figures'); + assert.deepEqual([g.label,g.note],['Maximum Basket Capacity','(eligible to be raised)'],'Exact owner capacity wording'); + assert.equal(g.noteCount,1,'Capacity has one smaller note'); + assert.ok(g.labelFont>=14&&g.noteFont>=12&&g.noteFont p'); + const signs=[...document.querySelectorAll('.deposit-led-sign')].map(e=>{ + const r=e.getBoundingClientRect(),p=e.closest('.deposit-state')?.getBoundingClientRect(),rect=e.querySelector('rect'); + const border=e.querySelector('.deposit-led-border'),chase=e.querySelector('.deposit-led-chase'),letters=e.querySelector('.deposit-led-letters'); + return {label:e.getAttribute('aria-label'),role:e.getAttribute('role'),inline:e.tagName.toLowerCase()==='svg',onDeposit:!!e.closest('.flow-layout > section.panel .deposit-state'), + width:r.width,height:r.height,inside:!!p&&r.left>=p.left&&r.right<=p.right&&r.top>=p.top&&r.bottom<=p.bottom, + black:rect?.getAttribute('fill'),blue:border?getComputedStyle(border).stroke:null,red:!!e.querySelector('circle[fill="#ff373d"]'), + circles:e.querySelectorAll('circle').length,animations:e.getAnimations({subtree:true}).length, + offset:chase?getComputedStyle(chase).strokeDashoffset:null,glow:letters?getComputedStyle(letters).opacity:null}; + }); + return {status:first?.textContent.replace(/\s+/g,' ').trim()??null,reopening:state?.querySelector('.reopen')?.textContent??null,readIssues:document.querySelectorAll('.read-errors').length,signs}; +} +// Replace only the existing empty-token depositStatus read; other public reads stay live. +let statusFixture=0,statusFixtureReads=0; +function fixtureStatus(call) { + const tx=call?.params?.[0]; + if(call?.method!=='eth_call'||tx?.to?.toLowerCase()!=='0x739fd5b653aa092a434534fa1ade67c1770b5a5b'||tx.data?.toLowerCase()!=='0x6a6ccbd800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000')return undefined; + statusFixtureReads++; + return {jsonrpc:'2.0',id:call.id,result:'0x'+statusFixture.toString(16).padStart(64,'0')+'0'.repeat(64)}; +} + +const WEEKEND_NOTICE="Deposits reopen when the stock feed prices update around 8pm on Sundays.", ISSUE_NOTICE="Deposits will be back open when “too few fresh prices” is resolved."; +let weekendBlockReads=0; +function fixtureBlock(call,answer) { + if(statusFixture!==3||call.method!=='eth_getBlockByNumber')return answer; + assert.ok(answer.result&&typeof answer.result.timestamp==='string','Weekend proof starts with an actual public block'); + weekendBlockReads++; + return {...answer,result:{...answer.result,timestamp:'0x'+Math.floor(Date.parse('2026-10-10T12:00:00Z')/1000).toString(16)}}; +} +const checks=[], visibility=[], bubbles=[], shelfCounts=[], wideLabels=[]; +let browser, baselinePage; try { for (const [engine, launcher] of [["Chromium",chromium],["WebKit",webkit]]) { browser=await launcher.launch({headless:true}); const page=await browser.newPage({viewport:{width:1440,height:1000}}); + let ledMotionProofs=0;statusFixture=0; await page.route(/https:\/\/(rpc\.mainnet\.chain\.robinhood\.com|robinhood-rpc\.publicnode\.com)\/?$/, async route=> { - 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"}); } + try { const call=route.request().postDataJSON(),fixed=fixtureStatus(call);if(fixed){await route.fulfill({body:JSON.stringify(fixed),contentType:'application/json'});return;} const res=await fetch(route.request().url(), {method:"POST",headers:{"content-type":"application/json"},body:route.request().postData(),signal:AbortSignal.timeout(20000)}); const body=await res.text(); await route.fulfill({body:statusFixture===3&&call.method==='eth_getBlockByNumber'?JSON.stringify(fixtureBlock(call,JSON.parse(body))):body,contentType:"application/json"}); } catch {await route.abort();} }); const strips=[]; @@ -370,7 +491,7 @@ try { } checks.push(`${engine} slogan strip, width:lines per half/half px/cycle [${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; v10 loop: the track stays put and the only moving blocks are the two halves (k lines each, at most the viewport plus one line), each its own endless linear Web Animation translateX(100%) to translateX(-100%) on one shared clock, no CSS animation, will-change or 3D in the strip and no endless CSS animation on the page; speed exactly 16 px/s (64 px/s at 660 px and below), two half widths per cycle; at ${STRIP_PHASES.length} loop phases (just before, at and just after each half's reset among them) the halves sit edge to edge across the window at their expected places, each jumping back only while wholly off screen, line boxes edge to edge and every line join and the seam with the normal word gap; so no animationiteration event can fire (that event comes only from CSS animations); reduced motion: no animation (cancelled), a still, full strip from the left edge; motion back when it is turned off`); await page.emulateMedia({reducedMotion:"reduce"}); - for(const width of [1440,1000,999,960,800,720,661,660,375,360,359,320]) { + for(const width of [2560,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); @@ -481,6 +602,35 @@ try { } else { assert.equal(await page.locator('.page-title.has-character, .title-character, .character-picture, .speech-bubble').count(),0,`${engine} ${name} no character`); } + // v12 SPEC 1-4: assert exact requested text/structure on every route width. + if(name==='vault') { + assert.equal(await page.locator('.page-title > h1').textContent(), 'Always in stock'); + assertCapacity(await page.evaluate(capacityGeometry)); + assert.equal(await page.locator('.page-title > p').count(), 0, 'Vault has neither a title sentence nor an empty paragraph'); + } + if(['vault','deposit'].includes(name)) { + await page.waitForFunction(()=>!document.querySelector('button.refresh')?.disabled); + assert.equal(await page.locator('.read-errors').count(),0,'Normal-state proof requires readable public vault data'); + assert.equal(await page.locator('.deposit-state').count(),0,'Known normal operation has no status area'); + assert.equal(await page.locator('.deposit-open').count(),0,'The old open-only Deposit line is absent'); + if(name==='vault')assert.equal(await page.locator('.deposit-summary').evaluate(e=>e.getBoundingClientRect().height),0,'Empty Vault status wrapper takes no space'); + } + assert.equal(await page.locator('.deposit-led-sign').count(),0,'Normal operation and other routes have no sign'); + if(name==='deposit') { + const aside=page.locator('.flow-layout > aside.panel'); + assert.equal(await aside.locator('details').count(),0); + assert.deepEqual((await aside.locator(':scope > p').allTextContents()).slice(2).map(t=>t.replace(/\s+/g,' ').trim()),V12_DEPOSIT); + } + if(name==='redeem') { + assert.equal(await page.locator('.flow-layout.one-column').count(),1); + assert.equal(await page.getByText('Take your basket home',{exact:true}).count(),0); + assert.equal(await page.locator('.claims').count(),0,'No-wallet Redeem has no claims panel'); + assert.equal(await page.getByText('Connect the receiver wallet to read and claim its owed stocks.',{exact:true}).count(),1); + } + if(name==='docs') { + const first=page.locator('.docs-grid > .panel').filter({has:page.getByRole('heading',{name:'What Basket is',exact:true})}); + assert.deepEqual((await first.locator(':scope > p').allTextContents()).map(t=>t.replace(/\s+/g,' ').trim()),V12_DOCS); + } if(name==='redeem') assert.equal(await page.getByText(/Unsent stocks are owed/).count(),0,`${engine} redeem receiver note`); // v10: the 24/7 redemption tag is the first thing in the Deposit and Redeem forms (shown open or closed): exact // words, at least 14 px, white on royal blue, inside its panel with whole words, clear of the title character @@ -519,6 +669,136 @@ try { } 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; v10: the 24/7 redemption tag first in the Deposit and Redeem forms (exact words, at least 14 px, white on royal blue, inside its panel, whole words, clear of the character and her bubble), the Redeem title without it, the capitalised Docs deposits sentence, no price time on any Stock shelves label or in the drawer (reason 9 keeps only its "old price" tag); 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`); } + // Owner follow-up: known closure has exactly one short notice; CLOSED sign at every selected width. + statusFixture=14; + for(const width of [2560,1440,1000,999,960,800,720,661,660,375,360,359,320])for(const name of ['vault','deposit']) { + const beforeFixtureReads=statusFixtureReads; + await page.setViewportSize({width,height:width===375?812:1000}); + await page.goto(base+'/preview/#'+name);await page.reload(); + await page.waitForFunction(routeShown,name); + await page.evaluate(()=>document.fonts.ready); + await page.waitForFunction(()=>!document.querySelector('button.refresh')?.disabled); + assert.ok(statusFixtureReads>beforeFixtureReads,'Closed proof consumed the exact existing depositStatus fixture'); + assert.equal(await page.locator('.read-errors').count(),0,'Closed proof requires readable public vault data'); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'Closed state has no horizontal overflow'); + if(name==='vault')assertCapacity(await page.evaluate(capacityGeometry)); + assert.equal(await page.locator('.deposit-state > p').first().textContent(),name==='deposit'?ISSUE_NOTICE:'Closed','Exact owner-approved issue notice'); + if(['vault','deposit'].includes(name)) { + const status=await page.locator('.deposit-state').evaluateAll(es=>es.map(e=>{ + const visible=n=>!!n&&n.getBoundingClientRect().height>0&&getComputedStyle(n).visibility!=='hidden'; + return {compact:!!e.closest('.deposit-summary'),label:e.querySelector(':scope > strong')?.textContent,labelVisible:visible(e.querySelector(':scope > strong')),paragraphs:[...e.querySelectorAll(':scope > p')].map(p=>({text:p.textContent,visible:visible(p)}))}; + })); + assert.equal(status.length,1,'Exactly one deposit status on '+name); + assert.equal(status[0].label,name==='vault'?'Vault status:':undefined,'Only Vault has the compact status label'); + assert.equal(status[0].labelVisible,name==='vault'); + assert.equal(status[0].paragraphs.length,1,'Exactly one status paragraph; old hours/reason detail is absent'); + assert.equal(status[0].paragraphs[0].visible,true,'Current status is visible'); + assert.equal(status[0].compact,name==='vault','Only Vault scopes the compact status'); + assert.equal(await page.locator('.deposit-state .reopen, .deposit-state .why').count(),0,'No old detailed status paragraphs'); + assert.equal(await page.locator('.deposit-open').count(),0,'The old open-only Deposit line is absent'); + } + if(name==='deposit')await page.waitForFunction(()=>!document.querySelector('button.refresh')?.disabled); + const led=await page.evaluate(ledGeometry); + const unknown=!led.status||/^(Reading\.\.\.|unreadable)/.test(led.status)||led.reopening==='Deposits may be open now. Press Refresh vault.'; + if(name!=='deposit'||unknown)assert.equal(led.signs.length,0,'LED is absent off Deposit and for unreadable/reopening status'); + else if(!led.readIssues)assert.equal(led.signs.length,1,'Readable Deposit status has exactly one LED sign'); + else assert.ok(led.signs.length<=1,'Partial reads never duplicate the sign'); + if(led.signs.length) { + const sign=led.signs[0]; + assert.equal(sign.label,'Closed','The only owner-approved LED state is Closed'); + assert.equal(sign.role,'img'); + assert.equal(sign.inline&&sign.onDeposit&&sign.inside,true,'LED is inline SVG contained in the Deposit status box'); + assert.ok(sign.width>0&&sign.width<=220&&sign.height>0,'LED keeps its small approved dimensions'); + assert.equal(sign.black,'#080b14');assert.equal(sign.blue,'rgb(41, 107, 204)');assert.equal(sign.red,true); + assert.equal(sign.circles,210,'LED keeps the complete CLOSED dot word'); + assert.equal(sign.animations,0,'LED is still under reduced motion'); + if([1440,375].includes(width)) { + await page.emulateMedia({reducedMotion:'no-preference'}); + await page.waitForFunction(()=>document.querySelector('.deposit-led-sign').getAnimations({subtree:true}).length===2); + const moving=await page.evaluate(ledGeometry); + await page.waitForTimeout(120); + const moved=await page.evaluate(ledGeometry); + assert.equal(moved.signs[0].label,sign.label,'Animation never changes the status word'); + assert.notEqual(moved.signs[0].offset,moving.signs[0].offset,'Approved border animation moves'); + await page.emulateMedia({reducedMotion:'reduce'}); + await page.waitForFunction(()=>document.querySelector('.deposit-led-sign').getAnimations({subtree:true}).length===0); + assert.equal((await page.evaluate(ledGeometry)).signs[0].animations,0,'Reduced motion cancels LED animations'); + ledMotionProofs++; + } + } + } + statusFixture=3; + for(const width of [1440,375]) { + const beforeBlocks=weekendBlockReads,beforeStatus=statusFixtureReads; + await page.setViewportSize({width,height:width===375?812:1000}); + await page.goto(base+'/preview/#deposit');await page.reload(); + await page.waitForFunction(routeShown,'deposit'); + await page.evaluate(()=>document.fonts.ready); + await page.waitForFunction(()=>!document.querySelector('button.refresh')?.disabled); + assert.ok(weekendBlockReads>beforeBlocks&&statusFixtureReads>beforeStatus,'Weekend proof consumes both exact status and public-block timestamp fixtures'); + assert.equal(await page.locator('.read-errors').count(),0,'Weekend proof requires readable public vault data'); + assert.deepEqual(await page.locator('.deposit-state > p').allTextContents(),[WEEKEND_NOTICE],'Weekend notice is exactly the one owner sentence'); + assert.equal(await page.locator('.deposit-state > strong, .deposit-state .reopen, .deposit-state .why').count(),0,'Weekend has no extra label or detail'); + const led=await page.evaluate(ledGeometry); + assert.equal(led.signs.length,1);assert.equal(led.signs[0].label,'Closed'); + assert.equal(led.signs[0].inside,true);assert.equal(led.signs[0].animations,0); + assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth>innerWidth),false,'Weekend notice has no horizontal overflow'); + } + statusFixture=0; + assert.equal(ledMotionProofs,2,'Both desktop and phone prove LED movement and reduced-motion cancellation'); + checks.push(engine+': normal status area absent; exact single issue paragraph, compact Vault Closed notice, Deposit-only CLOSED LED at all 13 selected widths; desktop/phone exact weekend sentence, motion and reduced-motion cancellation'); + // v12 SPEC 6: portable wide-font stress. Measure a one-line clone, widen only + // the tag with letter spacing, and make its first sentence at least 240 px and + // wider than the available text area. No installed fallback font is assumed. + for(const name of ['deposit','redeem']) for(const width of [661,375,320]) { + await page.setViewportSize({width,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 stress=await page.evaluate(()=>{ + document.getElementById('wide-label-test')?.remove(); + const tag=document.querySelector('.hours-tag'),strong=tag.querySelector('strong'); + const style=document.createElement('style'); style.id='wide-label-test'; document.head.append(style); + const probe=strong.cloneNode(true); + probe.style.cssText='position:absolute;visibility:hidden;white-space:nowrap!important;width:max-content;max-width:none;'; + tag.append(probe); + const tc=getComputedStyle(tag),box=tag.parentElement.getBoundingClientRect(),pc=getComputedStyle(tag.parentElement); + const room=box.width-parseFloat(pc.borderLeftWidth)-parseFloat(pc.borderRightWidth)-parseFloat(pc.paddingLeft)-parseFloat(pc.paddingRight)-parseFloat(tc.borderLeftWidth)-parseFloat(tc.borderRightWidth)-parseFloat(tc.paddingLeft)-parseFloat(tc.paddingRight); + const target=Math.max(240,room+16); + let spacing=0,oneLine=probe.getBoundingClientRect().width; + while(oneLine=240&&stress.oneLine>stress.room,label+': stress sentence reached the measured width'); + const current=await page.evaluate(wideLabelGeometry); + assertWideLabel(current,label); + assert.ok(current.strongLines>=2,label+': first sentence wraps at whole words'); + // Put back the exact v11 defect on the same page and available width. The + // same geometry assertion must now reject it; then v12 must pass again. + const legacy=await page.addStyleTag({content:'.hours-tag strong{white-space:nowrap!important}'}); + const old=await page.evaluate(wideLabelGeometry); + assert.throws(()=>assertWideLabel(old,label+' with v11 nowrap'),{name:'AssertionError'}); + assert.ok(old.outside.length>0,label+': failure is an actual word outside the pill'); + await legacy.evaluate(e=>e.remove()); + assertWideLabel(await page.evaluate(wideLabelGeometry),label+' restored'); + // Measurements are asserted above; retain bounded evidence, not duplicate + // full geometry, in the delivery's validation report. + wideLabels.push({engine,name,width,...stress,strongLines:current.strongLines,v11Outside:old.outside}); + await page.locator('.hours-tag').screenshot({path:path.join(captures,engine+'-'+name+'-'+width+'-wide-label.png')}); + await page.evaluate(()=>document.getElementById('wide-label-test').remove()); + checks.push(label+': one-line width at least 240 px; wraps at word boundaries inside pill and panel, clear of character/bubble; identical check rejects v11 nowrap'); + } + // v12 SPEC 1: compare the Vault height at every width with the live v11 + // title. Its public data does not affect title sizing, so this second page + // loads only local files and never adds a public RPC read. + baselinePage=await browser.newPage({viewport:{width:1440,height:1000},reducedMotion:'reduce'}); + await baselinePage.route('**/*',route=>route.request().url().startsWith(base+'/')?route.continue():route.abort()); + await baselinePage.goto(base+'/baseline/#vault'); + await baselinePage.waitForFunction(routeShown,'vault'); + await baselinePage.evaluate(()=>document.fonts.ready); + await baselinePage.waitForFunction(()=>[...document.images].every(i=>i.complete&&i.naturalWidth>0)); // 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"]) { @@ -532,12 +812,18 @@ try { 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); + if(name==='vault') { + await baselinePage.setViewportSize({width,height:1000}); + const previous=await baselinePage.locator('.page-title').evaluate(e=>e.getBoundingClientRect().height); + assert.ok(g.titleHeight<=previous+0.5,engine+' vault '+width+': title grew from '+previous+' to '+g.titleHeight); + } lines[g.bubbleLines]=(lines[g.bubbleLines]||0)+1; } 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 baselinePage.close();baselinePage=undefined; await browser.close();browser=undefined; } - fs.writeFileSync(path.join(root,'artifacts/art-validation.json'),JSON.stringify({hashes,checks,bubbles,limitations:['Linux browser engines; physical iPhone not tested. Rendered screenshots reviewed separately.']},null,2)+'\n'); + fs.writeFileSync(path.join(root,'artifacts/art-validation.json'),JSON.stringify({hashes,checks,bubbles,wideLabels,limitations:['Linux browser engines; physical iPhone not tested. Rendered screenshots reviewed separately.']},null,2)+'\n'); console.log('PASS art hashes and browser layout',hashes.length,checks.length); } finally {await browser?.close();await new Promise(r=>server.close(r));} diff --git a/web/scripts/check-bundle.mjs b/web/scripts/check-bundle.mjs index 588af08..7ab49f7 100644 --- a/web/scripts/check-bundle.mjs +++ b/web/scripts/check-bundle.mjs @@ -6,13 +6,11 @@ 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); -// v11 (on top of v10 = HEAD: the rebuilt Deposit form with its "You received" line and the Redeem amount box emptied -// after a redeem in Flows.tsx, the Disconnect button in main.tsx with disconnect() and its remembered flag in -// wallet.tsx, and their styles): only these web/src files may change; every other source stays as at HEAD, and -// Flows.tsx, wallet.tsx and main.tsx may only take their documented pieces (both byte-checked by check-calldata.mjs); -// web/pinned and web/public stay unchanged (the favicon is still the chosen icon); no file is added to web/src or -// web/public. -const v11Source=['Flows.tsx','wallet.tsx','main.tsx','styles.css'].map(f=>'web/src/'+f); +// v12 SPEC sections 1-6 and the approved persistent Claims mount: only these sources may change. +// check-calldata independently pins each reviewed source and every protected transaction block. +const v12Source=['Vault.tsx','components.tsx','Docs.tsx','Flows.tsx','wallet.tsx','main.tsx','styles.css'].map(f=>'web/src/'+f); +const SHELL_SHA256='82f66899a0c19f61299254c0005ee5117af0a912b97e8e57d249288e9dbc962d'; +const SHARE_SHA256='dc14dd6f4068ec9325426583104b1c6ae8748fbb17d8326863c9612280eacd85'; // 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'); @@ -20,21 +18,37 @@ assert.equal(sha256('web/public/favicon.svg'),FAVICON_SHA256,'web/public/favicon assert.equal(sha256('dist/favicon.svg'),FAVICON_SHA256,'dist/favicon.svg is the chosen icon'); for(const p of changed){ assert.ok(!p.startsWith('web/pinned/'),'Pinned source changed: '+p); - assert.ok(!p.startsWith('web/public/'),'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(!p.startsWith('web/public/') || p==='web/public/share-card.jpg','Public file changed: '+p); + // v12 section 5: the exact page shell is pinned below; build configuration and validation fixtures stay unchanged. + assert.ok(!['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(v11Source.includes(p),'Source outside the v11 scope changed: '+p); + if(p.startsWith('web/src/'))assert.ok(v12Source.includes(p),'Source outside the v12 scope 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'); +// v12 section 5: only the pinned share image may be new, whether untracked or tracked. +const untracked=git(['ls-files','--others','--exclude-standard','--','web/src','web/public']).trim().split('\n').filter(Boolean); +const trackedShare=git(['ls-files','--','web/public/share-card.jpg']).trim()==='web/public/share-card.jpg'; +assert.deepEqual(untracked,trackedShare?[]:['web/public/share-card.jpg'],'New files in web/src or web/public'); +assert.equal(sha256('web/index.html'),SHELL_SHA256,'exact v12 source shell'); +for(const p of ['web/public/share-card.jpg','dist/share-card.jpg']) { + assert.equal(sha256(p),SHARE_SHA256,'exact v12 link picture: '+p); + assert.ok(fs.statSync(path.join(root,p)).size<=45000,'share JPEG byte cap'); +} +const sourceShell=fs.readFileSync(path.join(root,'web/index.html'),'utf8'); +const builtShell=fs.readFileSync(path.join(root,'dist/index.html'),'utf8'); +const shareTags=s=>[...s.matchAll(//g)].map(x=>x[0].replace(/\s+/g,' ')); +assert.equal(shareTags(sourceShell).length,8,'exactly eight approved share tags'); +assert.deepEqual(shareTags(builtShell),shareTags(sourceShell),'built share tags equal the pinned shell'); +assert.equal((builtShell.match(/]*src="[^"]+"/); +assert.equal((builtShell.match(/]*rel="stylesheet"/g)||[]).length,1,'one stylesheet'); 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, root contracts, web/pinned, web/index.html, web/vite.config.ts, web/tsconfig.json and the validation fixtures unchanged; web/public unchanged (web/public/favicon.svg and dist/favicon.svg keep the SHA-256 of the chosen icon); web/src changed only within the v11 scope (Flows: the rebuilt Deposit form, "You received ... BASK" after a confirmed deposit, the Redeem amount box emptied after a confirmed redeem; wallet: disconnect() and the remembered disconnected flag; main: the Disconnect button; styles: the form, its rows and the Disconnect button); every other source frozen','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 32 since v11, 36 since v9, 40 before); 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, build configuration and validation fixtures unchanged; v12 source scope independently pinned; exact share shell and both share JPEG copies pinned; both favicon copies unchanged','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 26 in v12, 32 in v11, 36 in v9/v10, 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,"Over the 8 MiB budget (if the 24 screenshots are still an older set, run scripts/run-browser.mjs first: it rewrites them at quality 32): "+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 an older set, run scripts/run-browser.mjs first: it rewrites them at quality 26): "+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 2e49cae..1799219 100644 --- a/web/scripts/check-calldata.mjs +++ b/web/scripts/check-calldata.mjs @@ -11,85 +11,169 @@ 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'); const sha256=s=>createHash('sha256').update(s).digest('hex'); -// v11 (on top of v10, which is HEAD). Flows.tsx: the Deposit form is rebuilt (the wallet's stocks as rows with Max and -// Remove, one search box "Add another stock", a closed list of every stock that can be deposited, an estimated total, -// one status line while deposits are open); a confirmed deposit says "You received ... BASK", read from that deposit's -// own receipt (the one new chain read: client.getTransactionReceipt); the Redeem amount box is emptied after a -// confirmed redeem. main.tsx: a Disconnect button beside the address. wallet.tsx, outside send(): a remembered -// "disconnected" flag and local-only disconnect(), which makes no wallet request. No transaction changes: what is -// previewed, approved, deposited, redeemed and claimed is -// built and sent exactly as at HEAD. Every other web/src source is byte-identical to HEAD. -const protectedFiles=['Docs.tsx','Losses.tsx','Owner.tsx','Scenery.tsx','Vault.tsx','chain.ts','components.tsx','deployment.ts','governance.ts','model.ts','newYork.ts','poolMath.ts','vault.abi.json']; +// v12 on HEAD=v11: the approved presentation changes, mined-receipt hash and connection intent guard. +// Transactions and all chain reads remain HEAD's. main keeps Claims mounted by owner-approved Activity. +const protectedFiles=[ + "Losses.tsx", + "Owner.tsx", + "Scenery.tsx", + "chain.ts", + "deployment.ts", + "governance.ts", + "model.ts", + "newYork.ts", + "poolMath.ts", + "vault.abi.json" +]; for(const f of protectedFiles) assert.equal(read('web/src/'+f),baseline('web/src/'+f),f); -// The v11 files: byte-identical to HEAD once their documented pieces [v11, HEAD] are put back (each exactly once), so +// The v12 files: byte-identical to HEAD once their documented pieces [v12, HEAD] are put back (each exactly once), so // every word, call and attribute in them is pinned. The list between the two marker lines was generated from the // difference to HEAD; the pins further down do not depend on it: they compare with HEAD itself or with texts written // out in this file, so a piece cannot carry a change to a transaction, a wallet request or a chain read past them. -// >>> v11 pieces (generated from the difference to HEAD, not edited by hand) +// >>> v12 pieces (generated from the difference to HEAD, not edited by hand) const editedFiles={ - "Flows.tsx":[ - ["import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { decodeEventLog, type Address, type Hex } from \"viem\";\nimport {\n", - "import { useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport type { Address } from \"viem\";\nimport {\n"], - [" VAULT,\n client,\n vaultAbi,\n reasonWords,\n} from \"./chain\";\n", - " VAULT,\n} from \"./chain\";\n"], - [" same,\n type Asset,\n type Snapshot,\n", - " same,\n type Snapshot,\n"], - ["} from \"./components\";\nimport { DepositState, hoursLine } from \"./Vault\";\nimport type { Wallet } from \"./wallet\";\n", - "} from \"./components\";\nimport { DepositState } from \"./Vault\";\nimport type { Wallet } from \"./wallet\";\n"], - [" [retry, setRetry] = useState(0);\n const [received, setReceived] = useState<{\n hash: Hex;\n receiver: Address;\n shares: bigint;\n }>();\n // The rows come from the last finished balance read, so they stay in place\n // while a new read runs. Never an input to a transaction.\n const [held, setHeld] = useState>();\n const [removed, setRemoved] = useState>({});\n const [showSmall, setShowSmall] = useState(false);\n // The stock an Add button just added, until its row is on screen.\n const focusRow = useRef
(undefined);\n const sequence = useRef(0),\n", - " [retry, setRetry] = useState(0);\n const sequence = useRef(0),\n"], - [" }, [w.confirmed]);\n useLayoutEffect(() => {\n if (w.confirmed?.functionName === \"deposit\") setRemoved({});\n }, [w.confirmed]);\n // Another wallet's rows must not stay on screen.\n useLayoutEffect(() => {\n setHeld(undefined);\n setRemoved({});\n }, [w.account]);\n // The pressed Add button is gone: the new row's amount box takes its focus,\n // or the row itself when the stock is blocked and its box is off.\n useLayoutEffect(() => {\n const box = focusRow.current\n ? document.getElementById(`amount-${focusRow.current}`)\n : null;\n if (box instanceof HTMLInputElement && box.disabled)\n box.closest(\".deposit-row\")?.focus();\n else box?.focus();\n focusRow.current = undefined;\n }, [inputs]);\n // The BASK a confirmed deposit minted, from the Deposit event in its receipt.\n useEffect(() => {\n const done = w.confirmed;\n if (done?.functionName !== \"deposit\") return;\n let live = true;\n client\n .getTransactionReceipt({ hash: done.hash })\n .then((receipt) => {\n const event = receipt.logs\n .filter((l) => same(l.address, VAULT))\n .map((l) => {\n try {\n return decodeEventLog({\n abi: vaultAbi,\n data: l.data,\n topics: l.topics,\n });\n } catch {\n return undefined;\n }\n })\n .find((l) => l?.eventName === \"Deposit\");\n if (!live || !event) return;\n const { receiver, shares } = event.args as any;\n setReceived({ hash: done.hash, receiver, shares });\n })\n .catch(() => {});\n return () => {\n live = false;\n };\n }, [w.confirmed]);\n useEffect(() => {\n", - " }, [w.confirmed]);\n useEffect(() => {\n"], - [" setBalances(b);\n setHeld(b);\n setAllowances(al);\n", - " setBalances(b);\n setAllowances(al);\n"], - [" }\n // From the last finished vault read, kept while a new read runs so the form\n // does not jump; undefined before the first read or when unreadable.\n const r = s.status;\n const reason = r?.ok ? Number(r.value[0]) : undefined;\n // Dollar value of n units at the feed price of the last vault read, 18\n // decimals; undefined when that price is unreadable.\n const worth = (a: Asset, n: bigint) =>\n a.answer !== undefined && a.answer > 0n\n ? (n * a.answer * 10n ** 18n) /\n 10n ** BigInt(a.tokenDecimals + a.feedDecimals)\n : undefined;\n // \"about $1,234.56\"; usd() alone writes a value under one cent as \"$<0.01\".\n const about = (value: bigint) =>\n value < 10n ** 16n ? \"under $0.01\" : `about ${usd(value)}`;\n // Display only. Preview and Deposit read `inputs` in the vault's order.\n const stocks = open.map((a) => {\n const balance = held?.[a.token] ?? 0n,\n value = balance > 0n ? worth(a, balance) : undefined;\n return {\n a,\n value,\n inWallet: balance > 0n,\n // Under one cent. An unreadable price is unknown, never small.\n small: value !== undefined && value < 10n ** 16n,\n added: inputs[a.token] !== undefined,\n };\n });\n type Stock = (typeof stocks)[number];\n // A wallet stock the user has not removed.\n const kept = (x: Stock) => x.inWallet && !removed[x.a.token];\n const onScreen = (x: Stock) =>\n x.added || (kept(x) && (!x.small || showSmall));\n const hiddenSmall = stocks.filter((x) => kept(x) && !onScreen(x)).length;\n // Wallet stocks by value, largest first, unpriced ones after them; then the\n // stocks added by hand. Ties keep the vault's order.\n const rows = [\n ...stocks\n .filter((x) => onScreen(x) && x.inWallet)\n .sort((x, y) =>\n x.value === y.value\n ? 0\n : x.value === undefined\n ? 1\n : y.value === undefined || x.value > y.value\n ? -1\n : 1,\n ),\n ...stocks.filter((x) => onScreen(x) && !x.inWallet),\n ];\n // The last finished read covers every stock now listed.\n const ready = !!held && open.every((a) => a.token in held);\n // A gap in that read; it stays so while the next read runs.\n const unread = ready && open.some((a) => held[a.token] === undefined);\n // Tickers first (the exact one, then those that start with the text, then\n // those that contain it), then token addresses from four characters on.\n const query = search.trim().toLowerCase();\n const rank = (a: Asset) => {\n const ticker = a.symbol.toLowerCase();\n return ticker === query\n ? 0\n : ticker.startsWith(query)\n ? 1\n : ticker.includes(query)\n ? 2\n : query.length >= 4 && a.token.toLowerCase().includes(query)\n ? 3\n : 4;\n };\n const matches = query\n ? stocks\n .filter((x) => !onScreen(x) && rank(x.a) < 4)\n .map((x) => x.a)\n .sort((x, y) => rank(x) - rank(y))\n : [];\n // Every open stock by ticker, A to Z, for the list under the search. A copy\n // for display only: `open` keeps the vault's order.\n const listed = [...open].sort((x, y) => {\n const a = x.symbol.toLowerCase(),\n b = y.symbol.toLowerCase();\n return a < b ? -1 : a > b ? 1 : 0;\n });\n // `focus` is false for Enter in the search box, which keeps the focus.\n function add(t: Address, focus = true) {\n if (focus) focusRow.current = t;\n setRemoved((old) => ({ ...old, [t]: false }));\n setInputs((old) => ({ ...old, [t]: \"\" }));\n setSearch(\"\");\n }\n function remove(t: Address) {\n // The pressed button goes with its row: focus a row beside it or search.\n const row = document.getElementById(`amount-${t}`)?.closest(\".deposit-row\"),\n beside = row?.nextElementSibling ?? row?.previousElementSibling;\n if (row?.contains(document.activeElement)) {\n if (beside instanceof HTMLElement) beside.focus({ preventScroll: true });\n else\n document\n .querySelector(\".stock-search input\")\n ?.focus({ preventScroll: true });\n }\n setRemoved((old) => ({ ...old, [t]: true }));\n if (inputs[t] !== undefined)\n setInputs((old) => {\n const next = { ...old };\n delete next[t];\n return next;\n });\n }\n // An estimate of the typed amounts, for the same stocks Preview would send.\n let total = 0n,\n counted = 0;\n const badAmount: string[] = [],\n noPrice: string[] = [];\n for (const a of open) {\n const text = inputs[a.token];\n if (!text?.trim()) continue;\n let value: bigint | undefined;\n try {\n value = worth(a, amount(text, false, a.tokenDecimals));\n } catch {\n badAmount.push(a.symbol);\n continue;\n }\n if (value === undefined) noPrice.push(a.symbol);\n else {\n total += value;\n counted++;\n }\n }\n return (\n", - " }\n return (\n"], - [" \n
\n
\n \n {/* One line while the vault takes deposits; otherwise, and while the\n status is unread or unreadable, the full box with its reason. */}\n {reason === 0 && open.length > 0 ? (\n

\n Deposits are open. Hours:{\" \"}\n {hoursLine(s.globals.settings)}\n

\n ) : (\n \n )}\n

\n", - " \n

\n
\n \n \n

\n"], - [" >\n {!w.account ? (\n

\n

Connect your wallet to see the stocks you hold.

\n \n {w.connecting ? \"Connecting...\" : \"Connect wallet\"}\n \n
\n ) : (\n <>\n {!ready &&

Reading your wallet...

}\n

\n Showing {w.account.slice(0, 6)}…{w.account.slice(-4)}. To\n use another wallet, switch the address in your wallet app:\n this page follows it.\n

\n {unread ? (\n

\n Some balances could not be read. Press Retry balances\n below.\n

\n ) : (\n ready &&\n !rows.length &&\n !stocks.some((x) => x.inWallet) && (\n

No open listed stock in this wallet.

\n )\n )}\n \n )}\n
\n {rows.map(({ a, value, inWallet }) => {\n const blocked = a.reason !== undefined && a.reason !== 0,\n balance = held?.[a.token];\n return (\n \n
\n \n \n Wallet balance:{\" \"}\n {w.account\n ? balance === undefined && (reading || !ready)\n ? \"Reading...\"\n : fmt(balance, a.tokenDecimals)\n : \"Connect wallet\"}\n {inWallet && value !== undefined && (\n · {about(value)}\n )}\n \n
\n \n {open.length > 6 && (\n <>\n \n
\n {open\n .filter(\n (a) =>\n inputs[a.token] === undefined &&\n (a.symbol\n .toLowerCase()\n .includes(search.toLowerCase()) ||\n a.token\n .toLowerCase()\n .includes(search.toLowerCase())),\n )\n .map((a) => (\n \n setInputs((old) => ({ ...old, [a.token]: \"\" }))\n }\n >\n Add {a.symbol}\n \n ))}\n
\n \n )}\n {open\n .filter(\n (a) => open.length <= 6 || inputs[a.token] !== undefined,\n )\n .map((a) => (\n
\n
\n );\n })}\n
\n {(hiddenSmall > 0 || showSmall) && (\n setShowSmall(!showSmall)}\n >\n {showSmall\n ? \"Hide small balances\"\n : `Show small balances (${hiddenSmall})`}\n \n )}\n \n {/* Always in the page, so that a screen reader hears what the\n search found. */}\n
\n {matches.slice(0, 6).map((a) => (\n add(a.token)}\n >\n Add {a.symbol}\n \n ))}\n {matches.length > 6 && (\n

{matches.length - 6} more: keep typing.

\n )}\n {!!query && !matches.length && (\n

\n {rows.some(\n (x) =>\n rank(x.a) === 0 || x.a.token.toLowerCase() === query,\n )\n ? \"That stock is already in the list above.\"\n : \"No open stock matches.\"}\n

\n )}\n
\n {/* Every stock the vault takes now, closed until pressed. A\n press is the same Add as in the search; a stock that has a\n row stays in the list, switched off. */}\n
\n All stocks you can deposit ({open.length})\n

Press a stock to add it to your deposit.

\n

Grey stocks are already in the form above.

\n
    \n {listed.map((a) => (\n 5 ? \"wide\" : undefined}\n >\n x.a.token === a.token)\n }\n onClick={() => add(a.token)}\n >\n {a.symbol}\n \n \n ))}\n
\n
\n {counted + badAmount.length + noPrice.length > 0 && (\n
\n {counted > 0 && (\n <>\n

\n Total: {about(total)}\n

\n \n An estimate from the last vault read. Preview gives\n the exact figure.\n \n \n )}\n {badAmount.length > 0 && (\n

\n Not counted: {badAmount.join(\", \")} (check the amount\n typed).\n

\n )}\n {noPrice.length > 0 && (\n

\n Not counted: {noPrice.join(\", \")} (price unreadable).\n

\n )}\n
\n )}\n \n {balances[a.token] === undefined && (\n

\n {!w.account\n ? \"Connect wallet to read your balance.\"\n : reading\n ? \"Reading...\"\n : \"Balance unreadable. Retry balances below.\"}\n

\n )}\n
\n ))}\n \n \n ) : showSign ? (\n <>\n \n

{weekendHours\n ? \"Deposits reopen when the stock feed prices update around 8pm on Sundays.\"\n : `Deposits will be back open when “${issue}” is resolved.`}

\n \n ) : (\n <>Vault status:

Closed

\n )}\n
\n", + " ? closedWords(reason, s.globals.settings, s.blockTime, now)\n : undefined;\n return (\n
\n Vault deposit status\n

\n {s.loading\n ? \"Reading...\"\n : !s.assets.length &&\n s.complete &&\n s.globals.genesisFinalized === false\n ? \"No Stock Tokens are listed yet. Deposits start after the owner finishes setup.\"\n : s.assets.length > 0 &&\n s.assets.every((a) => !a.open || a.retired) &&\n s.globals.genesisFinalized\n ? \"Every stock is closed. The owner must reopen a stock before deposits.\"\n : !r || !r.ok\n ? \"unreadable — Retry vault\"\n : reasonWords(Number(r.value[0]), s.globals.settings)}\n

\n {r?.ok && r.value[1] !== zeroAddress && (\n

\n Stock at fault:{\" \"}\n {s.assets.find(\n (a) => a.token.toLowerCase() === r.value[1].toLowerCase(),\n )?.symbol ?? r.value[1]}\n

\n )}\n {closed &&

{closed}

}\n

Hours: {s.loading ? \"Reading...\" : hoursLine(s.globals.settings)}

\n {!s.loading && (!r || !r.ok) && (\n \n )}\n
\n" + ], + [ + " <>\n \n \n
\n
\n", + " line: \"I was hoping you would come through my aisle\",\n }}\n >\n Meet Basket (BASK): your share of the Stock Tokens in this vault.\n \n
\n
\n" + ], + [ + "
\n
\n Maximum Basket Capacity(eligible to be raised)\n {s.loading ? \"Reading...\" : usd(s.globals.NAV_CAP)}\n
\n", + "
\n
\n Size limit\n {s.loading ? \"Reading...\" : usd(s.globals.NAV_CAP)}\n
\n" + ], + [ + " )}\n
\n
\n \n
\n
\n Fees\n", + " )}\n
\n \n
\n Fees\n" + ] ], - "wallet.tsx":[ - ["}\n// Set by Disconnect and kept across reloads until Connect wallet is answered.\nconst disconnectedKey = \"basket-disconnected-4663\";\nfunction savedDisconnected() {\n try {\n return localStorage.getItem(disconnectedKey) === \"1\";\n } catch {\n return false;\n }\n}\nexport function useWallet(refresh: () => Promise) {\n", - "}\nexport function useWallet(refresh: () => Promise) {\n"], - [" connectingLock = useRef(false);\n // Mirrors the saved flag, so Disconnect also holds without storage.\n const disconnected = useRef(savedDisconnected());\n const accountVersion = useRef(0);\n const [connecting, setConnecting] = useState(false);\n", - " connectingLock = useRef(false);\n const [connecting, setConnecting] = useState(false);\n"], - [" if (!p) return;\n // After Disconnect the page takes no account until Connect wallet.\n const accounts = (a: Address[]) => {\n accountVersion.current++;\n if (!disconnected.current) setAccount(a[0]);\n };\n const network = (n: string) => setChainId(Number(n));\n const version = accountVersion.current;\n p.request({ method: \"eth_accounts\" })\n .then((a) => {\n // A later wallet event or user action takes precedence over this read.\n if (version === accountVersion.current) accounts(a);\n })\n .catch(() => {});\n", - " if (!p) return;\n const accounts = (a: Address[]) => setAccount(a[0]);\n const network = (n: string) => setChainId(Number(n));\n p.request({ method: \"eth_accounts\" })\n .then(accounts)\n .catch(() => {});\n"], - [" return () => {\n accountVersion.current++;\n p.removeListener?.(\"accountsChanged\", accounts);\n", - " return () => {\n p.removeListener?.(\"accountsChanged\", accounts);\n"], - [" connectingLock.current = true;\n accountVersion.current++;\n setConnecting(true);\n", - " connectingLock.current = true;\n setConnecting(true);\n"], - [" });\n if (a[0]) {\n disconnected.current = false;\n try {\n localStorage.removeItem(disconnectedKey);\n } catch {\n /* storage unavailable */\n }\n }\n setAccount(a[0]);\n", - " });\n setAccount(a[0]);\n"], - [" }\n // Forget the wallet on this page only; a reload stays disconnected.\n function disconnect() {\n if (sending.current) return;\n accountVersion.current++;\n disconnected.current = true;\n try {\n localStorage.setItem(disconnectedKey, \"1\");\n } catch {\n /* the ref above still holds for this visit */\n }\n setAccount(undefined);\n setHash(undefined);\n setMessage(\n \"Wallet disconnected from this page. To use another wallet, switch the address in your wallet app, then press Connect wallet.\",\n );\n }\n async function switchChain() {\n", - " }\n async function switchChain() {\n"], - [" connect,\n disconnect,\n switchChain,\n", - " connect,\n switchChain,\n"] + "components.tsx": [ + [ + " eyebrow?: string;\n title: string;\n children?: ReactNode;\n character?: {\n kind: \"vault\" | \"deposit\" | \"redeem\";\n", + " eyebrow?: string;\n title: string;\n children: ReactNode;\n character?: {\n kind: \"vault\" | \"deposit\" | \"redeem\";\n" + ], + [ + " {eyebrow &&

{eyebrow}

}\n

{title}

\n {children != null &&

{children}

}\n {character && (\n
\n", + " {eyebrow &&

{eyebrow}

}\n

{title}

\n

{children}

\n {character && (\n
\n" + ] ], - "main.tsx":[ - [" \n \n Disconnect\n \n {wallet.chainId !== 4663 && (\n", - " \n {wallet.chainId !== 4663 && (\n"] + "Docs.tsx": [ + [ + " vault.\n

\n

\n The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.\n

\n

\n BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.\n

\n

\n The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM.\n

\n

\n ITEM is not live yet. Its address will be listed here when it is.\n

\n
\n
\n", + " vault.\n

\n
\n
\n" + ] + ], + "Flows.tsx": [ + [ + " Receiver,\n} from \"./components\";\nimport { DepositState } from \"./Vault\";\nimport type { Wallet } from \"./wallet\";\ntype Props = { snapshot: Snapshot; wallet: Wallet };\n", + " Receiver,\n} from \"./components\";\nimport { DepositState, hoursLine } from \"./Vault\";\nimport type { Wallet } from \"./wallet\";\ntype Props = { snapshot: Snapshot; wallet: Wallet };\n" + ], + [ + " // From the last finished vault read, kept while a new read runs so the form\n // does not jump; undefined before the first read or when unreadable.\n // Dollar value of n units at the feed price of the last vault read, 18\n // decimals; undefined when that price is unreadable.\n", + " // From the last finished vault read, kept while a new read runs so the form\n // does not jump; undefined before the first read or when unreadable.\n const r = s.status;\n const reason = r?.ok ? Number(r.value[0]) : undefined;\n // Dollar value of n units at the feed price of the last vault read, 18\n // decimals; undefined when that price is unreadable.\n" + ], + [ + "
\n \n \n

\n Choose amounts, preview, approve each stock, press Deposit.\n", + "

\n \n {/* One line while the vault takes deposits; otherwise, and while the\n status is unread or unreadable, the full box with its reason. */}\n {reason === 0 && open.length > 0 ? (\n

\n Deposits are open. Hours:{\" \"}\n {hoursLine(s.globals.settings)}\n

\n ) : (\n \n )}\n

\n Choose amounts, preview, approve each stock, press Deposit.\n" + ], + [ + " <>\n {!ready &&

Reading your wallet...

}\n {unread ? (\n

\n", + " <>\n {!ready &&

Reading your wallet...

}\n

\n Showing {w.account.slice(0, 6)}…{w.account.slice(-4)}. To\n use another wallet, switch the address in your wallet app:\n this page follows it.\n

\n {unread ? (\n

\n" + ], + [ + " subtracts this lock and any fee.\n

\n

\n You approve each stock for exactly the amount you deposit. Each approval is its own wallet prompt; the deposit prompt comes last.\n

\n

\n A prepared deposit must be sent within ten minutes. The BASK goes to your connected wallet.\n

\n \n Feeds lag the market. Issuer restrictions and pool checks can stop\n", + " subtracts this lock and any fee.\n

\n
\n Transaction details\n

\n Each short allowance is set to exactly that stock’s amount.\n Approvals are separate wallet prompts; the deposit follows after\n simulation.\n

\n

\n The deadline is ten minutes from preparation. Your wallet receives\n the BASK.\n

\n
\n \n Feeds lag the market. Issuer restrictions and pool checks can stop\n" + ], + [ + " Receive your share of every stock.\n \n
\n
\n \n", + " Receive your share of every stock.\n \n
\n
\n \n" + ], + [ + "

\n
\n
\n \n", + "

\n
\n \n
\n \n" + ], + [ + " (a) => owed[a.token] !== undefined && owed[a.token]! > 0n,\n );\n const visible = [\n ...claimable,\n ...s.assets.filter((a) => owed[a.token] === undefined),\n ];\n // Presentation only: keep the last finished visibility through refreshes.\n // Capture old account values so neither its owed result nor its status can\n // reveal the new account's panel before that account's first read finishes.\n const visit = useRef({ account: w.account, initialOwed: owed, progress: w.progress, error });\n const readStart = useRef(owed);\n useLayoutEffect(() => {\n readStart.current = owed;\n }, [w.account, s.loadedAt, retry]);\n const [display, setDisplay] = useState({ account: w.account, finished: false, shown: false, items: [] as Asset[], count: 0 });\n useLayoutEffect(() => {\n if (visit.current.account !== w.account) {\n visit.current = { account: w.account, initialOwed: owed, progress: w.progress, error };\n setDisplay({ account: w.account, finished: false, shown: false, items: [], count: 0 });\n return;\n }\n if (!w.account) return;\n const finished = !reading && !s.loading && owed !== readStart.current;\n const status = Object.keys(w.progress).some(\n (key) => (key === \"claim-all\" || key.startsWith(\"Claim \")) &&\n w.progress[key] !== visit.current.progress[key],\n );\n const shown = working || (error !== \"\" && error !== visit.current.error) || status ||\n (finished && (!s.complete || visible.length > 0));\n if (finished || shown)\n setDisplay((old) => ({\n account: w.account, finished: old.finished || finished, shown: old.shown || shown,\n items: finished ? visible : old.items, count: finished ? claimable.length : old.count,\n }));\n }, [w.account, owed, reading, s.loading, s.complete, working, error, w.progress]);\n if (!w.account)\n return

Connect the receiver wallet to read and claim its owed stocks.

;\n if (display.account !== w.account || !display.shown)\n return display.account === w.account && display.finished\n ?

Nothing is owed to this wallet.

\n : null;\n return (\n
\n", + " (a) => owed[a.token] !== undefined && owed[a.token]! > 0n,\n );\n const zeros = s.assets.filter((a) => owed[a.token] === 0n);\n const visible = [\n ...claimable,\n ...s.assets.filter((a) => owed[a.token] === undefined),\n ];\n return (\n
\n" + ], + [ + " \n
\n {display.items.map((a) => (\n
\n {a.symbol}\n", + " \n
\n {visible.map((a) => (\n
\n {a.symbol}\n" + ], + [ + " ))}\n
\n {!s.assets.length && (\n

\n", + " ))}\n

\n {zeros.length > 0 && (\n
\n Nothing owed for {zeros.length} other stocks\n

{zeros.map((a) => a.symbol).join(\", \")}

\n
\n )}\n {!s.assets.length && (\n

\n" + ], + [ + "

\n )}\n {display.count > 1 && (\n

\n Claim all collects up to 10 stocks per wallet prompt. If one stock can’t be paid, that batch stays owed: collect the others with their own Claim buttons.\n

\n )}\n \n )}\n", + "

\n )}\n

\n Batches of up to 10 stocks, with one wallet prompt per batch. If one\n stock’s payment fails, the vault reverts that whole batch: nothing is\n lost and every stock in it stays owed. Use each stock’s own Claim\n button to collect the others.\n

\n \n )}\n" + ] + ], + "wallet.tsx": [ + [ + " const disconnected = useRef(savedDisconnected());\n const accountVersion = useRef(0);\n // Advanced by Connect wallet and Disconnect only: a Connect answer that\n // arrives after a later Disconnect is dropped.\n const connectVersion = useRef(0);\n const [connecting, setConnecting] = useState(false);\n const [progress, setProgress] = useState>({});\n", + " const disconnected = useRef(savedDisconnected());\n const accountVersion = useRef(0);\n const [connecting, setConnecting] = useState(false);\n const [progress, setProgress] = useState>({});\n" + ], + [ + " connectingLock.current = true;\n accountVersion.current++;\n const version = ++connectVersion.current;\n setConnecting(true);\n try {\n", + " connectingLock.current = true;\n accountVersion.current++;\n setConnecting(true);\n try {\n" + ], + [ + " method: \"eth_requestAccounts\",\n });\n // Disconnect was pressed while the wallet was asked: it stands.\n if (version !== connectVersion.current) return;\n if (a[0]) {\n disconnected.current = false;\n", + " method: \"eth_requestAccounts\",\n });\n if (a[0]) {\n disconnected.current = false;\n" + ], + [ + " if (sending.current) return;\n accountVersion.current++;\n connectVersion.current++;\n disconnected.current = true;\n try {\n", + " if (sending.current) return;\n accountVersion.current++;\n disconnected.current = true;\n try {\n" + ], + [ + " : \"The transaction was replaced in the wallet by a different one; it did not run. Refresh the vault before retrying.\",\n );\n // After a Speed up the transaction is mined under another hash than the\n // one the wallet returned: from here on the mined one is shown and read.\n const mined = receipt.transactionHash;\n sent = mined;\n setHash(mined);\n if (receipt.status !== \"success\")\n throw new Error(\n \"The transaction reverted. Refresh the data before retrying.\",\n );\n setConfirmed({ hash: mined, functionName: s.functionName });\n report(key, { message: `${label}: Confirmed. Refreshing…`, hash: mined });\n let detail = \"\";\n try {\n", + " : \"The transaction was replaced in the wallet by a different one; it did not run. Refresh the vault before retrying.\",\n );\n if (receipt.status !== \"success\")\n throw new Error(\n \"The transaction reverted. Refresh the data before retrying.\",\n );\n setConfirmed({ hash: tx, functionName: s.functionName });\n report(key, { message: `${label}: Confirmed. Refreshing…`, hash: tx });\n let detail = \"\";\n try {\n" + ], + [ + " \"Refresh unreadable. Retry vault / claims to read the confirmed result.\";\n }\n report(key, { message: `${label}: Confirmed.`, hash: mined, detail });\n return receipt;\n } catch (e) {\n", + " \"Refresh unreadable. Retry vault / claims to read the confirmed result.\";\n }\n report(key, { message: `${label}: Confirmed.`, hash: tx, detail });\n return receipt;\n } catch (e) {\n" + ] + ], + "main.tsx": [ + [ + "import { Activity, useCallback, useEffect, useRef, useState } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { chain, verifyNetwork, VAULT } from \"./chain\";\n", + "import { useCallback, useEffect, useRef, useState } from \"react\";\nimport { createRoot } from \"react-dom/client\";\nimport { chain, verifyNetwork, VAULT } from \"./chain\";\n" + ], + [ + " \n )}\n \n \n \n \n
\n", + " \n )}\n {page === \"Redeem\" && }\n \n
\n" + ] ] }; -// <<< v11 pieces -assert.deepEqual(Object.keys(editedFiles),['Flows.tsx','wallet.tsx','main.tsx'],'the v11 files'); +// <<< v12 pieces +assert.deepEqual(Object.keys(editedFiles),[ + "Vault.tsx", + "components.tsx", + "Docs.tsx", + "Flows.tsx", + "wallet.tsx", + "main.tsx" +],'the v12 files'); 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;}; for(const [f,edits] of Object.entries(editedFiles)) assert.equal(putBack(read('web/src/'+f),edits,f),baseline('web/src/'+f),f+' changed beyond the documented pieces'); // 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']); -// JSX attributes that act, send or check. v11 adds every event handler (on...) and the wallet and snapshot handed to a -// component, so the new Enter handler of the stock search and the wallet each page receives are compared too. +// JSX attributes that act, send or check, including every event handler and the wallet and snapshot handed to a +// component. The full HEAD coverage is retained for every v12 source. const actionAttributes=new Set(['onClick','onSubmit','onChange','getSpec','disabled','disabledReason','label','scope','role','primary','value','claim','reason','build','wallet','snapshot']); const isAction=name=>actionAttributes.has(name)||/^on[A-Z]/.test(name); // The attributes that build or start a transaction: none may be added, removed or changed in any file. @@ -100,8 +184,8 @@ const SEND=new Set(['send','request','sendTransaction','sendRawTransaction','wri 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;}; -// v11: a guarded name is matched without line breaks or optional chaining ("client\n .estimateGas" counts), and every -// method called on the public client (client.anything(...)) is a chain read, so the new receipt read is listed below. +// v12: a guarded name is matched without line breaks or optional chaining ("client\n .estimateGas" counts), and every +// method called on the public client (client.anything(...)) remains guarded, including the existing receipt read. const onClient=c=>{const e=unwrap(c.expression);return (ts.isPropertyAccessExpression(e)||ts.isElementAccessExpression(e))&&ts.isIdentifier(unwrap(e.expression))&&unwrap(e.expression).text==='client';}; const isGuarded=(n,tree)=>ts.isCallExpression(n)&&(names.has(n.expression.getText(tree).replace(/\s+/g,'').replace(/\?\./g,'.'))||onClient(n)||SEND.has(calleeName(n))||/ethereum|(^|[.?\]])\s*(send|request|sendTransaction|sendRawTransaction|writeContract)\b/.test(n.expression.getText(tree))); const parse=(text,file)=>ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS); @@ -134,82 +218,53 @@ function difference(a,b){ return {removed,added}; } // Guarded calls: the exact, documented differences from HEAD, each added call with the place it must sit in -// (everything else equals HEAD, in order and place). v11 removes none and adds the receipt read and the display-only -// amount() in Flows.tsx. Local-only Disconnect adds no guarded call or wallet request. +// (everything equals HEAD, in order and place). The existing receipt read and display-only amount parser are +// already part of HEAD. The v12 connection guard adds no guarded call or wallet request. +// SPEC 1-6 add no guarded call: receipt reading and display amount parsing already belong to HEAD. const expected={ - 'Flows.tsx':{why:'"You received ... BASK": one read of the receipt of the visitor\'s own confirmed deposit (the hash send() reported), to take the minted shares from its Deposit event; it runs after the deposit is confirmed and feeds only that line. The running total parses each typed amount with the page\'s own amount() to show an estimate in dollars; it runs while the form is drawn, outside every handler, and its result goes into no preview, approval or deposit (those still parse inputs[a.token] themselves, as at HEAD).', - removed:[], - added:[['client .getTransactionReceipt({ hash: done.hash })','DepositPage'],['amount(text, false, a.tokenDecimals)','DepositPage']]}, - 'wallet.tsx':{why:'Disconnect forgets the wallet only on this page. It adds no provider request, avoiding a late permission revocation undoing a later Connect in this page or another tab.',removed:[],added:[]}, + "Vault.tsx": { + "removed": [], + "added": [] + }, + "components.tsx": { + "removed": [], + "added": [] + }, + "Docs.tsx": { + "removed": [], + "added": [] + }, + "Flows.tsx": { + "removed": [], + "added": [] + }, + "wallet.tsx": { + "removed": [], + "added": [] + }, + "main.tsx": { + "removed": [], + "added": [] + } }; // Action attributes: the exact differences from HEAD, in page order, each [element, attribute, why]. Everything else // equals HEAD in order, text for text. None of them builds or starts a transaction (no onSubmit, getSpec, build or // claim) and none contains a guarded call (both asserted below). The list between the marker lines was generated // from the difference to HEAD, as the pieces were; each why was written by hand, and an entry without one fails. -// >>> v11 attributes (generated from the difference to HEAD; each why written by hand) +// >>> v12 attributes (generated from the difference to HEAD; each why written by hand) const attributeChanges={ - "Flows.tsx":{ - removed:[ - ["input","value={search}", - "The search box. HEAD showed it above the amount rows, v11 shows it below them, so the same attribute is listed once as removed and once as added."], - ["input","onChange={(e) => setSearch(e.target.value)}", - "The search box (see value={search}): HEAD's handler, moved with the box. It only sets the search text."], - ["button","disabled={working || w.busy}", - "HEAD's rule for a button of the Deposit form: off while a preview, approval or send is running. HEAD had it on its Add buttons and on Preview deposit; v11 has it on Remove, on the Add buttons under the search and on Preview deposit. The comparison pairs equal texts in order, so one use of HEAD shows as removed and two of v11 as added: the rule itself did not change."], - ["button","onClick={() => setInputs((old) => ({ ...old, [a.token]: \"\" }))}", - "HEAD's Add button. Adding a stock is now add(), which makes the same write: an empty amount box for that stock (see the writes in callChanges)."], - ["input","disabled={working || w.busy}", - "HEAD's rule for a text box of the form: off while a preview, approval or send is running. HEAD had it on the amount boxes, which now also go off while their stock is blocked (the added rule with `blocked`); v11 puts it on the search box."], - ["button","disabled={working || w.busy || balances[a.token] === undefined}", - "HEAD's Use full balance. The button is called Max now and also goes off while its stock is blocked (the added rule with `blocked`). Its onClick, which fills in the exact wallet balance, is HEAD's and is in neither list."]], - added:[ - ["button","onClick={w.connect}", - "Connect wallet inside the Deposit form, shown while no wallet is connected. It calls the hook's connect(), the one the header button calls."], - ["button","disabled={w.connecting}", - "That Connect wallet button is off while a connection request is open, like the header button."], - ["input","disabled={working || w.busy || blocked}", - "An amount box: HEAD's rule, and also off while the vault's own check of that stock fails (blocked). It can only switch the box off."], - ["button","disabled={working || w.busy || blocked || balances[a.token] === undefined}", - "Max (HEAD's Use full balance): HEAD's rule, and also off while that stock is blocked. It can only switch the button off."], - ["button","onClick={() => remove(a.token)}", - "Remove takes a stock's row off the form: remove() deletes its typed amount from the inputs (a documented write in callChanges) and remembers the stock as removed. It reads nothing from the chain and sends nothing."], - ["button","disabled={working || w.busy || s.loading}", - "Refresh on a blocked row: off while a preview, approval or send is running or while the vault is being read."], - ["button","onClick={s.retry}", - "Refresh on a blocked row reads the vault again with s.retry, as the Retry vault buttons do. A read; it sends nothing."], - ["button","onClick={() => setShowSmall(!showSmall)}", - "Shows or hides the wallet's stocks worth under one cent. Display only: a row with a typed amount is never hidden."], - ["input","value={search}", - "The search box. HEAD showed it above the amount rows, v11 shows it below them, so the same attribute is listed once as removed and once as added."], - ["input","disabled={working || w.busy}", - "HEAD's rule for a text box of the form: off while a preview, approval or send is running. HEAD had it on the amount boxes, which now also go off while their stock is blocked (the added rule with `blocked`); v11 puts it on the search box."], - ["input","onChange={(e) => setSearch(e.target.value)}", - "The search box (see value={search}): HEAD's handler, moved with the box. It only sets the search text."], - ["input","onKeyDown={(e) => { if (e.key !== \"Enter\") return; e.preventDefault(); if (matches[0]) add(matches[0].token, false); }}", - "Enter in the search box adds the first match with add(), as an Add button does, and never submits the form, so Enter there cannot start a preview. Every other key is left alone."], - ["div","role=\"status\"", - "The matches under the search box are read out by screen readers when they change."], - ["button","disabled={working || w.busy}", - "HEAD's rule for a button of the Deposit form: off while a preview, approval or send is running. HEAD had it on its Add buttons and on Preview deposit; v11 has it on Remove, on the Add buttons under the search and on Preview deposit. The comparison pairs equal texts in order, so one use of HEAD shows as removed and two of v11 as added: the rule itself did not change."], - ["button","onClick={() => add(a.token)}", - "An Add button under the search box, or a ticker in the list of every stock that can be deposited: add() puts an empty amount box for that stock in the form (a documented write in callChanges), takes it off the removed list and empties the search. It reads nothing from the chain and sends nothing."], - ["button","disabled={working || w.busy || rows.some((x) => x.a.token === a.token)}", - "A ticker in the list of every stock that can be deposited: off while a preview, approval or send is running, and while that stock already has a row in the form. It can only switch the button off."], - ["button","onClick={() => add(a.token)}", - "An Add button under the search box, or a ticker in the list of every stock that can be deposited: add() puts an empty amount box for that stock in the form (a documented write in callChanges), takes it off the removed list and empties the search. It reads nothing from the chain and sends nothing."], - ["button","disabled={working || w.busy}", - "HEAD's rule for a button of the Deposit form: off while a preview, approval or send is running. HEAD had it on its Add buttons and on Preview deposit; v11 has it on Remove, on the Add buttons under the search and on Preview deposit. The comparison pairs equal texts in order, so one use of HEAD shows as removed and two of v11 as added: the rule itself did not change."], - ["p","role=\"status\"", - "The line \"You received ... BASK.\" under a confirmed deposit is read out by screen readers."]]}, - "main.tsx":{ - removed:[], - added:[ - ["button","onClick={wallet.disconnect}", - "The Disconnect button beside the address calls the hook's disconnect(), which is pinned word for word in this file."], - ["button","disabled={wallet.busy}", - "Disconnect is off while a transaction is being checked, sent or confirmed."]]} + "Vault.tsx": { + "removed": [], + "added": [ + [ + "svg", + "role=\"img\"", + "The owner-approved Deposit LED sign is an inline SVG image with an accessible Closed label, shown only for a known closure using the existing snapshot status. This role is presentation only and performs no action or read." + ] + ] + } }; -// <<< v11 attributes +// <<< v12 attributes // Every call expression: HEAD's calls, in order and place, are all still there, except the removed ones listed here; // a call that only gained the documented piece inside it (put back before comparing) counts as kept. Every added // call, including display helpers and effects, is independently pinned below, not only the guarded calls. @@ -217,38 +272,77 @@ const attributeChanges={ // depositArgs, redeemArgs, recipient, client, ...) and the state and refs the file had at HEAD (setInputs, setQuote, // setAccount, sequence, sending, ...). Every use of one, a call or the bare name handed on or renamed, equals HEAD in // order and place, apart from the new uses listed here, each printed with the calls chained to it and its place. So -// nothing new reaches the typed amounts, the quote, the balances or the account except by these, and the one new -// chain read is pinned together with everything that is done with its answer. -// Exact startup read callback: ignore its answer after a newer account event, user action or cleanup. -// The provider request expression and method are unchanged. This pair never comes from generated pieces. -const ACCOUNT_REPLY=['p.request({ method: "eth_accounts" }) .then((a) => { if (version === accountVersion.current) accounts(a); })','p.request({ method: "eth_accounts" }) .then(accounts)']; -const restoreAccountReply=(x,file)=>file==='wallet.tsx'&&x.place==='useWallet'?(x.text===ACCOUNT_REPLY[0]?ACCOUNT_REPLY[1]:x.text===ACCOUNT_REPLY[0]+' .catch(() => { })'?ACCOUNT_REPLY[1]+' .catch(() => { })':x.text):x.text; +// nothing new reaches the typed amounts, the quote, the balances or the account except by these. No v12 chain +// read is added; the existing receipt read and answer handling are independently byte-pinned. +// The guarded startup eth_accounts answer is part of HEAD and must remain exact. +const restoreAccountReply=(x)=>x.text; +// Reviewed v12 differences in calls and watched-name uses. Independent of the generated pieces. const callChanges={ - 'Flows.tsx':{why:'The balance read keeps a copy of what it read for the rows (setHeld(b), one added line; the effect and its read are otherwise HEAD\'s). Removed with the old form: the search handler in its old place, the grid of Add buttons with its filter, and the amount rows with their filter and balance text; the new rows, search and Add are added calls. New uses: the receipt read with its whole answer handling (it keeps the vault\'s Deposit event of that receipt and remembers its receiver and shares for the "You received" line, nothing else); Add and Remove change the typed amounts only for the stock pressed and Add empties the search text; the estimate\'s amount(); the search box sets the search text; a confirmed redeem empties the Redeem amount box.', - pieces:[['setBalances(b); setHeld(b); setAllowances(al);','setBalances(b); setAllowances(al);']], - removed:['setSearch(e.target.value)', - 'open .filter((a) => inputs[a.token] === undefined && (a.symbol .toLowerCase() .includes(search.toLowerCase()) || a.token .toLowerCase() .includes(search.toLowerCase()))) .map((a) => ())', - 'open .filter((a) => inputs[a.token] === undefined && (a.symbol .toLowerCase() .includes(search.toLowerCase()) || a.token .toLowerCase() .includes(search.toLowerCase())))', - 'a.symbol .toLowerCase() .includes(search.toLowerCase())','a.symbol .toLowerCase()','search.toLowerCase()', - 'a.token .toLowerCase() .includes(search.toLowerCase())','a.token .toLowerCase()','search.toLowerCase()', - 'setInputs((old) => ({ ...old, [a.token]: "" }))', - 'open .filter((a) => open.length <= 6 || inputs[a.token] !== undefined) .map((a) => (
{balances[a.token] === undefined && (

{!w.account ? "Connect wallet to read your balance." : reading ? "Reading..." : "Balance unreadable. Retry balances below."}

)}
))', - 'open .filter((a) => open.length <= 6 || inputs[a.token] !== undefined)', - 'fmt(balances[a.token], a.tokenDecimals)'], - uses:[['client .getTransactionReceipt({ hash: done.hash }) .then((receipt) => { const event = receipt.logs .filter((l) => same(l.address, VAULT)) .map((l) => { try { return decodeEventLog({ abi: vaultAbi, data: l.data, topics: l.topics, }); } catch { return undefined; } }) .find((l) => l?.eventName === "Deposit"); if (!live || !event) return; const { receiver, shares } = event.args as any; setReceived({ hash: done.hash, receiver, shares }); }) .catch(() => { })','DepositPage'], - ['setInputs((old) => ({ ...old, [t]: "" }))','DepositPage > add'],['setSearch("")','DepositPage > add'], - ['setInputs((old) => { const next = { ...old }; delete next[t]; return next; })','DepositPage > remove'], - ['amount(text, false, a.tokenDecimals)','DepositPage'], - ['setSearch(e.target.value)','DepositPage, in onChange'],['setInput("")','RedeemPage']]}, - 'wallet.tsx':{why:'The handler for the wallet\'s accounts (the first read and accountsChanged) takes no account while the disconnected flag is set; its startup answer is ignored after a newer event, user action or cleanup. The four exact pieces restore HEAD\'s effect and each must occur. New uses, all in disconnect(): it looks at the send lock (and returns while a send is running), forgets the account and the last transaction link and sets the top message.', - pieces:[['const accounts = (a: Address[]) => { accountVersion.current++; if (!disconnected.current) setAccount(a[0]); };','const accounts = (a: Address[]) => setAccount(a[0]);'], - ['const version = accountVersion.current; p.request({ method: "eth_accounts" })','p.request({ method: "eth_accounts" })'], - ACCOUNT_REPLY, - ['return () => { accountVersion.current++; p.removeListener?.("accountsChanged", accounts);','return () => { p.removeListener?.("accountsChanged", accounts);']], - removed:[], - uses:[['sending.current','useWallet > disconnect'],['setAccount(undefined)','useWallet > disconnect'],['setHash(undefined)','useWallet > disconnect'], - ['setMessage("Wallet disconnected from this page. To use another wallet, switch the address in your wallet app, then press Connect wallet.")','useWallet > disconnect']]}, - 'main.tsx':{why:'No call added or removed: the Disconnect button is markup with two attributes.',pieces:[],removed:[],uses:[]}, + "Vault.tsx": { + "why": "SPEC 1 and owner-approved short closure notice: the heading and removed sentence, compact Closed status on Vault, and Closed LED plus one sentence on Deposit. The exact local display tail uses the existing snapshot and settings; original r/reason/clock/closed calculations remain byte-identical. Normal, loading and reopening-boundary states show no notice; unreadable status retains Retry.", + "pieces": [], + "removed": [ + "reasonWords(Number(r.value[0]), s.globals.settings)", + "Number(r.value[0])", + "s.assets.find((a) => a.token.toLowerCase() === r.value[1].toLowerCase())", + "a.token.toLowerCase()", + "r.value[1].toLowerCase()", + "hoursLine(s.globals.settings)" + ], + "uses": [] + }, + "components.tsx": { + "why": "SPEC 1: PageTitle renders no empty paragraph when there are no children.", + "pieces": [], + "removed": [], + "uses": [] + }, + "Docs.tsx": { + "why": "SPEC 2: four approved paragraphs in the existing panel.", + "pieces": [], + "removed": [], + "uses": [] + }, + "Flows.tsx": { + "why": "SPEC 3/4 and owner-approved presentation: direct Deposit paragraphs, the short DepositState sign notice, one-column Redeem, removed redundant wallet instruction lines and Claims presentation. The unused hoursLine import, display calls and r/reason declarations are removed; all transaction builders and reads stay byte-identical.", + "pieces": [], + "removed": [ + "Number(r.value[0])", + "hoursLine(s.globals.settings)", + "w.account.slice(0, 6)", + "w.account.slice(-4)", + "s.assets.filter((a) => owed[a.token] === 0n)", + "visible.map((a) => (
{a.symbol}

Still owed:{\" \"} {reading ? \"Reading...\" : fmt(owed[a.token], a.tokenDecimals)}

vault(\"claim\", [[a.token], recipient(to)])}/>
))", + "zeros.map((a) => a.symbol).join(\", \")", + "zeros.map((a) => a.symbol)" + ], + "uses": [] + }, + "wallet.tsx": { + "why": "SPEC 6: a separate connection intent counter and mined-receipt hash propagation after the protected send head.", + "pieces": [], + "removed": [ + "setConfirmed({ hash: tx, functionName: s.functionName })", + "report(key, { message: `${label}: Confirmed. Refreshing…`, hash: tx })", + "report(key, { message: `${label}: Confirmed.`, hash: tx, detail })" + ], + "uses": [ + [ + "setHash(mined)", + "useWallet > send" + ], + [ + "setConfirmed({ hash: mined, functionName: s.functionName })", + "useWallet > send" + ] + ] + }, + "main.tsx": { + "why": "Owner-approved persistence: React Activity keeps Claims mounted across navigation and pauses its effects while hidden.", + "pieces": [], + "removed": [], + "uses": [] + } }; const WATCHED=[...names].filter(x=>!x.includes('.')).concat('client'); // The state setters and refs a file declares: const [x, setX] = useState(...) gives setX, const r = useRef(...) gives r. @@ -279,10 +373,11 @@ function usesOf(text,file,watched){ // Vault.tsx (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 equal HEAD's and this pinned list, in page order: the Retry vault // button and the vault, Stock Token and price feed address links, plus the shelves' controls (label Details, drawer -// Close and its Escape key, filters, search, Show all), which only change what is shown. v11 does not touch the file; -// the list is longer than in v10 only because every event handler and the snapshot handed to a component now count as -// action attributes. The StockShelves handlers behind those controls are pinned too. -const vaultAttributes=['onClick={s.retry}','snapshot={s}','value={VAULT}','snapshot={s}','onClick={onToggle}','onKeyDown={(e) => e.key === "Escape" && onClose()}','onClick={onClose}','value={a.token}','value={a.feed}','onClose={() => onClose(o.a.token)}','onToggle={() => onToggle(a.token)}','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", "")}']; +// Close and its Escape key, filters, search, Show all), which only change what is shown. SPEC 1 changes the title +// and its sentence; owner-approved deposit presentation adds only the LED image role. Every original control and +// StockShelves handler keeps its original pin. +// Owner-approved LED accessibility role; all original controls keep their exact order and text. +const vaultAttributes=['role="img"','onClick={s.retry}','snapshot={s}','value={VAULT}','snapshot={s}','onClick={onToggle}','onKeyDown={(e) => e.key === "Escape" && onClose()}','onClick={onClose}','value={a.token}','value={a.feed}','onClose={() => onClose(o.a.token)}','onToggle={() => onToggle(a.token)}','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);', @@ -298,8 +393,8 @@ function localDeclarations(file,text,fn,list){ } 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'); -// Every source is either frozen (byte-identical to HEAD) or a v11 file with its documented pieces (above). -assert.deepEqual([...sources].sort(),[...protectedFiles,...Object.keys(editedFiles)].filter(f=>/\.tsx?$/.test(f)).sort(),'every web/src source is frozen or a documented v11 file'); +// Every source is either frozen (byte-identical to HEAD) or a v12 file with its documented pieces (above). +assert.deepEqual([...sources].sort(),[...protectedFiles,...Object.keys(editedFiles)].filter(f=>/\.tsx?$/.test(f)).sort(),'every web/src source is frozen or a documented v12 file'); const scans={},facts={attributes:{},guarded:{},calls:{}}; for(const f of sources){ const old=calls(baseline('web/src/'+f),f),now=calls(read('web/src/'+f),f),c=callChanges[f]??{pieces:[],removed:[],uses:[]},used=c.pieces.map(()=>0); @@ -323,13 +418,25 @@ if(process.env.BASKET_GATE_FACTS)fs.writeFileSync(process.env.BASKET_GATE_FACTS, // includes these pins. Changing a callback, receiver, argument, location, order or count changes the digest. // >>> reviewed calls (manual; never refreshed by put-pieces) const addedCallPins={ + "Vault.tsx": { + "sha256": "4f11db85cbd213b7a60ff309a42fc122b75f2408685534c1596c381656a0347d", + "callees": "toUpperCase flatMap flatMap map some split reasonWords" + }, + "components.tsx": { + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "callees": "" + }, + "Docs.tsx": { + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "callees": "" + }, "Flows.tsx": { - "sha256": "2a444f9a1180a8f5e693d9ae7db5e8b4efd2899e4e6aab2724bfee440d741d8c", - "callees": "useState useState useState useState useRef useLayoutEffect setRemoved useLayoutEffect setHeld setRemoved useLayoutEffect getElementById focus closest focus useEffect catch then getTransactionReceipt find map filter same decodeEventLog setReceived setHeld Number BigInt usd map worth kept filter kept onScreen sort filter onScreen filter onScreen every some toLowerCase trim toLowerCase startsWith includes includes toLowerCase sort map filter onScreen rank rank rank sort toLowerCase toLowerCase setRemoved setInputs setSearch closest getElementById contains focus focus querySelector setRemoved setInputs trim worth amount push push hoursLine slice slice some map fmt about remove reasonWords setShowSmall setSearch preventDefault add map slice add some rank toLowerCase map some add about join join same fmt useLayoutEffect setInput" + "sha256": "a300e32681affcedb9a06c8a18f5f3278b503113fe7752c4b7f6705fcefc19f1", + "callees": "useRef useRef useLayoutEffect useState useLayoutEffect setDisplay some keys startsWith setDisplay map" }, "wallet.tsx": { - "sha256": "76209004bfb699f033e9ba9e7e42a5b08029064f660c535033933e46caf4cdb8", - "callees": "getItem useRef savedDisconnected useRef accounts removeItem setItem setAccount setHash setMessage" + "sha256": "4aa092bccb8c851943d0ea97214d6e4be2efb19245eea9383a4a4380efdf23c8", + "callees": "useRef setHash setConfirmed report report" }, "main.tsx": { "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", @@ -337,6 +444,15 @@ const addedCallPins={ } }; // <<< reviewed calls +// SPEC 1-6 and the owner's mounting decision: exact reviewed source, beyond either generated block. +const reviewedSourcePins={ + "Vault.tsx": "d8936edd240c7571e4e65bdf878f65bea73b889c5541873f8fe2b128633a7582", + "components.tsx": "d9ae83c4ad85ca3762e24914efd4669e22916bb5a2e29bbfd778bcdec5041730", + "Docs.tsx": "5f24a982beba7124f0af8e06496b5e64f79b052978ff37785ff6700279f984c5", + "Flows.tsx": "3b380cc8bcdf5d2cc81ed46fef7274e00a0ac83025200cdc046bf8fb18c49e3b", + "wallet.tsx": "488b62c80121b3302ea8cbdac7d23e0a32d8fc874082dfab6499388949b083de", + "main.tsx": "2745de3906186f90b599d7b3675d602be6cc5ed0ed055813de8b4dd7b0f0e04e" +}; for(const f of Object.keys(editedFiles)){ const {now,all}=scans[f],list=all.added.map(i=>[now.all[i].text,now.all[i].place]); assert.equal(all.added.map(i=>now.all[i].callee).join(' '),addedCallPins[f]?.callees,f+': added-call order or count differs from its reviewed list'); @@ -344,7 +460,7 @@ for(const f of Object.keys(editedFiles)){ } // Direct writes and mutating methods on transaction state, including aliases. A separate check is needed because // an assignment or delete can change inputs without adding a call. Compare the full ordered writes with HEAD; -// v11 adds none. Shallow copies have fresh containers but can still hold references to an original nested object. +// v12 adds none to these roots. Shallow copies have fresh containers but can still hold references to an original nested object. function stateWrites(text,file){ const tree=parse(text,file),print=printerOf(tree),host={getSourceFile:n=>n===file?tree:undefined,writeFile(){},getCurrentDirectory:()=>'',getDirectories:()=>[],fileExists:n=>n===file,readFile:n=>n===file?text:undefined,getCanonicalFileName:n=>n,useCaseSensitiveFileNames:()=>true,getNewLine:()=> '\n',getDefaultLibFileName:()=>''}; const checker=ts.createProgram([file],{noLib:true,noResolve:true},host).getTypeChecker(),aliases=new Map(),roots=new Set(['inputs','open','quote','balances','allowances','s']),out=[]; @@ -445,13 +561,13 @@ for(const f of sources){ } counts[f]={guarded:now.found.length,calls:now.all.length,attributes:now.attributes.length}; } -for(const f of [...Object.keys(expected),...Object.keys(attributeChanges),...Object.keys(callChanges)])assert.ok(f in editedFiles,f+': a documented difference in a file that is not a v11 file'); +for(const f of [...Object.keys(expected),...Object.keys(attributeChanges),...Object.keys(callChanges)])assert.ok(f in editedFiles,f+': a documented difference in a file that is not a v12 file'); // A6 G-A: wallet sends and raw requests. Listed in order per file, each with its place: 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 with any key (window[...], also window["fetch"]); eval, -// Function, Reflect; and (new in v11) the names by which a page reaches the network by itself (fetch, +// Function, Reflect; and the retained scan of names by which a page reaches the network by itself (fetch, // XMLHttpRequest, WebSocket, EventSource, sendBeacon, importScripts, a dynamic import), written as a name or as a -// plain string (folded across +). Every file equals HEAD after the one exact startup reply guard below is restored. +// plain string (folded across +). Every file equals HEAD directly, including the startup reply guard already present there. // Disconnect is local-only and must have no provider request of its own. 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','fetch','XMLHttpRequest','WebSocket','EventSource','sendBeacon','importScripts']),GLOBAL=new Set(['window','globalThis','self','top','parent','frames']); @@ -472,10 +588,10 @@ function sendsOf(text,file){ 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); - // Only the answer handling of the existing startup read changes, exactly once in its original scope. - // The request itself is also compared directly by guarded calls and named methods, without any exception. + // The startup request, its answer handling and all provider uses already belong to HEAD and remain exact. + // Guarded calls and named methods independently compare the requests without an exception. const restored=n.map(x=>({...x,text:restoreAccountReply(x,f)})); - if(f==='wallet.tsx')assert.equal(n.filter(x=>x.place==='useWallet'&&x.text===ACCOUNT_REPLY[0]+' .catch(() => { })').length,1,'wallet.tsx: one exact guarded startup reply'); + if(f==='wallet.tsx')assert.deepEqual(n,h,'wallet.tsx: provider uses including the guarded startup reply equal HEAD'); assert.deepEqual(restored,h,f+': a wallet send, provider request, signing method or network call was added, removed, changed or moved'); if(f==='wallet.tsx')assert.deepEqual(n.filter(x=>x.place==='useWallet > disconnect'),[],'wallet.tsx disconnect(): no provider request'); if(NO_SEND.includes(f))assert.ok(n.every(x=>x.text==='window.ethereum'),f+' must contain no send or request (only the read of window.ethereum in main.tsx)'); @@ -483,11 +599,11 @@ for(const f of sources){ } // 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'); -// v11: what a page can load or open without any of those names. Listed in order per file, each with its place: the +// v12: what a page can load or open without any of those names. Listed in order per file, each with its place: the // names in LOADS, written as a name, a property or a plain string (folded across +): a picture, sound or worker made // in script; the address bar, the history and the navigator; a new window; an element or HTML built in script; a // src, href, action or style set in script. Then every JSX attribute in LOAD_ATTRIBUTES (any letter case) with its -// whole value, and every spread of attributes onto an element. Every file equals HEAD, with no exception: v11 adds no +// whole value, and every spread of attributes onto an element. Every file equals HEAD, with no exception: v12 adds no // link, picture, frame, style attribute or navigation. `open` alone is the list of open stocks in Flows.tsx, so it // counts only as a property (window.open) or when it is called. This is a list of known ways, not a proof that no // other way exists. @@ -506,13 +622,16 @@ function loadsOf(text,file){ })(tree); return out; } +// The retained scanner treats the asset flag a.open as a load name. This one new display-only +// entry belongs to the independently raw-pinned operational declaration; no network sink is exempted. +const loadDisplayAdded={'Vault.tsx':[{text:'a.open',place:'DepositState'}]}; const loadUses={}; for(const f of sources){ const h=loadsOf(baseline('web/src/'+f),f),n=loadsOf(read('web/src/'+f),f); - assert.deepEqual(n,h,f+': something a page loads or opens (a picture, frame, worker or element made in script, a navigation, a link, a style attribute) was added, removed, changed or moved'); + assert.deepEqual(n,[...(loadDisplayAdded[f]??[]),...h],f+': something a page loads or opens (a picture, frame, worker or element made in script, a navigation, a link, a style attribute) was added, removed, changed or moved'); if(n.length)loadUses[f]=n.length; } -// v11: the wallet and RPC methods the pages name. Every `method` given as a property (or assigned) in web/src, in order +// v12: the wallet and RPC methods the pages name. Every `method` given as a property (or assigned) in web/src, in order // with its place: exactly HEAD's list. Local-only Disconnect names no new wallet or RPC method. function methodsOf(text,file){ const tree=parse(text,file),print=printerOf(tree),out=[],key=n=>ts.isIdentifier(n)||ts.isStringLiteralLike(n)?n.text:ts.isComputedPropertyName(n)?lit(n.expression):undefined; @@ -532,8 +651,8 @@ for(const f of sources){ assert.deepEqual(n,h,f+': a wallet or RPC method was added, removed, changed or moved'); for(const x of n)methods[x.method]=(methods[x.method]||0)+1; } -// v11: what the pages keep in the browser. Every use of localStorage, sessionStorage, indexedDB, cookie or caches, in -// order: HEAD's, plus the three uses of the remembered "disconnected" flag in wallet.tsx (read once at start, cleared +// v12: what the pages keep in the browser. Every use of localStorage, sessionStorage, indexedDB, cookie or caches, in +// order: HEAD's, including the three uses of the remembered "disconnected" flag in wallet.tsx (read once at start, cleared // when Connect wallet is answered with an account, set by Disconnect). Its key is pinned with the names below. const STORE=new Set(['localStorage','sessionStorage','indexedDB','cookie','caches']); function storageOf(text,file){ @@ -541,7 +660,7 @@ function storageOf(text,file){ (function v(n){if(ts.isIdentifier(n)&&STORE.has(n.text))out.push({text:print(top(n)),place:place(n,tree)});ts.forEachChild(n,v);})(tree); return out; } -const storageAdded={'wallet.tsx':[['localStorage.getItem(disconnectedKey)','savedDisconnected'],['localStorage.removeItem(disconnectedKey)','useWallet > connect'],['localStorage.setItem(disconnectedKey, "1")','useWallet > disconnect']]}; +const storageAdded={}; for(const f of sources){ const h=storageOf(baseline('web/src/'+f),f),n=storageOf(read('web/src/'+f),f),d=difference(h.map(x=>x.text+' @ '+x.place),n.map(x=>x.text+' @ '+x.place)); assert.deepEqual({removed:d.removed.map(i=>h[i].text),added:d.added.map(i=>[n[i].text,n[i].place])},{removed:[],added:storageAdded[f]??[]},f+': browser storage is used differently from HEAD beyond the documented flag'); @@ -559,36 +678,70 @@ function sameFunction(f,name,what){ assert.deepEqual(n,h,f+' '+name+'() changed ('+what+')'); pinnedFunctions[f+' '+name+'()']={equalToHead:true,length:n[0].length,sha256:sha256(n[0])}; } -// wallet.tsx send(), the only function that asks the wallet for a transaction: printed without comments it equals -// HEAD (v11 documents no piece in it), so from, to, data, gas (estimate x 1.3), nonce, the chain and account -// rechecks, the simulation and the eth_sendTransaction request are unchanged. There is one function of that name. -sameFunction('wallet.tsx','send','v11 changes nothing in it'); -// Flows.tsx approveAll(), the approvals before a deposit: equal to HEAD, so each approval is still -// token(t, "approve", [VAULT, n]) with n the previewed amount of that stock, after the same balance and allowance reads. -sameFunction('Flows.tsx','approveAll','v11 changes nothing in it'); -// wallet.tsx disconnect(), the whole local-only function, word for word. It returns at once while a send is running, -// forgets the account and last transaction link on this page and sets the flag. It makes no provider request and -// changes no chain id, no pending transaction and nothing a send reads but the account. -const DISCONNECT="function disconnect() { if (sending.current) return; accountVersion.current++; disconnected.current = true; try { localStorage.setItem(disconnectedKey, \"1\"); } catch { } setAccount(undefined); setHash(undefined); setMessage(\"Wallet disconnected from this page. To use another wallet, switch the address in your wallet app, then press Connect wallet.\"); }"; -assert.deepEqual(functionsNamed(baseline('web/src/wallet.tsx'),'wallet.tsx','disconnect'),[],'wallet.tsx: no disconnect() at HEAD'); -assert.deepEqual(functionsNamed(read('web/src/wallet.tsx'),'wallet.tsx','disconnect'),[DISCONNECT],'wallet.tsx disconnect() differs from the pinned text'); -pinnedFunctions['wallet.tsx disconnect()']={pinnedText:true,length:DISCONNECT.length,sha256:sha256(DISCONNECT)}; -// Connect keeps HEAD's complete function after these two exact intent changes: invalidate the startup read, -// and clear the saved Disconnect flag only when the explicit account request returns an account. -const CONNECT_PIECES=[['connectingLock.current = true; accountVersion.current++;','connectingLock.current = true;'], - ['if (a[0]) { disconnected.current = false; try { localStorage.removeItem(disconnectedKey); } catch { } } setAccount(a[0]);','setAccount(a[0]);']]; -const connectNow=functionsNamed(read('web/src/wallet.tsx'),'wallet.tsx','connect'); -assert.equal(connectNow.length,1,'wallet.tsx: one connect()'); -assert.deepEqual(connectNow.map(x=>putBack(x,CONNECT_PIECES,'wallet.tsx connect()')),functionsNamed(baseline('web/src/wallet.tsx'),'wallet.tsx','connect'),'wallet.tsx connect(): only the two reviewed intent changes'); -pinnedFunctions['wallet.tsx connect()']={pinnedText:true,length:connectNow[0].length,sha256:sha256(connectNow[0])}; -// Pin every mention of the new generation ref, including its declaration and complete containing statement. -// This catches a removed increment, changed guard, alias or extra assignment after either regeneration mode. -const accountIntentUses=[]; +// SPEC 6: compare the send head with HEAD itself byte for byte, including comments and whitespace. +function rawFunction(text,file,name){ + const tree=parse(text,file),found=[]; + (function visit(n){if(ts.isFunctionDeclaration(n)&&n.name?.text===name)found.push(n);ts.forEachChild(n,visit);})(tree); + assert.equal(found.length,1,file+': one raw '+name+'()'); + return {tree,node:found[0],text:text.slice(found[0].getStart(tree),found[0].end)}; +} +function sendParts(text){ + const f=rawFunction(text,'wallet.tsx','send'),wait=[]; + (function visit(n){if(ts.isVariableStatement(n)&&n.declarationList.declarations.some(d=>ts.isIdentifier(d.name)&&d.name.text==='receipt'&&d.initializer&&d.initializer.getText(f.tree).startsWith('await client.waitForTransactionReceipt(')))wait.push(n);ts.forEachChild(n,visit);})(f.node); + assert.equal(wait.length,1,'wallet.tsx send(): one receipt wait statement'); + return {head:text.slice(f.node.getStart(f.tree),wait[0].end),tail:text.slice(wait[0].end,f.node.end)}; +} +const headSend=sendParts(baseline('web/src/wallet.tsx')),nowSend=sendParts(read('web/src/wallet.tsx')); +assert.equal(nowSend.head,headSend.head,'wallet.tsx send(): byte-identical head through receipt wait'); +// SPEC 6: mined hash, sent error-link hash, top link, confirmation record and both Confirmed reports. +// These exact replacements are independent of both regeneration blocks and each must occur exactly once. +const SEND_TAIL_PIECES=[ + [' // After a Speed up the transaction is mined under another hash than the\n // one the wallet returned: from here on the mined one is shown and read.\n const mined = receipt.transactionHash;\n sent = mined;\n setHash(mined);\n',''], + ['setConfirmed({ hash: mined, functionName: s.functionName });','setConfirmed({ hash: tx, functionName: s.functionName });'], + ['report(key, { message: `${label}: Confirmed. Refreshing…`, hash: mined });','report(key, { message: `${label}: Confirmed. Refreshing…`, hash: tx });'], + ['report(key, { message: `${label}: Confirmed.`, hash: mined, detail });','report(key, { message: `${label}: Confirmed.`, hash: tx, detail });'], +]; +assert.equal(putBack(nowSend.tail,SEND_TAIL_PIECES,'wallet.tsx send() tail'),headSend.tail,'wallet.tsx send(): only exact receipt-hash tail replacements'); +pinnedFunctions['wallet.tsx send()']={headEqualToHead:true,headBytes:Buffer.byteLength(nowSend.head,'utf8'),headSha256:sha256(nowSend.head),tailPieces:SEND_TAIL_PIECES.map(([now,head])=>({now,head}))}; +// An explicit ordered pin of the changed transaction-status statements, independent of the piece list. +const sendTailWrites=[]; {const tree=parse(read('web/src/wallet.tsx'),'wallet.tsx'),print=printerOf(tree); - (function visit(n){if(ts.isIdentifier(n)&&n.text==='accountVersion'){let statement=n;while(statement.parent&&!ts.isStatement(statement))statement=statement.parent;accountIntentUses.push([print(statement),place(n,tree)]);}ts.forEachChild(n,visit);})(tree);} -assert.deepEqual(accountIntentUses,[['const accountVersion = useRef(0);','useWallet'],['accountVersion.current++;','useWallet'],['const version = accountVersion.current;','useWallet'],['if (version === accountVersion.current) accounts(a);','useWallet'],['accountVersion.current++;','useWallet'],['accountVersion.current++;','useWallet > connect'],['accountVersion.current++;','useWallet > disconnect']],'wallet.tsx: the exact startup account intent guard'); -// What the wallet hook hands to the pages: HEAD's list with `disconnect` added. `send` is handed over by its own name, -// so the pages still call the function pinned above (the names check below finds one `send` in the hook). + (function visit(n){ + if(place(n,tree)==='useWallet > send'&&ts.isExpressionStatement(n)){ + const t=print(n); + if(t==='sent = mined;'||t==='setHash(mined);'||t.startsWith('setConfirmed(')||t.startsWith('report(key, { message: `${label}: Confirmed.'))sendTailWrites.push(t); + } + ts.forEachChild(n,visit); + })(tree);} +assert.deepEqual(sendTailWrites,[ + 'sent = mined;', + 'setHash(mined);', + 'setConfirmed({ hash: mined, functionName: s.functionName });', + 'report(key, { message: `${label}: Confirmed. Refreshing…`, hash: mined });', + 'report(key, { message: `${label}: Confirmed.`, hash: mined, detail });', +],'wallet.tsx: exact ordered mined-hash state writes'); +sameFunction('Flows.tsx','approveAll','SPEC 3/4 change presentation only'); +const CONNECT="async function connect() { if (connectingLock.current) return; connectingLock.current = true; accountVersion.current++; const version = ++connectVersion.current; setConnecting(true); try { if (!window.ethereum) throw new Error(\"Open this page in your wallet app's browser\"); const a = await window.ethereum.request({ method: \"eth_requestAccounts\", }); if (version !== connectVersion.current) return; if (a[0]) { disconnected.current = false; try { localStorage.removeItem(disconnectedKey); } catch { } } setAccount(a[0]); setChainId(Number(await window.ethereum.request({ method: \"eth_chainId\" }))); setMessage(\"\"); } catch (e) { setMessage(explain(e)); } finally { connectingLock.current = false; setConnecting(false); } }"; +const DISCONNECT="function disconnect() { if (sending.current) return; accountVersion.current++; connectVersion.current++; disconnected.current = true; try { localStorage.setItem(disconnectedKey, \"1\"); } catch { } setAccount(undefined); setHash(undefined); setMessage(\"Wallet disconnected from this page. To use another wallet, switch the address in your wallet app, then press Connect wallet.\"); }"; +for(const [name,value] of [['connect',CONNECT],['disconnect',DISCONNECT]]){ + assert.deepEqual(functionsNamed(read('web/src/wallet.tsx'),'wallet.tsx',name),[value],'wallet.tsx '+name+'() differs from the pinned v12 text'); + pinnedFunctions['wallet.tsx '+name+'()']={pinnedText:true,length:value.length,sha256:sha256(value)}; +} +// The existing startup guard stays exactly as at HEAD. A separate counter rejects a late Connect reply. +function intentStatements(text,name){ + const tree=parse(text,'wallet.tsx'),print=printerOf(tree),found=[]; + (function visit(n){if(ts.isIdentifier(n)&&n.text===name){let statement=n;while(statement.parent&&!ts.isStatement(statement))statement=statement.parent;found.push([print(statement),place(n,tree)]);}ts.forEachChild(n,visit);})(tree); + return found; +} +const accountIntentUses=intentStatements(read('web/src/wallet.tsx'),'accountVersion'); +assert.deepEqual(accountIntentUses,intentStatements(baseline('web/src/wallet.tsx'),'accountVersion'),'wallet.tsx: accountVersion guard equals HEAD'); +assert.deepEqual(accountIntentUses,[['const accountVersion = useRef(0);','useWallet'],['accountVersion.current++;','useWallet'],['const version = accountVersion.current;','useWallet'],['if (version === accountVersion.current) accounts(a);','useWallet'],['accountVersion.current++;','useWallet'],['accountVersion.current++;','useWallet > connect'],['accountVersion.current++;','useWallet > disconnect']],'wallet.tsx: exact startup account intent guard'); +assert.deepEqual(intentStatements(read('web/src/wallet.tsx'),'connectVersion'),[ + ['const connectVersion = useRef(0);','useWallet'], + ['const version = ++connectVersion.current;','useWallet > connect'], + ['if (version !== connectVersion.current) return;','useWallet > connect'], + ['connectVersion.current++;','useWallet > disconnect'], +],'wallet.tsx: the separate counter advances only in connect/disconnect and is read only in connect'); function hookReturn(text){ const tree=parse(text,'wallet.tsx'),print=printerOf(tree),hook=tree.statements.filter(st=>ts.isFunctionDeclaration(st)&&st.name?.text==='useWallet'); assert.equal(hook.length,1,'wallet.tsx: one useWallet'); @@ -596,13 +749,63 @@ function hookReturn(text){ assert.equal(returns.length,1,'wallet.tsx useWallet: one return'); return print(returns[0]); } -const HOOK_RETURN_PIECE=['connect, disconnect, switchChain,','connect, switchChain,']; -assert.equal(putBack(hookReturn(read('web/src/wallet.tsx')),[HOOK_RETURN_PIECE],'wallet.tsx useWallet return'),hookReturn(baseline('web/src/wallet.tsx')),'wallet.tsx: the hook returns something other than HEAD\'s list plus disconnect'); -// Names. In the v11 files every name HEAD declares at the top of the file (imports included) and at the top of each +assert.equal(hookReturn(read('web/src/wallet.tsx')),hookReturn(baseline('web/src/wallet.tsx')),'wallet.tsx: hook returns HEAD\'s unchanged list'); +// SPEC protected code: raw bytes, including entire buttons and effects, independent of generated differences. +const protectedCode={sendTailWrites}; +function rawProtected(label,file,select,count=1){ + const list=text=>{const tree=parse(text,file),found=[];(function visit(n){if(select(n,tree))found.push(text.slice(n.getStart(tree),n.end));ts.forEachChild(n,visit);})(tree);return found;}; + const h=list(baseline('web/src/'+file)),n=list(read('web/src/'+file)); + assert.equal(h.length,count,label+': protected baseline count'); + assert.deepEqual(n,h,label+': bytes differ from HEAD'); + protectedCode[label]=h.map(x=>({bytes:Buffer.byteLength(x,'utf8'),sha256:sha256(x)})); +} +for(const name of ['switchChain','listingPending','waitPending'])rawProtected('wallet '+name,'wallet.tsx',n=>ts.isFunctionDeclaration(n)&&n.name?.text===name); +// Owner-approved issue-only notice and Deposit-only Closed LED: only these exact pieces may differ. +// Every original calculation, status branch, clock use and Retry handler restores byte-for-byte to HEAD. +const DEPOSIT_STATE_PIECES=[ + [ + "export function DepositState({ snapshot: s, showSign = false }: { snapshot: Snapshot; showSign?: boolean }) {", + "export function DepositState({ snapshot: s }: { snapshot: Snapshot }) {" + ], + [ + "\n // The owner wants a notice only for closed deposits or an unreadable status.\n const statusUnreadable =\n !s.complete || !r?.ok || s.globals.genesisFinalized === undefined;\n const operational =\n !statusUnreadable && reason === 0 && s.globals.genesisFinalized === true &&\n s.assets.some((a) => a.open && !a.retired);\n if (s.loading || operational || closed === mayBeOpen) return null;\n const issueReason = s.globals.genesisFinalized === false ? 1\n : !s.assets.length ? 4\n : s.assets.every((a) => !a.open || a.retired) ? 5\n : reason === 13 ? 9 : reason;\n const issue = reasonWords(issueReason, s.globals.settings).split(\" (\")[0];\n const weekendHours = reason === 3 &&\n s.globals.settings?.hoursFrom === 72000n && s.globals.settings?.hoursTo === 504000n;\n return (\n
\n {statusUnreadable ? (\n <>\n {!showSign && Vault status:}\n

unreadable — Retry vault

\n \n \n ) : showSign ? (\n <>\n \n

{weekendHours\n ? \"Deposits reopen when the stock feed prices update around 8pm on Sundays.\"\n : `Deposits will be back open when “${issue}” is resolved.`}

\n \n ) : (\n <>Vault status:

Closed

\n )}\n
\n );\n}", + "\n return (\n
\n Vault deposit status\n

\n {s.loading\n ? \"Reading...\"\n : !s.assets.length &&\n s.complete &&\n s.globals.genesisFinalized === false\n ? \"No Stock Tokens are listed yet. Deposits start after the owner finishes setup.\"\n : s.assets.length > 0 &&\n s.assets.every((a) => !a.open || a.retired) &&\n s.globals.genesisFinalized\n ? \"Every stock is closed. The owner must reopen a stock before deposits.\"\n : !r || !r.ok\n ? \"unreadable — Retry vault\"\n : reasonWords(Number(r.value[0]), s.globals.settings)}\n

\n {r?.ok && r.value[1] !== zeroAddress && (\n

\n Stock at fault:{\" \"}\n {s.assets.find(\n (a) => a.token.toLowerCase() === r.value[1].toLowerCase(),\n )?.symbol ?? r.value[1]}\n

\n )}\n {closed &&

{closed}

}\n

Hours: {s.loading ? \"Reading...\" : hoursLine(s.globals.settings)}

\n {!s.loading && (!r || !r.ok) && (\n \n )}\n
\n );\n}" + ] +]; +const headDepositState=rawFunction(baseline('web/src/Vault.tsx'),'Vault.tsx','DepositState').text; +const nowDepositState=rawFunction(read('web/src/Vault.tsx'),'Vault.tsx','DepositState').text; +assert.equal(putBack(nowDepositState,DEPOSIT_STATE_PIECES,'Vault DepositState'),headDepositState,'Vault DepositState: only exact owner-approved issue-only presentation pieces'); +protectedCode['Vault DepositState']={headBytes:Buffer.byteLength(headDepositState,'utf8'),headSha256:sha256(headDepositState),pieces:DEPOSIT_STATE_PIECES}; +// These independent raw pins bind the reviewed local LED renderer and its glyph data outside regeneration. +const depositSignPins={DepositLedSign:"5261668a5c55b74287e3dc342168e9176be06b3c7bbd46b0ae98106dc733cf29",DEPOSIT_LETTERS:"4942959fd3db1936945a38cf0bb15be49da43088af5d080c224a19d8e33e44fd"}; +for(const [name,digest] of Object.entries(depositSignPins)){ + const text=read('web/src/Vault.tsx'),tree=parse(text,'Vault.tsx'); + const found=tree.statements.filter(n=>(ts.isFunctionDeclaration(n)&&n.name?.text===name)||(ts.isVariableStatement(n)&&n.declarationList.declarations.some(d=>ts.isIdentifier(d.name)&&d.name.text===name))); + assert.equal(found.length,1,'Vault: one top-level '+name); + assert.equal(sha256(text.slice(found[0].getStart(tree),found[0].end)),digest,'Vault: exact reviewed '+name); +} +const depositStateUses=[]; +for(const f of sources){const tree=parse(read('web/src/'+f),f),print=printerOf(tree);(function visit(n){if((ts.isJsxSelfClosingElement(n)||ts.isJsxOpeningElement(n))&&n.tagName.getText(tree)==='DepositState')depositStateUses.push([f,place(n,tree),print(n)]);ts.forEachChild(n,visit);})(tree);} +assert.deepEqual(depositStateUses,[['Flows.tsx','DepositPage',''],['Vault.tsx','VaultPage','']],'DepositState: the approved sign is enabled only on Deposit'); +protectedCode.depositSign={rawPins:depositSignPins,uses:depositStateUses}; +rawProtected('Flows approveAll','Flows.tsx',n=>ts.isFunctionDeclaration(n)&&n.name?.text==='approveAll'); +rawProtected('Flows onSubmit','Flows.tsx',n=>ts.isJsxAttribute(n)&&n.name.getText()==='onSubmit',2); +rawProtected('Flows getSpec','Flows.tsx',n=>ts.isJsxAttribute(n)&&n.name.getText()==='getSpec',3); +rawProtected('Flows transaction buttons','Flows.tsx',n=>ts.isJsxSelfClosingElement(n)&&n.tagName.getText()==='TxButton',3); +rawProtected('Claims Claim all handler','Flows.tsx',(n,t)=>ts.isJsxAttribute(n)&&n.name.getText(t)==='onClick'&&n.getText(t).includes('claimable.slice')); +const effect=(component,needle)=>(n,t)=>ts.isCallExpression(n)&&['useEffect','useLayoutEffect'].includes(n.expression.getText(t))&&place(n,t)===component&&n.getText(t).includes(needle); +rawProtected('Claims owed read','Flows.tsx',effect('Claims','vault("owed"')); +rawProtected('Deposit simulate effect','Flows.tsx',effect('DepositPage','simulate(')); +rawProtected('Deposit confirmed reset','Flows.tsx',effect('DepositPage','setInputs({})')); +rawProtected('Deposit receipt read','Flows.tsx',effect('DepositPage','.getTransactionReceipt(')); +for(const name of ['short','approvals'])rawProtected('Deposit '+name,'Flows.tsx',(n,t)=>ts.isVariableDeclaration(n)&&ts.isIdentifier(n.name)&&n.name.text===name&&place(n,t)==='DepositPage'); + + +// Names. In the v12 files every name HEAD declares at the top of the file (imports included) and at the top of each // component or hook (parameters included) is still declared there once, by the same text: the same import, the same // useState or useRef, the same expression. So `vault`, `token`, `amount`, `depositArgs`, `VAULT`, `quote`, `inputs`, // `open`, `approvals`, `account` and every other name the pinned transaction code uses still mean what they meant at -// HEAD. The names v11 adds at the top of a file are listed here; a name v11 adds at the top of a component or hook +// HEAD. The names v12 adds at the top of a file are listed here; a name v12 adds at the top of a component or hook // must be new there and must not hide a name declared at the top of the file. function scopes(text,file){ const tree=parse(text,file),print=printerOf(tree),module=Object.create(null),functions=Object.create(null); @@ -622,7 +825,7 @@ function scopes(text,file){ for(const st of tree.statements)if(ts.isFunctionDeclaration(st)&&st.name&&st.body){const map=functions[st.name.text]=Object.create(null);for(const p of st.parameters)bound(p.name,name=>put(map,name,'parameter '+print(p)));level(st.body.statements,map);} return {module,functions}; } -// Scoping. The transaction code of a v11 file is send(), approveAll() and every attribute that builds or starts a +// Scoping. The transaction code of a v12 file is send(), approveAll() and every attribute that builds or starts a // transaction or holds a guarded call. A name used in it is declared inside that code itself, at the top of its // component or hook, at the top of the file, or by something wrapped around the code in between (as the `a` of a // list's map() is at HEAD). This lists every name of the last kind with its declaration; the list must equal HEAD's, @@ -664,30 +867,148 @@ for(const f of Object.keys(editedFiles)){ transactionCode[f]={pieces:n.count,namesDeclaredAroundThem:n.names}; } const namesAdded={ - // decodeEventLog, client and vaultAbi read and decode the receipt of the confirmed deposit ("You received"), - // reasonWords says why a row is blocked, hoursLine writes the hours in the one-line status; Hex and Asset are types. - 'Flows.tsx':{decodeEventLog:'import decodeEventLog from "viem"',Hex:'import type Hex from "viem"',client:'import client from "./chain"',vaultAbi:'import vaultAbi from "./chain"',reasonWords:'import reasonWords from "./chain"',Asset:'import type Asset from "./model"',hoursLine:'import hoursLine from "./Vault"'}, - // The key of the remembered flag and the function that reads it once when the page starts. - 'wallet.tsx':{disconnectedKey:'disconnectedKey = "basket-disconnected-4663"',savedDisconnected:'function'}, - 'main.tsx':{}, + "Vault.tsx": { + "DEPOSIT_LETTERS": "DEPOSIT_LETTERS: Record = { O: [\"01110\", \"10001\", \"10001\", \"10001\", \"10001\", \"10001\", \"01110\"], E: [\"11111\", \"10000\", \"10000\", \"11110\", \"10000\", \"10000\", \"11111\"], C: [\"01111\", \"10000\", \"10000\", \"10000\", \"10000\", \"10000\", \"01111\"], L: [\"10000\", \"10000\", \"10000\", \"10000\", \"10000\", \"10000\", \"11111\"], S: [\"01111\", \"10000\", \"10000\", \"01110\", \"00001\", \"00001\", \"11110\"], D: [\"11110\", \"10001\", \"10001\", \"10001\", \"10001\", \"10001\", \"11110\"], }", + "DepositLedSign": "function" + }, + "components.tsx": {}, + "Docs.tsx": {}, + "Flows.tsx": {}, + "wallet.tsx": {}, + "main.tsx": { + "Activity": "import Activity from \"react\"" + } +}; +// Exact removal of the unused display-only hoursLine import for the owner-approved status move. +const moduleChanges={ + "Vault.tsx": [], + "components.tsx": [], + "Docs.tsx": [], + "Flows.tsx": [ + { + "name": "hoursLine", + "head": [ + "import hoursLine from \"./Vault\"" + ], + "now": null + } + ], + "wallet.tsx": [], + "main.tsx": [] +}; +// Narrow exact declarations changed by SPEC 1/4 and the owner-approved status move; never generated by put-pieces. +const scopeChanges={ + "Vault.tsx": [ + { + "function": "DepositState", + "name": "s", + "head": [ + "parameter { snapshot: s }: { snapshot: Snapshot; }" + ], + "now": [ + "parameter { snapshot: s, showSign = false }: { snapshot: Snapshot; showSign?: boolean; }" + ] + } + ], + "components.tsx": [ + { + "function": "PageTitle", + "name": "eyebrow", + "head": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ], + "now": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children?: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ] + }, + { + "function": "PageTitle", + "name": "title", + "head": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ], + "now": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children?: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ] + }, + { + "function": "PageTitle", + "name": "children", + "head": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ], + "now": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children?: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ] + }, + { + "function": "PageTitle", + "name": "character", + "head": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ], + "now": [ + "parameter { eyebrow, title, children, character, }: { eyebrow?: string; title: string; children?: ReactNode; character?: { kind: \"vault\" | \"deposit\" | \"redeem\"; src: string; width: number; height: number; alt: string; line: string; }; }" + ] + } + ], + "Docs.tsx": [], + "Flows.tsx": [ + { + "function": "DepositPage", + "name": "r", + "head": [ + "r = s.status" + ], + "now": null + }, + { + "function": "DepositPage", + "name": "reason", + "head": [ + "reason = r?.ok ? Number(r.value[0]) : undefined" + ], + "now": null + }, + { + "function": "Claims", + "name": "zeros", + "head": [ + "zeros = s.assets.filter((a) => owed[a.token] === 0n)" + ], + "now": null + } + ], + "wallet.tsx": [], + "main.tsx": [] }; const newNames={}; for(const f of Object.keys(editedFiles)){ const h=scopes(baseline('web/src/'+f),f),n=scopes(read('web/src/'+f),f); - for(const name in h.module)assert.deepEqual(n.module[name],h.module[name],f+': '+name+' is declared differently at the top of the file'); + for(const name in h.module){ + const change=moduleChanges[f]?.find(x=>x.name===name); + if(change){assert.deepEqual(h.module[name],change.head,f+': module exception baseline');assert.deepEqual(n.module[name]??null,change.now,f+': exact approved module declaration change');} + else assert.deepEqual(n.module[name],h.module[name],f+': '+name+' is declared differently at the top of the file'); + } assert.deepEqual(Object.fromEntries(Object.keys(n.module).filter(name=>!(name in h.module)).map(name=>[name,n.module[name].join(' | ')])),namesAdded[f],f+': the names added at the top of the file (imports included) differ from the documented ones'); for(const fn in h.functions){ const was=h.functions[fn],is=n.functions[fn]; assert.ok(is,f+': '+fn+' is gone'); - for(const name in was)assert.deepEqual(is[name],was[name],f+' '+fn+': '+name+' is declared differently'); + for(const name in was){ + const change=scopeChanges[f]?.find(x=>x.function===fn&&x.name===name); + if(change){assert.deepEqual(was[name],change.head,f+' '+fn+': declaration exception baseline');assert.deepEqual(is[name]??null,change.now,f+' '+fn+': exact approved declaration change');} + else assert.deepEqual(is[name],was[name],f+' '+fn+': '+name+' is declared differently'); + } const added=Object.keys(is).filter(name=>!(name in was)); for(const name of added){assert.equal(is[name].length,1,f+' '+fn+': '+name+' is declared twice');assert.ok(!(name in n.module),f+' '+fn+': the new name '+name+' hides a name declared at the top of the file');} if(added.length)(newNames[f]??={})[fn]=added; } } +// The whole-source digest is additional to the specific structural assertions above. +for(const [f,digest] of Object.entries(reviewedSourcePins))assert.equal(sha256(read('web/src/'+f)),digest,f+': reviewed v12 source differs; regeneration does not authorize source edits'); // 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; v11 adds no refusal, never accepts what HEAD -// refused, and refuses with HEAD's words (model.ts, chain.ts and governance.ts are byte-identical to HEAD in v11). +// calldata must be byte-identical whenever the current page accepts; v12 adds no refusal, never accepts what HEAD +// refused, and refuses with HEAD's words (model.ts, chain.ts and governance.ts are byte-identical to HEAD in v12). 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))){ @@ -797,7 +1118,7 @@ for(const [file,list] of Object.entries(pinned)){ 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 (v11 documents no piece: the [v11, HEAD] lists are empty), or equals the pinned v9 text: the Guardian and +// HEAD (v12 documents no piece: the [v12, HEAD] lists are empty), or equals the pinned v9 text: the Guardian and // FeeRecipient proposal targets (newGuardian(v.next), feeRecipient(v.recipient)), the parsers they use, and the // listing rows (checkRows) and the Finalize helpers. const pinnedV9={ @@ -843,37 +1164,35 @@ for(const [file,map] of Object.entries(imports)){ } assert.equal(now.VAULT,head.VAULT,'the vault address the encoders use'); // The v9 refusals (uint128 minLiquidity, vault as guardian or new owner, Sunday 2:00-2:59 am start under Dst 0) are -// still made, by HEAD and now alike, and v11 adds none. +// still made, by HEAD and now alike, and v12 adds none. 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.refusedBoth[m]>0,'corpus exercises (refused by HEAD and now alike): '+m); assert.deepEqual(tally.newRefusals,{},'no new refusal'); assert.ok(tally.sameCalldata>400,'corpus size '+JSON.stringify(tally)); +assert.equal(tally.sameCalldata,496,'v12 retains all 496 accepted differential cases'); +assert.equal(tally.bothRefused,1773,'v12 retains all 1773 refused differential cases'); fs.rmSync(scratch,{recursive:true,force:true}); // The report. The delivery is close to its size limit, so each piece is recorded by length and SHA-256, not repeated. -const pieceRecord=([v11,head])=>({v11:{length:v11.length,sha256:sha256(v11)},head:{length:head.length,sha256:sha256(head)}}); +const pieceRecord=([v12,head])=>({v12:{length:v12.length,sha256:sha256(v12)},head:{length:head.length,sha256:sha256(head)}}); const brief=x=>x.length>120?{start:x.slice(0,80),length:x.length,sha256:sha256(x)}:x; -fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:git(['rev-parse','HEAD']).trim(),protectedFiles,editedFiles:Object.fromEntries(Object.entries(editedFiles).map(([f,x])=>[f,x.length+' documented pieces [v11, HEAD]'])),v11Pieces:Object.fromEntries(Object.entries(editedFiles).map(([f,x])=>[f,x.map(pieceRecord)])),counts, +fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:git(['rev-parse','HEAD']).trim(),protectedFiles,editedFiles:Object.fromEntries(Object.entries(editedFiles).map(([f,x])=>[f,x.length+' documented pieces [v12, HEAD]'])),v12Pieces:Object.fromEntries(Object.entries(editedFiles).map(([f,x])=>[f,x.map(pieceRecord)])),counts, documentedGuardedCallChanges:expected,documentedAttributeChanges:Object.fromEntries(Object.entries(attributeChanges).map(([f,a])=>[f,{removed:a.removed.map(x=>'<'+x[0]+'> '+x[1]),added:a.added.map(x=>'<'+x[0]+'> '+x[1]),why:'each has its why in scripts/check-calldata.mjs'}])), documentedCallChanges:Object.fromEntries(Object.entries(callChanges).map(([f,c])=>[f,{why:c.why,piecesPutBackInsideCalls:c.pieces.map(pieceRecord),removed:c.removed.map(brief),newUsesOfWatchedNames:c.uses.map(([x,where])=>[brief(x),where])}])), - vaultStockShelves:{attributes:vaultAttributes,handlers:vaultHandlers},pinnedFunctions,walletHookReturn:{equalToHeadPlus:'disconnect'}, - walletAndRpcMethods:{named:methods,addedInV11:[]},browserStorageAddedInV11:storageAdded,namesAddedAtTheTopOfAFile:namesAdded,namesAddedInsideComponents:newNames,transactionCode,reviewedAddedCalls:addedCallPins,transactionStateWrites, + vaultStockShelves:{attributes:vaultAttributes,handlers:vaultHandlers},pinnedFunctions,walletHookReturn:{equalToHead:true}, + walletAndRpcMethods:{named:methods,addedInV12:[]},browserStorageAddedInV12:storageAdded,namesAddedAtTheTopOfAFile:namesAdded,moduleDeclarationChanges:moduleChanges,namesAddedInsideComponents:newNames,transactionCode,reviewedAddedCalls:addedCallPins,reviewedSourcePins,transactionStateWrites,protectedCode, 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)),addedInV11:[]}, - pageLoads:{names:[...LOADS],jsxAttributes:[...LOAD_ATTRIBUTES],usesPerFile:loadUses,equalToHead:true}, + sendsAndRequests:{sendFreeFiles:NO_SEND,usesPerFile:Object.fromEntries(Object.entries(sendUses).map(([f,x])=>[f,x.length])),components:sendUses['components.tsx'].filter(x=>/\(/.test(x)),addedInV12:[]}, + pageLoads:{names:[...LOADS],jsxAttributes:[...LOAD_ATTRIBUTES],usesPerFile:loadUses,equalToHeadExceptDisplayNames:loadDisplayAdded}, pinnedV9Declarations:Object.fromEntries(Object.entries(pinnedV9).map(([f,m])=>[f,Object.fromEntries(Object.entries(m).map(([k,x])=>[k,typeof x==='string'?'v9 text (also at HEAD)':'equal to HEAD ('+x.length+' pieces)']))])),importBindings:imports, - checks:['v11 scope: Docs, Losses, Owner, Scenery, Vault, chain, components, deployment, governance, model, newYork, poolMath and the ABI byte-identical to HEAD; Flows.tsx, wallet.tsx and main.tsx byte-identical to HEAD once their documented v11 pieces are put back, each exactly once (Flows: the rebuilt Deposit form, the "You received ... BASK" line, the Redeem amount box emptied after a confirmed redeem; wallet: the remembered disconnected flag and disconnect(); main: the Disconnect button); the pieces are recorded here by length and SHA-256; no source file added or removed', - 'Every vault/token/feed/read call, transaction argument parser, encoding, simulation and send, and every method called on the public client (the guarded calls), equals the HEAD AST in order in every web/src source, apart from two documented additions in Flows.tsx: the one new chain read, client.getTransactionReceipt of the visitor\'s own confirmed deposit, and the display-only amount() of the estimated total, both in DepositPage outside every handler; wallet.tsx adds no guarded call or provider request', - 'Action, send and check JSX attributes (every event handler, the wallet and snapshot handed to a component, and the listed names) equal HEAD in every file, in order, apart from the documented ones of Flows.tsx and main.tsx, each listed with its element and a why; no onSubmit, getSpec, build or claim attribute is added, removed or changed in any file (their texts equal HEAD in order), and no added or removed attribute contains a guarded call; Vault.tsx has no guarded call at all, its attributes equal the pinned list and its StockShelves handlers equal their pinned text', - 'Every call expression of HEAD in Flows.tsx, wallet.tsx and main.tsx is still there, in order, apart from the documented removed ones (the old grid of Add buttons, the old search filter and the old amount rows in Flows.tsx; a call that only gained a documented piece inside it counts as kept); the complete ordered added calls, including their callbacks and places, match independently reviewed SHA-256 pins and ordered callee lists that the piece-regeneration tool never refreshes', - 'Direct and aliased assignments, deletion, updates and mutating methods on inputs, open, quote and its arrays, balances, allowances and s.assets equal HEAD in order; shallow copies retain tracking of nested references', - 'Watched names (the names that build, parse, read or send, and the state setters and refs each file had at HEAD): every use, a call or the bare name handed on or renamed, equals HEAD in order and place, apart from the documented new uses: the receipt read with its whole answer handling, the writes of Add and Remove to the typed amounts and to the search text, the amount() of the estimated total, the search box, the emptied Redeem amount box, and in disconnect() the look at the send lock and the forgetting of the account, the transaction link and the top message', - '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 with any key, eval/Function/Reflect and the listed names that reach the network (fetch, XMLHttpRequest, WebSocket, EventSource, sendBeacon, importScripts, dynamic import; as a name or as a plain string) equal HEAD in order and place after restoring the exact guarded startup eth_accounts reply once; the request expression and every method still equal HEAD directly; Vault, Scenery, main and Docs have no send or request (main only reads window.ethereum), components keeps the single wallet.send of HEAD', - 'What a page can load or open without those names equals HEAD in every web/src file, in order and place, with no exception: the listed names (a picture, sound or worker made in script, the address bar, the history, the navigator, a new window, an element or HTML built in script, a src, href, action or style set in script; as a name, a property or a plain string), the JSX attributes that carry an address, a style or HTML, and attributes spread onto an element. A list of known ways, not a proof that no other exists', - 'The wallet and RPC methods named in web/src equal HEAD with no additions; browser storage is used as at HEAD plus the three documented uses of the disconnected flag (key "basket-disconnected-4663")', - 'wallet.tsx send() (the only function that asks the wallet for a transaction) equals HEAD: from, to, data, gas, nonce, chain and account rechecks, simulation and the eth_sendTransaction request unchanged; disconnect() equals its pinned text; the hook returns HEAD\'s list plus disconnect, so the pages call the same send', - 'Flows.tsx approveAll() equals HEAD, and so does every onSubmit and getSpec (the two previews, Deposit, Redeem and Claim); in the three v11 files every name HEAD declares at the top of the file (imports included) and at the top of each component or hook is still declared once by the same text, the names added at the top of a file are the documented ones, and a new name inside a component hides none of them', - 'Argument sources: governance.ts proposalAction (Guardian target newGuardian(v.next), FeeRecipient target feeRecipient(v.recipient)), proposalSpec, settingValues and poolValues equal HEAD; newOwner, newGuardian, feeRecipient, minLiquidityOf and the spring-forward rule equal the pinned v9 text (at HEAD and now); 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', - '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); no new refusal, nothing HEAD refused is accepted, and every refusal has HEAD\'s words (the v9 refusals, uint128 minLiquidity, vault as guardian or new owner, Sunday 2:00-2:59 am start under Dst 0, are still made by both)', - '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 v11 files change in web/src (check-bundle.mjs)']},null,2)+'\n'); + checks:[ + 'v12 source scope: every other TypeScript source and ABI equal HEAD; edited sources equal HEAD once their exact pieces are restored, and also match independent reviewed SHA-256 pins outside regeneration blocks', + 'Every guarded reading, parsing, encoding, simulation, sending and provider call equals HEAD in order and place; no added chain read, wallet request, storage key or network call', + 'Action attributes equal HEAD except the documented approved presentation changes with a written reason; every onSubmit, getSpec, build and claim attribute equals HEAD', + 'Every removed call and added watched-name use is independently listed; complete ordered added calls match reviewed hashes and ordered callee lists outside regeneration blocks', + 'Direct and aliased transaction-state writes equal HEAD; send-tail receipt-hash writes are individually pinned with their ordered statements', + 'Wallet send head is byte-identical to HEAD through the receipt wait; its tail equals HEAD after exact receipt-hash replacements; connect and disconnect equal reviewed text and the new counter is used only at the pinned sites', + 'All protected wallet functions, flow effects, transaction buttons and handlers are byte-identical to HEAD; declaration and wrapper-scope checks prevent name rebinding', + 'Provider methods, browser storage, network and page-load sinks equal HEAD; Disconnect makes no request; components retains the same single wallet.send', + 'The differential encoding corpus, six-clock send builders, parser declarations, pinned v9 decisions and imported bindings retain every prior assertion', + 'Only approved v12 source files change; shell and share-card bytes are separately pinned by 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-fixes.mjs b/web/scripts/check-fixes.mjs index a4d058e..c200306 100644 --- a/web/scripts/check-fixes.mjs +++ b/web/scripts/check-fixes.mjs @@ -13,14 +13,15 @@ import React from '${path.resolve('node_modules/react/index.js')}'; import {createRoot} from '${path.resolve('node_modules/react-dom/client.js')}'; import {useWallet} from '${src}/wallet'; import {Claims} from '${src}/Flows'; -import {client,VAULT} from '${src}/chain'; -import {keccak256,toHex} from '${path.resolve('node_modules/viem/_esm/index.js')}'; +import {client,VAULT,vaultAbi} from '${src}/chain'; +import {keccak256,toHex,decodeFunctionData,encodeAbiParameters} from '${path.resolve('node_modules/viem/_esm/index.js')}'; const mode=new URLSearchParams(location.search).get('mode'); const hash=keccak256(toHex('isolated pending transaction fixture')); const key='basket-pending-4663'; -const fixture={hash,account:VAULT,functionName:'genesisList',...(mode==='legacy'?{}:{nonce:4})}; +const mockAccount='0x00000000000000000000000000000000000a11ce'; +const fixture={hash,account:mockAccount,functionName:'genesisList',...(mode==='legacy'?{}:{nonce:4})}; sessionStorage.setItem(key,JSON.stringify(fixture)); -window.ethereum={request:async({method})=>method==='eth_chainId'?'0x1237':[VAULT]}; +window.ethereum={request:async({method})=>method==='eth_chainId'?'0x1237':[mockAccount]}; let waits=0,refreshed=0; Object.assign(client,{ getTransactionReceipt:async()=>{if(mode==='receipt')return {};throw Error('no receipt')}, @@ -31,12 +32,41 @@ Object.assign(client,{ const s={assets:[],globals:{},errors:[],complete:mode==='complete',aggregate:false,loadedAt:0,loading:false,retry:()=>{refreshed++}}; function App(){const w=useWallet(async()=>{refreshed++;return s});return <>
{w.account}
} window.inspect=()=>({saved:sessionStorage.getItem(key),waits,refreshed}); -createRoot(document.getElementById('root')).render(); + +// v12 SPEC 4: drive the shipped Claims component, including its Activity/key mounting. +const fixtureAddress=(n)=>'0x'+n.toString(16).padStart(40,'0'); +const fixtureAssets=[1,2,3].map(n=>({token:fixtureAddress(n),symbol:'FIX'+n,tokenDecimals:0,index:n-1})); +let fixtureState,fixtureUpdate,fixtureReads=[],fixtureCalls=0,fixtureSend; +if(mode==='claims')Object.assign(client,{call:({data})=>new Promise((resolve,reject)=>{ + const decoded=decodeFunctionData({abi:vaultAbi,data}); + if(decoded.functionName!=='owed')throw Error('Unexpected Claims read '+decoded.functionName); + fixtureCalls++;fixtureReads.push({token:decoded.args[1],resolve,reject}); +})}); +function ClaimsFixture(){ + const [state,update]=React.useState({account:mockAccount,visible:true,complete:true,loading:false,loadedAt:0,progress:{},empty:false}); + fixtureState=state;fixtureUpdate=update; + const report=(key,value)=>update(old=>({...old,progress:{...old.progress,[key]:value}})); + const w={...state,chainId:4663,busy:false,connecting:false,report,fail:(key,message)=>report(key,{message}),send:async(spec,key,label)=>{ + report(key,{message:label+': Waiting for wallet…'}); + await new Promise((resolve,reject)=>{fixtureSend={resolve,reject,key,label}}); + report(key,{message:label+': Confirmed.'}); + },connect:()=>{},switchChain:()=>{}}; + const snapshot={assets:state.empty?[]:fixtureAssets,complete:state.complete,loading:state.loading,loadedAt:state.loadedAt,globals:{},retry:()=>{}}; + return ; +} +window.fixtureUpdate=(change)=>fixtureUpdate(old=>({...old,...change,...(change.bump?{loadedAt:old.loadedAt+1}:{})})); +window.fixtureFinish=(values)=>{for(const item of fixtureReads.splice(0)){const value=values[Number(BigInt(item.token))-1];if(value===null)item.reject(Error('unreadable'));else item.resolve({data:encodeAbiParameters([{type:'uint256'}],[BigInt(value)])});}}; +window.fixtureSettle=(error)=>{const send=fixtureSend;fixtureSend=undefined;if(error)send.reject(Error(error));else send.resolve();}; +window.fixtureInspect=()=>({calls:fixtureCalls,pending:fixtureReads.length,sending:!!fixtureSend,panel:!!document.querySelector('.claims'),quiet:document.querySelector('.claims-quiet')?.textContent??'',rows:document.querySelectorAll('.claim-grid>.receipt').length,body:document.body.innerText}); + +createRoot(document.getElementById('root')).render(mode==='claims'?:); `); await build({configFile:false,root:scratch,base:'./',logLevel:'error',build:{outDir:path.join(scratch,'dist'),emptyOutDir:true}}); const server=http.createServer((req,res)=>{const p=path.join(scratch,'dist',req.url.split('?')[0]==='/'?'index.html':req.url.split('?')[0]);try{res.setHeader('Content-Type',p.endsWith('.js')?'application/javascript':'text/html');res.end(fs.readFileSync(p));}catch{res.writeHead(404).end();}}); await new Promise(r=>server.listen(0,'127.0.0.1',r));let browser;const checks=[]; +const blockedNetwork=[]; try{browser=await chromium.launch();const page=await browser.newPage(); +await page.route('**/*',route=>{const address=route.request().url();if(address.startsWith('http://127.0.0.1:'+server.address().port+'/'))return route.continue();blockedNetwork.push(address);return route.abort();}); for(const mode of ['replaced','legacy','receipt','pending','unknown','queue']){ await page.goto(`http://127.0.0.1:${server.address().port}/?mode=${mode}`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent); await page.getByRole('button',{name:'Check listing',exact:true}).click(); @@ -46,6 +76,72 @@ for(const mode of ['replaced','legacy','receipt','pending','unknown','queue']){ } await page.goto(`http://127.0.0.1:${server.address().port}/?mode=replaced`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent);await page.getByRole('button',{name:'Wait',exact:true}).click();assert.equal(await page.locator('output').innerText(),'waited');assert.deepEqual(await page.evaluate(()=>window.inspect()),{saved:null,waits:0,refreshed:1});checks.push('Wait reconciles replaced hash without waiting for impossible receipt'); assert.equal(await page.getByText('Owed balances unreadable. Retry vault.',{exact:true}).count(),1);assert.equal(await page.getByText('Nothing is owed to your connected wallet.',{exact:true}).count(),0);await page.getByRole('button',{name:'Retry vault',exact:true}).click();assert.equal((await page.evaluate(()=>window.inspect())).refreshed,2);checks.push('Incomplete stock read says unreadable, offers functioning Retry vault, never says nothing owed'); -await page.goto(`http://127.0.0.1:${server.address().port}/?mode=complete`);await page.getByText('Nothing is owed to your connected wallet.',{exact:true}).waitFor();checks.push('Complete empty stock read retains nothing-owed message'); -fs.writeFileSync('../artifacts/site-fixes.json',JSON.stringify({checks,scope:'Actual wallet hook and Claims component, mocked reads only; fork sends tested separately.'},null,2)+'\n');console.log('PASS',checks.length,'site-fix regressions'); +await page.goto(`http://127.0.0.1:${server.address().port}/?mode=complete`);await page.getByText('Nothing is owed to this wallet.',{exact:true}).waitFor();assert.equal(await page.locator('.claims').count(),0);checks.push('v12 SPEC 4: complete empty stock read has no Claims panel and the exact quiet line'); + +// Keep the existing pending-transaction regressions above. These additions cover +// v12 SPEC 4 only; all owed reads use the shipped chain adapter with a mocked call. +await page.goto('http://127.0.0.1:'+server.address().port+'/?mode=claims'); +const tick=()=>page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))); +const state=()=>page.evaluate(()=>window.fixtureInspect()); +const update=async(change)=>{await page.evaluate(change=>window.fixtureUpdate(change),change);await tick();}; +const finish=async(values)=>{await page.evaluate(values=>window.fixtureFinish(values),values);await tick();}; +const check=async(name,predicate)=>{await tick();const value=await state();assert.ok(predicate(value),name+' '+JSON.stringify(value));checks.push(name);}; +await page.waitForFunction(()=>window.fixtureInspect?.().pending===3); +await check('v12 Claims: first owed read is blank',v=>!v.panel&&!v.quiet); +await finish([0,0,0]); +await check('v12 Claims: zero owed has only the exact quiet line',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await update({bump:true}); +await check('v12 Claims: refresh retains the quiet line',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await finish([2,3,0]); +await check('v12 Claims: positive balances show only owed stock rows',v=>v.panel&&v.rows===2); +assert.equal(await page.locator('.claims details').count(),0); +await page.getByText('Claim all collects up to 10 stocks per wallet prompt. If one stock can’t be paid, that batch stays owed: collect the others with their own Claim buttons.',{exact:true}).waitFor(); +checks.push('v12 Claims: no zero-stock fold, exact multiple-stock note'); +await update({bump:true}); +await check('v12 Claims: refresh retains panel rows',v=>v.panel&&v.rows===2); +await finish([2,0,0]); +assert.equal(await page.getByText('Claim all collects up to 10 stocks per wallet prompt. If one stock can’t be paid, that batch stays owed: collect the others with their own Claim buttons.',{exact:true}).count(),0); +checks.push('v12 Claims: one owed stock has no multiple-stock note'); +await page.getByRole('button',{name:'Claim all',exact:true}).click(); +await check('v12 Claims: running batch retains the panel and status',v=>v.panel&&v.sending&&v.body.includes('Waiting for wallet')); +await page.evaluate(()=>window.fixtureSettle());await page.waitForFunction(()=>window.fixtureInspect().pending===3); +await finish([0,0,0]); +await check('v12 Claims: last claim keeps Confirmed and the empty panel',v=>v.panel&&v.rows===0&&v.body.includes('Confirmed.')); +const callsBeforeHide=(await state()).calls; +await update({visible:false,bump:true}); +await check('v12 Claims: hidden Activity starts no owed reads',v=>v.calls===callsBeforeHide); +await update({visible:true});await finish([0,0,0]); +await check('v12 Claims: navigation preserves confirmation for this account',v=>v.panel&&v.body.includes('Confirmed.')); +await update({account:'0x00000000000000000000000000000000000a11cf'}); +await check('v12 Claims: account change has no old panel before its read',v=>!v.panel&&!v.quiet); +await finish([0,0,0]); +await check('v12 Claims: account change ignores prior claim status',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await update({bump:true});await finish([null,0,0]); +await page.getByRole('button',{name:'Retry claims',exact:true}).waitFor(); +await check('v12 Claims: unread owed result shows panel and retry reason',v=>v.panel&&v.body.includes('Owed balances are unreadable. Retry claims above.')); +await page.getByRole('button',{name:'Retry claims',exact:true}).click();await finish([0,0,0]); +await check('v12 Claims: retry keeps a previously shown panel',v=>v.panel&&v.rows===0); +await update({visible:false});await update({account:'0x00000000000000000000000000000000000a11d0'});await update({account:'0x00000000000000000000000000000000000a11cf'});await update({visible:true});await finish([0,0,0]); +await check('v12 Claims: account changes while hidden reset the old latch',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await update({account:'0x00000000000000000000000000000000000a11d1',complete:false});await finish([0,0,0]); +await check('v12 Claims: incomplete stock list shows panel with vault retry reason',v=>v.panel&&v.body.includes('Owed balances unreadable. Retry vault.')); +await update({account:'0x00000000000000000000000000000000000a11d2',empty:true,complete:false,bump:true}); +await check('v12 Claims: unreadable empty list shows the panel and exact reason',v=>v.panel&&v.body.includes('Listed stocks unreadable. Retry the vault.')); +await update({account:'0x00000000000000000000000000000000000a11d3',empty:true,complete:true,bump:true}); +await check('v12 Claims: complete empty list has the quiet line',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await update({empty:false,bump:true}); +await check('v12 Claims: newly loaded stocks do not reinterpret an old empty owed result',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await finish([0,0,0]); +await check('v12 Claims: newly loaded zero balances remain quiet',v=>!v.panel&&v.quiet==='Nothing is owed to this wallet.'); +await update({progress:{'claim-all':{message:'Claim batch: Confirmed.'}}}); +await check('v12 Claims: a claim status reveals and retains its panel',v=>v.panel&&v.body.includes('Claim batch: Confirmed.')); +await update({account:'0x00000000000000000000000000000000000a11d4'});await finish([1,0,0]);await page.getByRole('button',{name:'Claim all',exact:true}).click(); +await page.evaluate(()=>window.fixtureSettle('Fixture claim refused'));await tick(); +await update({bump:true});await finish([0,0,0]); +await check('v12 Claims: a claim error remains visible after zero owed refresh',v=>v.panel&&v.body.includes('Fixture claim refused')); +await update({account:undefined}); +await check('v12 Claims: disconnected has no panel and the exact connection line',v=>!v.panel&&v.quiet==='Connect the receiver wallet to read and claim its owed stocks.'); + +assert.deepEqual(blockedNetwork,[],'Isolated fixtures request no external network');checks.push('Isolated synthetic-wallet fixtures make no external network request'); +fs.writeFileSync('../artifacts/site-fixes.json',JSON.stringify({checks,scope:'Actual wallet hook and Claims component; mocked reads and wallet sends for deterministic regressions; synthetic accounts and no external network; fork sends tested separately.'},null,2)+'\n');console.log('PASS',checks.length,'site-fix regressions'); }finally{await browser?.close();await new Promise(r=>server.close(r));} diff --git a/web/scripts/check-visibility.mjs b/web/scripts/check-visibility.mjs index bb39493..88bbed1 100644 --- a/web/scripts/check-visibility.mjs +++ b/web/scripts/check-visibility.mjs @@ -1,29 +1,357 @@ -import fs from 'node:fs';import http from 'node:http';import path from 'node:path';const {chromium}=await import(process.env.PLAYWRIGHT_MODULE); -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; -// v11 (the previous build is v10): nothing may leave the first phone screen. Every element visible on the first screen -// of the previous build must still be visible there, with the same words (line breaks aside), and the title character -// must still start on it. With a wallet connected the header gained a Disconnect button beside the address: it must -// not make the header taller, or everything under it would move down and the last elements of the first screen (on -// the Owner page the Immediate and Propose buttons) would leave it. The other v11 changes (the rebuilt Deposit form, -// "You received" after a deposit, the emptied Redeem amount box) sit below the first screen on phones. +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import assert from 'node:assert/strict'; +import { decodeFunctionData, encodeFunctionResult, multicall3Abi, parseAbi } from 'viem'; +const { chromium } = await import(process.env.PLAYWRIGHT_MODULE); +const account = '0x00000000000000000000000000000000000a11ce'; +const accountWord = account.slice(2); +const accountAbi = parseAbi([ + 'function balanceOf(address) view returns (uint256)', + 'function allowance(address,address) view returns (uint256)', + 'function owed(address,address) view returns (uint256)', +]); +const RPC = /^https:\/\/(rpc\.mainnet\.chain\.robinhood\.com|robinhood-rpc\.publicnode\.com)\/?$/; +const READS = new Set(['eth_call','eth_chainId','eth_getCode','eth_blockNumber','eth_getBlockByNumber','eth_getBlockByHash']); +// Replace only the existing empty-token depositStatus read; other public reads stay live. +let statusFixture=0,statusFixtureReads=0; +function fixtureStatus(call) { + const tx=call?.params?.[0]; + if(call?.method!=='eth_call'||tx?.to?.toLowerCase()!=='0x739fd5b653aa092a434534fa1ade67c1770b5a5b'||tx.data?.toLowerCase()!=='0x6a6ccbd800000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000')return undefined; + statusFixtureReads++; + return {jsonrpc:'2.0',id:call.id,result:'0x'+statusFixture.toString(16).padStart(64,'0')+'0'.repeat(64)}; +} + +const WEEKEND_NOTICE="Deposits reopen when the stock feed prices update around 8pm on Sundays.", ISSUE_NOTICE="Deposits will be back open when “too few fresh prices” is resolved."; +let weekendBlockReads=0; +function fixtureBlock(call,answer) { + if(statusFixture!==3||call.method!=='eth_getBlockByNumber')return answer; + assert.ok(answer.result&&typeof answer.result.timestamp==='string','Weekend proof starts with an actual public block'); + weekendBlockReads++; + return {...answer,result:{...answer.result,timestamp:'0x'+Math.floor(Date.parse('2026-10-10T12:00:00Z')/1000).toString(16)}}; +} +const publicCache = new Map(), routingErrors = []; +let inflight = 0; +const server = http.createServer((req,res) => { + const parts=req.url.split('?')[0].split('/'); + const root=parts[1]==='old'?process.env.BASKET_BASELINE_DIST:path.resolve('../dist'); + const p=path.join(root,parts.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 base=`http://127.0.0.1:${server.address().port}`; +const b=await chromium.launch(),page=await b.newPage({viewport:{width:375,height:812},reducedMotion:'reduce'}); +const results=[]; + +// v12 SPEC 1-4 permits only the named text/layout changes below. All prior +// first-screen elements otherwise retain their words and remain on screen. +// The header, slogan strip, and start of each title character stay pinned. const characterPages=['vault','deposit','redeem']; -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=[],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};};const m=document.querySelector('header .marquee'),w=m.querySelector('.marquee-window').getBoundingClientRect(),h=[...m.querySelectorAll('.marquee-half')].map(e=>e.getBoundingClientRect()).sort((a,c)=>a.left-c.left);return {hint:box('main .wallet-help'),title:box('main .page-title'),picture:box('main .page-title .title-character img.character-picture'),strip:{...box('header .marquee'),animations:m.getAnimations({subtree:true}).length,still:h.length===2&&Math.abs(h[0].left-w.left)<0.01&&Math.abs(h[1].left-h[0].right)<0.01&&h[1].right>=w.right-0.01}};}));} -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),regressions=[]; -for(const x of prior){const y=counterpart(x);if(y&&y.bottom<=812)continue;regressions.push({...x,now:y});} -const checks=[]; -// v10 slogan strip (now two halves, each its own Web Animation): on the phone screen it keeps the previous build's place -// and height, and under reduced motion it has no animation and stands still and full, the halves side by side. -checks.push('slogan strip in the same place and size, still and full under reduced motion');{const o=oldLayout.strip,n=newLayout.strip;if(Math.abs(n.top-o.top)>0.01||Math.abs(n.height-o.height)>0.01||n.animations!==0||!n.still)regressions.push({tag:'DIV',text:'slogan strip',old:o,now:n});} -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});} -results.push({variant,name,prior,hintGrowth,checks,regressions});} -for(const r of results) if(r.regressions.length) throw Error(JSON.stringify({variant:r.variant,name:r.name,regressions:r.regressions})); -console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.prior.length,0),'first-screen elements of the previous build still on the first screen with the same words; nothing moved or removed; slogan strip in place, still and full under reduced motion');fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2));}finally{await b.close();await new Promise(r=>server.close(r));} +const removedVault='Meet Basket (BASK): your share of the Stock Tokens in this vault.'; +const removedWalletLine='Showing 0x0000…11ce. To use another wallet, switch the address in your wallet app: this page follows it.'; +const docsAdded=[ + 'The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.', + 'BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.', + 'The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM.', + 'ITEM is not live yet. Its address will be listed here when it is.', +]; +const words=t=>String(t).replace(/\s+/g,''); +const normalize=t=>String(t).replace(/\s+/g,' ').trim(); +const near=(a,b)=>Math.abs(a-b)<=0.5; +await page.addInitScript(({account})=>{ + if(location.search.includes('connected')) window.ethereum={ + request:async({method})=>{ + if(method==='eth_chainId')return '0x1237'; + if(method==='eth_accounts'||method==='eth_requestAccounts')return [account]; + throw Error('Visibility fixture refuses '+method); + },on(){},removeListener(){}, + }; +},{account}); + +async function publicRead(url,call) { + assert.ok(READS.has(call.method),'Unexpected visibility RPC method: '+call.method); + assert.ok(!JSON.stringify(call.params??[]).toLowerCase().includes(accountWord),'Fixture account must not reach public RPC'); + const key=JSON.stringify([url,call.method,call.params??[]]); + if(!publicCache.has(key)) publicCache.set(key,(async()=>{ + const body=JSON.stringify({...call,id:1}); + const endpoints=[url,...['https://rpc.mainnet.chain.robinhood.com','https://robinhood-rpc.publicnode.com'].filter(endpoint=>endpoint!==url.replace(/\/$/,''))]; + const failures=[]; + for(const endpoint of endpoints) { + assert.ok(RPC.test(endpoint),'Public read fallback must use an allowed RPC'); + let answer; + try { + const response=await fetch(endpoint,{method:'POST',headers:{'content-type':'application/json'},body,signal:AbortSignal.timeout(20000)}); + if(!response.ok)throw Error(`HTTP ${response.status} (${response.headers.get('content-type')||'no content type'})`); + const text=await response.text(); + try {answer=JSON.parse(text);}catch {throw Error(`Non-JSON response (${response.headers.get('content-type')||'no content type'})`);} + }catch(error){failures.push(`${endpoint}: ${error.message}`);continue;} + assert.ok(answer&&answer.jsonrpc==='2.0'&&('result' in answer||'error' in answer),'Public RPC must return a JSON-RPC answer'); + if(failures.length)console.warn('Visibility public RPC transport fallback:',failures.join('; '),'->',endpoint); + return answer.error?{error:answer.error}:{result:answer.result}; + } + throw Error('All allowed public RPC transports failed: '+failures.join('; ')); + })().catch(error=>{publicCache.delete(key);throw error;})); + return {jsonrpc:'2.0',id:call.id,...await publicCache.get(key)}; +} +function synthetic(data) { + let decoded; + try {decoded=decodeFunctionData({abi:accountAbi,data});}catch{return undefined;} + if(!decoded.args?.some(a=>String(a).toLowerCase()===account))return undefined; + return encodeFunctionResult({abi:accountAbi,functionName:decoded.functionName,result:0n}); +} +async function answer(url,call) { + const fixed=fixtureStatus(call);if(fixed)return fixed; + if(call.method==='eth_call'&&call.params?.[0]?.data?.toLowerCase().includes(accountWord)) { + const tx=call.params[0],made=synthetic(tx.data); + if(made!==undefined)return {jsonrpc:'2.0',id:call.id,result:made}; + const decoded=decodeFunctionData({abi:multicall3Abi,data:tx.data}); + assert.equal(decoded.functionName,'aggregate3'); + const result=await Promise.all(decoded.args[0].map(async c=>{ + const made=synthetic(c.callData); + if(made!==undefined)return {success:true,returnData:made}; + const value=await publicRead(url,{...call,params:[{...tx,to:c.target,data:c.callData},...call.params.slice(1)]}); + return {success:!value.error,returnData:value.error?'0x':value.result}; + })); + return {jsonrpc:'2.0',id:call.id,result:encodeFunctionResult({abi:multicall3Abi,functionName:'aggregate3',result})}; + } + return fixtureBlock(call,await publicRead(url,call)); +} + + +function capacityGeometry() { + const cells=[...document.querySelectorAll('.stats > div')],cell=cells[3],label=cell?.querySelector(':scope > span'),note=label?.querySelector('small'),value=cell?.querySelector(':scope > strong'); + if(!cell||!label||!note||!value)return {count:cells.length,label:label?.textContent??null}; + const r=cell.getBoundingClientRect(),s=getComputedStyle(cell),split=[],outside=[]; + const labelRange=document.createRange();labelRange.selectNode(label.firstChild); + const labelBox=labelRange.getBoundingClientRect(),noteBox=note.getBoundingClientRect(),valueBox=value.getBoundingClientRect(); + const walker=document.createTreeWalker(label,NodeFilter.SHOW_TEXT); + for(let node;(node=walker.nextNode());)for(const word of node.textContent.matchAll(/\S+/g)) { + const range=document.createRange();range.setStart(node,word.index);range.setEnd(node,word.index+word[0].length); + const box=range.getBoundingClientRect(); + if(new Set([...range.getClientRects()].filter(x=>x.width>0).map(x=>Math.round(x.top))).size!==1)split.push(word[0]); + if(box.leftr.right-parseFloat(s.borderRightWidth)+0.5||box.topr.bottom-parseFloat(s.borderBottomWidth)+0.5)outside.push(word[0]); + } + return {count:cells.length,label:label.firstChild.textContent.trim(),note:note.textContent,noteCount:label.querySelectorAll('small').length,labelFont:parseFloat(getComputedStyle(label).fontSize),noteFont:parseFloat(getComputedStyle(note).fontSize),noteBelow:noteBox.top>=labelBox.bottom-0.5,valueBelow:valueBox.top>=noteBox.bottom-0.5,value:value.textContent,split,outside}; +} +function assertCapacity(g) { + assert.equal(g.count,4,'Vault keeps all four figures'); + assert.deepEqual([g.label,g.note],['Maximum Basket Capacity','(eligible to be raised)'],'Exact owner capacity wording'); + assert.equal(g.noteCount,1,'Capacity has one smaller note'); + assert.ok(g.labelFont>=14&&g.noteFont>=12&&g.noteFont p'); + const signs=[...document.querySelectorAll('.deposit-led-sign')].map(e=>{ + const r=e.getBoundingClientRect(),p=e.closest('.deposit-state')?.getBoundingClientRect(),rect=e.querySelector('rect'); + const border=e.querySelector('.deposit-led-border'),chase=e.querySelector('.deposit-led-chase'),letters=e.querySelector('.deposit-led-letters'); + return {label:e.getAttribute('aria-label'),role:e.getAttribute('role'),inline:e.tagName.toLowerCase()==='svg',onDeposit:!!e.closest('.flow-layout > section.panel .deposit-state'), + width:r.width,height:r.height,inside:!!p&&r.left>=p.left&&r.right<=p.right&&r.top>=p.top&&r.bottom<=p.bottom, + black:rect?.getAttribute('fill'),blue:border?getComputedStyle(border).stroke:null,red:!!e.querySelector('circle[fill="#ff373d"]'), + circles:e.querySelectorAll('circle').length,animations:e.getAnimations({subtree:true}).length, + offset:chase?getComputedStyle(chase).strokeDashoffset:null,glow:letters?getComputedStyle(letters).opacity:null}; + }); + return {status:first?.textContent.replace(/\s+/g,' ').trim()??null,reopening:state?.querySelector('.reopen')?.textContent??null,readIssues:document.querySelectorAll('.read-errors').length,signs}; +} +function layoutSnapshot() { + const box=e=>{ + if(typeof e==='string')e=document.querySelector(e); + if(!e)return null; + const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}; + }; + const m=document.querySelector('header .marquee'),w=m.querySelector('.marquee-window').getBoundingClientRect(); + const halves=[...m.querySelectorAll('.marquee-half')].map(e=>e.getBoundingClientRect()).sort((a,c)=>a.left-c.left); + const docs=[...document.querySelectorAll('.docs-grid > .panel')]; + const paragraphs=docs[0]?[...docs[0].querySelectorAll(':scope > p')]:[]; + return { + header:box('header'),hint:box('main .wallet-help'),title:box('main .page-title'),picture:box('main .page-title .title-character img.character-picture'), + strip:{...box('header .marquee'),animations:m.getAnimations({subtree:true}).length,still:halves.length===2&&Math.abs(halves[0].left-w.left)<0.01&&Math.abs(halves[1].left-halves[0].right)<0.01&&halves[1].right>=w.right-0.01}, + titleParagraphs:[...document.querySelectorAll('.page-title > p')].map(e=>e.textContent.replace(/\s+/g,' ').trim()), + heading:document.querySelector('.page-title > h1')?.textContent, + docs:docs.map(box),docsParagraphs:paragraphs.map(e=>({text:e.textContent.replace(/\s+/g,' ').trim(),...box(e)})), + docsAddedHeight:paragraphs.length===5?box(paragraphs[4]).bottom-box(paragraphs[0]).bottom:0, + redeemAside:document.querySelector('.flow-layout > aside')?.querySelector('h2')?.textContent??null, + oneColumn:document.querySelector('.flow-layout')?.classList.contains('one-column')??false, + claims:document.querySelectorAll('.claims').length, + quiet:document.querySelector('.claims-quiet')?.textContent??null, + walletLine:[...document.querySelectorAll('.flow-layout > section.panel > p')].map(e=>e.textContent.replace(/\s+/g,' ').trim()).find(t=>t.startsWith('Showing '))??null, + depositStatus:[...document.querySelectorAll('.deposit-state')].map(e=>({ + compact:!!e.closest('.deposit-summary'),label:e.querySelector(':scope > strong')?.textContent, + labelBox:box(e.querySelector(':scope > strong')), + paragraphs:[...e.querySelectorAll(':scope > p')].map(p=>({text:p.textContent.replace(/\s+/g,' ').trim(),...box(p),visible:p.getBoundingClientRect().height>0&&getComputedStyle(p).visibility!=='hidden'})), + })), + depositOpen:document.querySelector('.deposit-open')?{text:document.querySelector('.deposit-open').textContent.replace(/\s+/g,' ').trim(),...box('.deposit-open')}:null, + flowSteps:document.querySelector('.flow-steps')?{text:document.querySelector('.flow-steps').textContent.replace(/\s+/g,' ').trim(),...box('.flow-steps')}:null, + depositSummary:box('.deposit-summary'), + led:ledGeometry(), + sideways:document.documentElement.scrollWidth>innerWidth, + }; +} +function elementSnapshots(es) { + const docs=[...document.querySelectorAll('.docs-grid > .panel')]; + return 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'),inClaims:!!e.closest('.claims'), + inRedeemAside:!!e.closest('.flow-layout > aside.panel')&&e.closest('aside').querySelector('h2')?.textContent==='Take your basket home', + docsIndex:docs.indexOf(e.closest('.docs-grid > .panel')), + })); +} + +try { + await page.route('**/*',async route=>{ + const url=route.request().url(); + if(url.startsWith(base+'/'))return route.continue(); + if(RPC.test(url)&&route.request().method()==='OPTIONS')return route.fulfill({status:204,headers:{'access-control-allow-origin':'*','access-control-allow-headers':'*','access-control-allow-methods':'POST'}}); + if(RPC.test(url)&&route.request().method()==='POST') { + inflight++; + try { + const body=route.request().postDataJSON(); + const result=Array.isArray(body)?await Promise.all(body.map(c=>answer(url,c))):await answer(url,body); + await route.fulfill({contentType:'application/json',body:JSON.stringify(result)}); + }catch(e){routingErrors.push(e.message);await route.abort();} + finally{inflight--;} + return; + } + routingErrors.push('Blocked unexpected request: '+url);await route.abort(); + }); + for(const statusCase of ['normal','closed','weekend']) for(const variant of ['no-wallet','connected']) for(const name of (statusCase==='weekend'?['deposit']:statusCase==='closed'?['vault','deposit']:['vault','deposit','redeem','docs','owner','losses'])) { + statusFixture=statusCase==='weekend'?3:statusCase==='closed'?14:0; + const beforeFixtureReads=statusFixtureReads,beforeBlocks=weekendBlockReads; + const snapshots=[],layout=[]; + for(const ver of ['old','new']) { + await page.goto(`${base}/${ver}/?${variant}#${name}`); + await page.evaluate(()=>document.fonts.ready); + await page.waitForFunction(()=>!document.querySelector('button.refresh').disabled); + await page.waitForFunction(()=>[...document.images].every(i=>i.complete&&i.naturalWidth>0)); + for(let quiet=0;quiet<2;){await page.waitForTimeout(100);quiet=inflight===0?quiet+1:0;} + await page.waitForFunction(()=>![...document.querySelectorAll('main p, main small, main button')].some(e=>/Reading\.\.\.|Reading your wallet/.test(e.textContent))); + await page.evaluate(()=>scrollTo(0,0)); + // The complete original selector stays; metadata only narrows the v12 exceptions. + snapshots.push(await page.locator('main p, main input, main button, main select, main textarea, main summary, main .empty, main .note').evaluateAll(elementSnapshots)); + await page.evaluate(source=>{window.ledGeometry=Function('return ('+source+')')();},ledGeometry.toString()); + layout.push(await page.evaluate(layoutSnapshot)); + if(name==='vault'&&ver==='new')assertCapacity(await page.evaluate(capacityGeometry)); + if(name==='vault')layout.at(-1).capacity=await page.locator('.stats > div').nth(3).evaluate(e=>({label:e.querySelector(':scope > span').firstChild.textContent.trim(),value:e.querySelector(':scope > strong').textContent})); + } + const [old,now]=snapshots,[oldLayout,newLayout]=layout,regressions=[],checks=[],exceptions=[]; + const hintGrowth=(newLayout.hint?.height??0)-(oldLayout.hint?.height??0); + const counterpart=x=>now.filter(y=>y.tag===x.tag&&words(y.text)===words(x.text)&&y.inTitle===x.inTitle&&y.inClaims===x.inClaims&&y.docsIndex===x.docsIndex).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); + const docsGrowth=newLayout.docsAddedHeight; + if(name==='vault') { + assert.equal(newLayout.heading,'Always in stock'); + assert.deepEqual(oldLayout.titleParagraphs,[removedVault]); + assert.deepEqual(newLayout.titleParagraphs,[]); + assert.ok(newLayout.title.height<=oldLayout.title.height+0.5,'Vault panel must not get taller'); + assert.equal(oldLayout.capacity.label,'Size limit','Baseline retains the old capacity label'); + assert.equal(newLayout.capacity.value,oldLayout.capacity.value,'Capacity figure value remains unchanged'); + checks.push('v12 Vault: exact replacement heading, sentence and paragraph removed, panel does not grow; exact capacity label with a smaller note underneath, unchanged figure value'); + } + assert.ok(statusFixtureReads>beforeFixtureReads,'State comparison consumes the existing depositStatus fixture'); + assert.equal(oldLayout.led.signs.length,0,'Baseline has no LED sign'); + if(statusCase==='normal') { + assert.equal(newLayout.depositStatus.length,0,'Normal operation has no extra status area'); + assert.equal(newLayout.led.signs.length,0,'Normal operation has no sign'); + assert.equal(newLayout.depositOpen,null,'Normal operation has no open-only paragraph'); + if(name==='vault')assert.equal(newLayout.depositSummary.height,0,'Empty Vault status wrapper takes no space'); + } else { + assert.equal(newLayout.depositStatus.length,1,'Exactly one issue notice on '+name); + const status=newLayout.depositStatus[0]; + assert.equal(status.label,name==='vault'?'Vault status:':undefined,'Only Vault has the compact status label'); + assert.equal(!!status.labelBox&&status.labelBox.height>0,name==='vault','Only Vault shows the status label'); + assert.equal(status.paragraphs.length,1,'Exactly one short status paragraph; old hours/reason detail is absent'); + assert.ok(status.paragraphs[0].visible&&status.paragraphs[0].text.length>0,'Current status stays visible'); + assert.equal(status.paragraphs[0].text,name==='vault'?'Closed':statusCase==='weekend'?WEEKEND_NOTICE:ISSUE_NOTICE,'Exact owner-approved short notice'); + assert.equal(status.compact,name==='vault','Compact display is scoped to Vault'); + assert.equal(newLayout.led.reopening,null,'Old reopening/countdown paragraphs are absent'); + assert.equal(newLayout.depositOpen,null,'Deposit no longer uses the open-only line'); + if(statusCase==='closed')assert.equal(oldLayout.depositStatus[0].paragraphs[0].text,'too few fresh prices','Baseline proves the same reason fixture'); + else assert.ok(weekendBlockReads>beforeBlocks,'Weekend comparison uses the public-block timestamp fixture'); + assert.equal(newLayout.led.signs.length,name==='deposit'?1:0,'Only Deposit has the known-closed sign'); + if(name==='deposit') { + const sign=newLayout.led.signs[0]; + assert.equal(sign.label,'Closed','No Open sign exists'); + assert.equal(sign.inline&&sign.onDeposit&&sign.inside,true,'Small inline sign remains in Deposit status'); + assert.ok(sign.width>0&&sign.width<=220&&sign.height>0); + assert.equal(sign.animations,0,'LED is still under reduced motion'); + } + } + if(name==='deposit') { + assert.equal(newLayout.walletLine,null,'Owner removed the wallet-switch instruction from Deposit'); + assert.equal(newLayout.flowSteps.text,oldLayout.flowSteps.text,'Task instructions keep their words'); + if(oldLayout.flowSteps.bottom<=812)assert.ok(newLayout.flowSteps.bottom<=812,'Previously visible Deposit task instructions remain on the first screen'); + } + checks.push(statusCase==='normal'?'Owner follow-up: no normal status area/sign':'Owner follow-up: exact single '+statusCase+' sentence, compact Vault Closed notice, Deposit-only CLOSED sign'); + if(name==='docs') { + assert.equal(oldLayout.docsParagraphs.length,1); + assert.equal(newLayout.docsParagraphs.length,5); + assert.deepEqual(newLayout.docsParagraphs.slice(1).map(p=>p.text),docsAdded); + assert.equal(newLayout.docsParagraphs[0].text,oldLayout.docsParagraphs[0].text); + assert.ok(near(newLayout.docsParagraphs[0].top,oldLayout.docsParagraphs[0].top)&&near(newLayout.docsParagraphs[0].height,oldLayout.docsParagraphs[0].height),'Docs existing paragraph keeps its geometry'); + assert.ok(docsGrowth>0&&near(newLayout.docs[0].height-oldLayout.docs[0].height,docsGrowth),'Docs grows only by the four paragraphs including their spacing'); + assert.equal(newLayout.docs.length,oldLayout.docs.length); + for(let i=1;i0&&y&&near(y.y-x.y,docsGrowth)&&near(y.bottom-x.bottom,docsGrowth)) { + exceptions.push({reason:'SPEC 2 exact added-paragraph displacement',element:x,now:y});continue; + } + if(y&&y.bottom<=812) { + if(name==='vault'&&(y.y>x.y+0.5||y.bottom>x.bottom+0.5))regressions.push({...x,now:y,reason:'Vault content moved downward'}); + continue; + } + regressions.push({...x,now:y}); + } + // Original slogan-strip and title-character assertions, with explicit header + // height and overflow pins added. No v12 exception applies to these parts. + checks.push('header retains its exact position and height'); + if(!near(newLayout.header.top,oldLayout.header.top)||!near(newLayout.header.height,oldLayout.header.height))regressions.push({tag:'HEADER',text:'header',old:oldLayout.header,now:newLayout.header}); + checks.push('slogan strip in the same place and size, still and full under reduced motion'); + {const o=oldLayout.strip,n=newLayout.strip;if(Math.abs(n.top-o.top)>0.01||Math.abs(n.height-o.height)>0.01||n.animations!==0||!n.still)regressions.push({tag:'DIV',text:'slogan strip',old:o,now:n});} + 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}); + } + assert.equal(newLayout.sideways,false,'No horizontal overflow'); + // Keep the shipped report small; full layout values are used above by every + // assertion, while the report retains the prior elements and measured deltas. + results.push({statusCase,variant,name,prior,hintGrowth,checks,exceptions,regressions, + headerHeights:[oldLayout.header.height,newLayout.header.height], + ...(name==='docs'?{docsAddedHeight:docsGrowth}:{}), + }); + } + assert.deepEqual(routingErrors,[],'Only local files and allowed public RPC reads; fixture account reads stay synthetic'); + for(const r of results)if(r.regressions.length)throw Error(JSON.stringify({variant:r.variant,name:r.name,regressions:r.regressions})); + console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.prior.length,0),'previous first-screen elements preserved except exact named v12 removals and Docs displacement; header height, slogan strip and character assertions retained'); + fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2)+'\n'); +}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 2129784..3516071 100644 --- a/web/scripts/run-browser.mjs +++ b/web/scripts/run-browser.mjs @@ -464,7 +464,9 @@ try { const consoleErrors = [], asked = [], revoked = []; - let lastSent; + let lastSent, lastMined, speedNext = false, repricing; + let holdConnect = false, releaseConnect, emptyAccounts = false; + const v12Checks = []; page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); @@ -476,8 +478,10 @@ try { pendingNext = false; await page.exposeFunction("testWallet", async ({ method, params }) => { asked.push(method); + if (method === "eth_requestAccounts" && holdConnect) + return new Promise((resolve) => { releaseConnect = () => resolve([currentAccount]); }); if (method === "eth_accounts" || method === "eth_requestAccounts") - return [currentAccount]; + return emptyAccounts ? [] : [currentAccount]; if (method === "eth_chainId") return currentChain; if (method === "wallet_switchEthereumChain") { currentChain = "0x1237"; @@ -501,7 +505,18 @@ try { const decoded = decodeFunctionData({ abi, data: t.data }); assert.equal(await raw("eth_chainId"), "0x1237"); sends.push({ to: t.to, ...decoded, gas: t.gas }); - return (lastSent = await raw(method, params)); + // v12: reprice one existing deposit in the mock, without adding a page send. + if (speedNext) { + speedNext = false; + await raw("evm_setAutomine", [false]); + const nonce = await raw("eth_getTransactionCount", [t.from, "pending"]); + const gasPrice = BigInt(await raw("eth_gasPrice")) * 2n; + const original = await raw(method, [{ ...t, nonce, gasPrice: toHex(gasPrice) }]); + repricing = { original, t, nonce, gasPrice, scheduled: false }; + return (lastSent = original); + } + lastMined = lastSent = await raw(method, params); + return lastSent; } if (method === "wallet_revokePermissions") { // Disconnect is local to this page and must never make this request. @@ -533,6 +548,8 @@ try { // v11: the count of answered wallet requests lets a step wait until the // page has had the wallet's answer. The page still gets the same result // or the same error, and an error it does not catch is still reported. + const listeners = new Map(); + window.__accounts = (a) => listeners.get("accountsChanged")?.(a); window.ethereum = { request: async (a) => { try { @@ -541,8 +558,8 @@ try { window.__walletAnswers = (window.__walletAnswers ?? 0) + 1; } }, - on() {}, - removeListener() {}, + on(event, fn) { listeners.set(event, fn); }, + removeListener(event, fn) { if (listeners.get(event) === fn) listeners.delete(event); }, }; }, { now }, @@ -578,6 +595,19 @@ try { b.params?.[0]?.data?.slice(0, 10), response.error, ); + // Wait until viem has asked for the original pending transaction, so it + // can identify the same-nonce replacement on the next mined block. + if (repricing && !repricing.scheduled && b.method === "eth_getTransactionByHash" && b.params[0] === repricing.original) { + repricing.scheduled = true; + setTimeout(async () => { + try { + const r = repricing; + lastMined = await raw("eth_sendTransaction", [{ ...r.t, nonce: r.nonce, gasPrice: toHex(r.gasPrice * 2n) }]); + await raw("evm_mine"); + await raw("evm_setAutomine", [true]); + } catch (e) { issues.push("Speed up fixture: " + e.message); } + }, 500); + } return response; }; const result = Array.isArray(body) @@ -720,15 +750,24 @@ try { false, name, ); - // The status at the top of the Deposit form: the one line while the vault - // takes deposits, otherwise the lines of the full box. Never both. - async function statusShown() { + // Latest owner decision: no normal notice; retain the full closure/issue box. + async function statusShown(shown = true) { const line = page.locator(".flow-layout .deposit-open"), - box = page.locator(".flow-layout .deposit-state"); - assert.equal((await line.count()) + (await box.count()), 1); - return (await line.count()) - ? [await line.innerText()] - : box.locator("strong, p").allInnerTexts(); + box = page.locator(".flow-layout > section.panel > .deposit-state"); + assert.equal(await line.count(), 0); + if (!shown) { + assert.equal(await box.count(), 0, "normal deposits show no status box"); + assert.equal(await page.locator(".deposit-led-sign").count(), 0, "normal deposits show no sign"); + return []; + } + await box.waitFor(); + assert.equal(await box.count(), 1); + const words = await box.locator("strong, p").allInnerTexts(); + const sign = box.locator(".deposit-led-sign"); + assert.equal(await sign.count(), 1, "known deposit status has its approved sign"); + assert.equal(await sign.getAttribute("role"), "img"); + assert.equal(await sign.getAttribute("aria-label"), "Closed", "only a known closure displays the Closed sign"); + return words; } // previewDeposit reads the page has made so far. const previewDeposit = encodeFunctionData({ @@ -792,14 +831,13 @@ try { `${state} ${name} overflow ${width}`, ); const file = `${state}-${name.toLowerCase()}-${width}.jpg`; - // Quality 32 (36 in v9 and v10, 40 before): the v11 Deposit form has a - // row for every stock in the wallet, so the two "stocks25" Deposit - // pictures are much taller. The quality-36 set is about 310 KB larger - // than this one and no longer fits the 8 MiB delivery budget. + // Quality 26 in v12 (32 in v11, 36 in v9/v10, 40 before): the 24 + // readable full-page screenshots leave at least 250,000 bytes within + // the 8 MiB delivery budget, including the two share-picture copies. await page.screenshot({ path: path.join(artifacts, file), type: "jpeg", - quality: 32, + quality: 26, fullPage: true, }); shots.push(file); @@ -810,6 +848,70 @@ try { console.log("Taking empty screenshots"); await screenshots("empty"); console.log("Empty screenshots complete"); + // v12 SPEC sections 1-5: exact visitor words and structure on real pages. + await nav("Vault"); + await page.getByRole("heading", { name: "Always in stock", exact: true }).waitFor(); + assert.equal(await page.locator("h1").textContent(), "Always in stock"); + assert.equal(await page.locator(".page-title > p").count(), 0); + assert.equal(await page.locator(".page-title p:empty").count(), 0); + const capacityLabel = page.locator(".stats > div").nth(3).locator("span"); + assert.equal(await capacityLabel.evaluate(element => [...element.childNodes].filter(node => node.nodeType === Node.TEXT_NODE).map(node => node.textContent).join("")), "Maximum Basket Capacity"); + assert.equal(await capacityLabel.locator("small").innerText(), "(eligible to be raised)"); + assert.ok(await capacityLabel.locator("small").evaluate(element => getComputedStyle(element).display === "block" && parseFloat(getComputedStyle(element).fontSize) < parseFloat(getComputedStyle(element.parentElement).fontSize)), "capacity qualification is a smaller separate line"); + const compactStatus = page.locator(".deposit-summary > .deposit-state"); + await compactStatus.waitFor(); + assert.equal(await compactStatus.count(), 1); + assert.equal(await page.locator(".deposit-led-sign").count(), 0, "approved sign belongs only on Deposit"); + assert.equal(await compactStatus.locator(":scope > strong").innerText(), "Vault status:"); + const compactParagraphs = await compactStatus.locator(":scope > p").evaluateAll(elements => elements.map(element => ({text:element.textContent,visible:element.getClientRects().length > 0}))); + assert.equal(compactParagraphs.length, 1, "compact Vault has one plain closure paragraph"); + assert.deepEqual(compactParagraphs[0], {text:"Closed",visible:true}, "compact Vault uses the approved plain Closed notice"); + assert.ok(compactParagraphs.slice(1).every(paragraph => !paragraph.visible), "only the first status paragraph is visible on Vault"); + if (await compactStatus.locator(":scope > button").count()) assert.ok(await compactStatus.locator(":scope > button").isVisible(), "Retry vault remains visible"); + v12Checks.push("Vault: Always in stock, no sentence or empty paragraph; compact deposit status shows existing label and status only"); + await nav("Docs"); + await page.getByRole("heading", { name: "Docs", exact: true }).waitFor(); + assert.deepEqual(await page.locator(".docs-grid > section").first().locator("p").allTextContents().then(xs => xs.map(x => x.replace(/\s+/g, " ").trim())), ["One token, BASK, backed by a basket of Stock Tokens held in a vault on Robinhood Chain. Deposit one or more listed Stock Tokens at once and receive BASK. Redeeming BASK pays a share of every stock in the vault.","The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.","BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.","The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM.","ITEM is not live yet. Its address will be listed here when it is."]); + v12Checks.push("Docs: original paragraph and four approved paragraphs, word for word"); + await nav("Deposit"); + await page.getByRole("heading", { name: "Deposit Stock Tokens", exact: true }).waitFor(); + const beforeDeposit = page.locator("aside.panel"); + assert.equal(await beforeDeposit.locator("details").count(), 0); + assert.equal(await beforeDeposit.getByText(/^Size limit: /).count(), 1, "Deposit aside retains Size limit"); + for (const text of ["You approve each stock for exactly the amount you deposit. Each approval is its own wallet prompt; the deposit prompt comes last.", "A prepared deposit must be sent within ten minutes. The BASK goes to your connected wallet."]) + assert.equal(await beforeDeposit.getByText(text, {exact:true}).count(), 1); + const fullDepositStatus = page.locator(".flow-layout > section.panel > .deposit-state"); + await fullDepositStatus.waitFor(); + assert.equal(await fullDepositStatus.count(), 1); + await fullDepositStatus.getByRole("img", {name:"Closed",exact:true}).waitFor(); + assert.equal(await fullDepositStatus.locator(".deposit-led-sign").count(), 1, "unfinalized initial fixture shows Closed"); + assert.equal(await page.locator(".flow-layout .deposit-open").count(), 0); + assert.equal(await page.locator(".deposit-summary").count(), 0); + assert.equal(await page.locator(".wallet-line").count(), 0, "owner removed the Deposit wallet instruction line"); + assert.equal(await fullDepositStatus.locator(":scope > strong").count(), 0, "Deposit notice has only its Closed sign and sentence"); + assert.ok(await fullDepositStatus.evaluate(element => element.previousElementSibling?.classList.contains("hours-tag") && element.parentElement.firstElementChild === element.previousElementSibling), "full deposit status follows the unchanged first-child 24/7 pill"); + assert.deepEqual(await fullDepositStatus.locator(":scope > p").allInnerTexts(), ["Deposits will be back open when “genesis not finished” is resolved."], "initial setup has exactly one approved issue sentence"); + v12Checks.push("Deposit: Before you deposit shows both approved paragraphs without a fold; full deposit status follows the 24/7 pill"); + await nav("Redeem"); + await page.getByRole("heading", { name: "Redeem BASK", exact: true }).waitFor(); + assert.equal(await page.locator(".flow-layout.one-column").count(), 1); + assert.equal(await page.getByRole("heading", {name:"Take your basket home",exact:true}).count(), 0); + assert.equal(await page.locator(".wallet-line").count(), 0, "owner removed the Redeem wallet instruction line"); + v12Checks.push("Redeem: one column, removed aside and no wallet instruction line"); + await page.getByText("Nothing is owed to this wallet.", {exact:true}).waitFor(); + assert.equal(await page.locator("section.claims").count(), 0); + v12Checks.push("Claims: connected empty wallet shows quiet line without panel"); + const shell = await (await page.request.get(url)).text(); + assert.deepEqual(await page.evaluate(html => { + const doc = new DOMParser().parseFromString(html, "text/html"); + return ["og:title", "og:description", "og:image", "twitter:card"].map(key => + doc.querySelector('meta[property="'+key+'"],meta[name="'+key+'"]')?.getAttribute("content")); + }, shell), ["Basket Protocol", "Basket Protocol. View the BASK vault, deposit Stock Tokens, redeem and manage claims.", "https://basket-protocol.sites.imd.fun/share-card.jpg", "summary_large_image"]); + const jpeg = Buffer.from(await (await page.request.get(url+"share-card.jpg")).body()); + assert.equal(jpeg.readUInt16BE(0), 0xffd8); + assert.ok(jpeg.length <= 45000); + assert.deepEqual(await page.evaluate(async (src) => {const i=new Image(); i.src=src; await i.decode(); return [i.naturalWidth,i.naturalHeight];}, url+"share-card.jpg"),[1200,630]); + v12Checks.push("Link preview: served approved share tags and 1200x630 JPEG"); await nav("Owner"); const indices = stocks.map((_, i) => i).filter((i) => i !== 3); const lines = indices @@ -982,19 +1084,15 @@ try { const hoursTag = "Redemptions are open 24/7. Deposit hours apply to deposits only."; await nav("Deposit"); await refresh(); - await page.getByText(/^Deposits are closed until stock prices update \(at least 1 must have updated in the last 1 hour\(s\), e\.g\. a US market holiday\); no set time\. Redemptions are always open\.$/).waitFor(); + await page.getByText("Deposits will be back open when “too few fresh prices” is resolved.", {exact:true}).waitFor(); await page.getByText(hoursTag, { exact: true }).waitFor(); assert.equal(await page.getByText(/Deposits reopen /).count(), 0, "no reopen time on a holiday"); - // v11: closed, so the status is the full box: title, reason, when it - // reopens, hours. + // Latest owner wording: Closed sign and one short issue sentence. const closedBox = await statusShown(); - assert.deepEqual(closedBox.slice(0, 2), [ - "Vault deposit status", - "too few fresh prices", - ]); - assert.match(closedBox[2], /^Deposits are closed until stock prices update /); - assert.match(closedBox[3], /^Hours: \S/); - assert.equal(closedBox.length, 4); + assert.deepEqual(closedBox, ["Deposits will be back open when “too few fresh prices” is resolved."]); + assert.equal(await page.locator(".flow-layout .deposit-state > strong").count(), 0); + assert.equal(await page.locator(".flow-layout .deposit-state").getByText(/^Hours:/).count(), 0); + assert.equal(closedBox.length, 1); // v11: the wallet's stocks are rows from the start, in the vault's order // while their values are equal (v10 had to press "Add FIG" here). await page.getByLabel("FIG amount", { exact: true }).waitFor(); @@ -1074,15 +1172,13 @@ try { assert.equal(Number((await rv("depositStatus", [[]]))[0]), 0); await refresh(); checks.push("One updated feed reopens deposits after the holiday"); - // v11: open, so the status is one line, with the same hours. After this read - // S22 is a row (the wallet holds it now) and a removed row stays removed. - assert.deepEqual(await statusShown(), [ - "Deposits are open. " + closedBox[3], - ]); + // The latest owner decision removes the normal status notice. S22 is a row + // now, and a removed row stays removed. + assert.deepEqual(await statusShown(false), []); await page.getByLabel("S22 amount", { exact: true }).waitFor(); assert.equal(await page.getByLabel("S10 amount", { exact: true }).count(), 0); checks.push( - "Deposit status: one line while deposits are open, the full Vault deposit status box while closed", + "Deposit status: no normal notice; Closed sign and one approved closure sentence", ); // v11: the list under the search, closed until pressed, holds every stock // the vault takes now. A stock that has a row is switched off; S10 to S16 @@ -1197,9 +1293,7 @@ try { const greyed = await look("S03"); assert.ok(greyed[0] !== plain[0] && greyed[1] !== plain[1], "muted look"); assert.deepEqual(await look("S02"), plain); - assert.deepEqual(await statusShown(), [ - "Deposits are open. " + closedBox[3], - ]); + assert.deepEqual(await statusShown(false), []); // Undo it on the chain; the row's own Refresh reads the vault again. await tx(feeds[grey], compiled.TestFeed.abi, "setLag", [lagBefore]); assert.equal(await pc.readContract(feedLag), lagBefore); @@ -1350,6 +1444,7 @@ try { { exact: true }, ) .waitFor({ timeout: 30000 }); + speedNext = true; await sendClick( page.getByRole("button", { name: "Deposit", exact: true }), "deposit", @@ -1396,7 +1491,7 @@ try { ); // v11: "You received" is the shares of the Deposit event in the receipt of // that same transaction, read and decoded here. - const minted = (await pc.getTransactionReceipt({ hash: lastSent })).logs + const minted = (await pc.getTransactionReceipt({ hash: lastMined })).logs .filter((l) => l.address.toLowerCase() === VAULT) .map((l) => { try { @@ -1420,6 +1515,19 @@ try { checks.push( `"You received ${fmt(minted.args.shares)} BASK." equals the shares of the Deposit event in the deposit's receipt`, ); + assert.ok(repricing?.scheduled && lastSent !== lastMined); + await page.getByText("Deposit: Confirmed.", { exact:true }).waitFor(); + assert.ok((await page.locator(".wallet-status a").getAttribute("href")).endsWith(lastMined)); + const links = await page.locator("main a[href*='/tx/']").evaluateAll(es=>es.map(e=>e.getAttribute("href"))); + assert.ok(links.some(x=>x.endsWith(lastMined))); + assert.ok(links.every(x=>!x.endsWith(lastSent))); + const replacedTx = await pc.getTransaction({hash:lastMined}); + assert.equal(replacedTx.to.toLowerCase(), repricing.t.to.toLowerCase()); + assert.equal(replacedTx.input, repricing.t.data); + assert.equal(replacedTx.nonce, Number(BigInt(repricing.nonce))); + assert.ok(replacedTx.gasPrice > repricing.gasPrice); + assert.equal(await raw("eth_getTransactionReceipt", [lastSent]), null); + v12Checks.push("Speed up: existing deposit repriced with same nonce/to/data; Confirmed, received event amount, status and top links use mined hash"); // v11 Disconnect: the page forgets the wallet and stays so across a reload, // until Connect wallet. It makes no wallet request or send, so no delayed // revocation from this page can undo a later connection. @@ -1498,14 +1606,11 @@ try { await page.reload(); await nav("Deposit"); await refresh(); - await page.getByText(/^Deposits reopen Sunday 8:00 pm New York time \(Sunday, .* in \d+ h \d+ min; your time .*\)\. On a US market holiday they reopen when prices update\. Redemptions are always open\.$/).waitFor(); + await page.getByText("Deposits reopen when the stock feed prices update around 8pm on Sundays.", {exact:true}).waitFor(); await page.getByText(hoursTag, { exact: true }).waitFor(); // v11: closed, so the full status box again. The wallet is still connected // after this reload and FIG has its row (v10 pressed "Add FIG" here). - assert.deepEqual((await statusShown()).slice(0, 2), [ - "Vault deposit status", - "outside deposit hours", - ]); + assert.deepEqual(await statusShown(), ["Deposits reopen when the stock feed prices update around 8pm on Sundays."]); assert.equal(await address.innerText(), short); await page.getByLabel("FIG amount", { exact: true }).fill("1"); await page.getByRole("button", { name: "Preview deposit", exact: true }).click(); @@ -1544,6 +1649,12 @@ try { checks.push("Redeem: the BASK amount box is empty after a confirmed redeem"); assert.ok(sends.at(-1).gas); assert.ok((await rv("owed", [owner, stocks[0]])) > 0n); + // v12 SPEC 4: Claims finishes its own owed read after the wallet's vault refresh. + // Wait for the newly owed stock before asserting the finished presentation. + await page.getByRole("button", { name: "Claim FIG", exact: true }).waitFor(); + assert.equal(await page.locator("section.claims").count(),1); + assert.equal(await page.locator("section.claims details").count(),0); + assert.equal(await page.getByText(/^Claim all collects up to 10/).count(),0); await tx(stocks[0], compiled.TestStock.abi, "setFail", [false]); await refresh(); await sendClick( @@ -1566,6 +1677,8 @@ try { await page.getByLabel("BASK amount", { exact: true }).inputValue(), "", ); + await page.getByText("Claim all collects up to 10 stocks per wallet prompt. If one stock can’t be paid, that batch stays owed: collect the others with their own Claim buttons.", {exact:true}).waitFor(); + assert.equal(await page.locator("section.claims details").count(),0); for (const t of stocks.slice(0, 2)) await tx(t, compiled.TestStock.abi, "setFail", [false]); await refresh(); @@ -1575,6 +1688,16 @@ try { ); assert.equal(sends.at(-1).args[0].length, 2); checks.push("Saturday redeem, single claim and Claim all succeed outside deposit hours"); + await page.locator("section.claims").getByText("Nothing is owed to your connected wallet.", { exact: true }).waitFor(); + assert.equal(await page.locator("section.claims .claim-grid > .receipt").count(), 0); + await page.waitForFunction(()=>document.querySelector("section.claims")?.textContent.includes("Confirmed.")); + assert.deepEqual(await Promise.all(stocks.slice(0,2).map(t=>rv("owed",[owner,t]))),[0n,0n]); + await page.locator('nav a[href="#docs"]').click(); + await page.locator('nav a[href="#redeem"]').click(); + await page.locator("section.claims").getByText("Nothing is owed to your connected wallet.", { exact: true }).waitFor(); + assert.equal(await page.locator("section.claims .claim-grid > .receipt").count(), 0); + await page.waitForFunction(()=>document.querySelector("section.claims")?.textContent.includes("Confirmed.")); + v12Checks.push("Claims: one/two owed show panel with exact conditional note and no zero fold; last claim confirmation survives navigation"); // Immediate controls and each proposal form. await nav("Owner"); const closeForm = action("Close a stock"); @@ -1818,19 +1941,16 @@ try { page.getByRole("button", { name: "Unpause deposits", exact: true }), "unpauseDeposits", ); - // A Saturday under the new hours (deposits unpaused) shows the Monday 9:30 am reopening (UTC-5). + // Changed Hours/Dst still govern the chain; the notice uses the approved short issue sentence. await warpTo(await saturday()); assert.equal(Number((await rv("depositStatus", [[]]))[0]), 3); await page.reload(); await nav("Deposit"); await refresh(); - await page.getByText(/^Deposits reopen Monday 9:30 am UTC-5 \(Monday, /).waitFor(); + await page.getByText("Deposits will be back open when “outside deposit hours” is resolved.", {exact:true}).waitFor(); // v11: closed, so the full status box. - assert.deepEqual((await statusShown()).slice(0, 2), [ - "Vault deposit status", - "outside deposit hours", - ]); - checks.push("Saturday after the Hours change shows Deposits reopen Monday 9:30 am"); + assert.deepEqual(await statusShown(), ["Deposits will be back open when “outside deposit hours” is resolved."]); + checks.push("Saturday after the Hours change shows the approved short outside-hours closure notice"); await nav("Owner"); // Cancel owner and guardian proposal boundaries, without changing the deployed roles. await form("Recentre", { "Stock Token": stocks[0] }, "Propose Recentre"); @@ -1904,6 +2024,54 @@ try { ], ); assert.equal(revoked.length, 0); + // v12: late account answer may not undo a later Disconnect, even when an + // accountsChanged event arrived first. No transaction is sent by this case. + emptyAccounts = true; + await page.evaluate(()=>localStorage.removeItem("basket-disconnected-4663")); + await page.reload(); + await page.waitForFunction(()=>window.__walletAnswers>=2); + await page.locator('nav a[href="#redeem"]').click(); + await page.getByText("Connect the receiver wallet to read and claim its owed stocks.",{exact:true}).waitFor(); + assert.equal(await page.locator("section.claims").count(),0); + emptyAccounts = false; + holdConnect = true; + await page.getByRole("button",{name:"Connect wallet",exact:true}).click(); + await page.waitForFunction(()=>[...document.querySelectorAll("button")].some(b=>b.textContent.includes("Connecting"))); + await page.evaluate(a=>window.__accounts([a]),currentAccount); + await page.getByRole("button",{name:"Disconnect",exact:true}).click(); + const requestsAfterDisconnect=asked.length; + assert.equal(typeof releaseConnect,"function"); + releaseConnect(); holdConnect=false; + await page.getByRole("button",{name:"Connect wallet",exact:true}).waitFor(); + await page.waitForFunction(()=>![...document.querySelectorAll("button")].find(b=>b.textContent==="Connect wallet")?.disabled); + assert.equal(await page.locator(".connected-address").count(),0); + assert.equal(await page.evaluate(()=>localStorage.getItem("basket-disconnected-4663")),"1"); + assert.equal(asked.length,requestsAfterDisconnect); + assert.match(await page.locator(".wallet-status").innerText(),/^Wallet disconnected from this page/); + await walletAnswers(2,()=>page.getByRole("button",{name:"Connect wallet",exact:true}).click()); + await page.locator(".connected-address").waitFor(); + assert.equal(await page.evaluate(()=>localStorage.getItem("basket-disconnected-4663")),null); + v12Checks.push("Late Connect: early account event, Disconnect, held reply dropped without request; normal Connect then succeeds; no-wallet quiet claims line"); + // v12 portable wide-font stress: force the complete bold line wider than + // its available space, then require whole words inside the pill and panel. + for(const name of ["Deposit","Redeem"]) { + await nav(name); await page.setViewportSize({width:661,height:1000}); + const facts=await page.evaluate(()=>{ + const tag=document.querySelector(".hours-tag"),strong=tag.querySelector("strong"),panel=tag.closest("section.panel"); + const probe=strong.cloneNode(true);probe.style.cssText="position:fixed;white-space:nowrap;width:max-content;visibility:hidden";tag.append(probe); + let spacing=0;while(probe.getBoundingClientRect().widthx.width);rects.forEach(x=>tops.add(x.top));if(new Set(rects.map(x=>x.top)).size!==1||rects.some(x=>x.leftr.right||x.topr.bottom))bad.push(m[0]);} + return {natural,lines:tops.size,bad,inside:r.left>=p.left&&r.right<=p.right,overflow:document.documentElement.scrollWidth>innerWidth}; + }); + assert.ok(facts.natural>=240&&facts.lines>1&&facts.inside&&!facts.overflow,JSON.stringify(facts));assert.deepEqual(facts.bad,[]); + } + v12Checks.push("24/7 label: widened text wraps at whole words inside pill and panel at 661px on Deposit and Redeem"); + checks.push(...v12Checks); + assert.deepEqual([...new Set(asked)].sort(),["eth_accounts","eth_chainId","eth_requestAccounts","eth_sendTransaction"]); + assert.equal(revoked.length,0); + assert.deepEqual(issues,[]); fs.writeFileSync( path.join(artifacts, "browser-fork.json"), JSON.stringify( diff --git a/web/src/Docs.tsx b/web/src/Docs.tsx index 3265653..279b07c 100644 --- a/web/src/Docs.tsx +++ b/web/src/Docs.tsx @@ -15,6 +15,18 @@ export function DocsPage() { and receive BASK. Redeeming BASK pays a share of every stock in the vault.

+

+ The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption. +

+

+ BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing. +

+

+ The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM. +

+

+ ITEM is not live yet. Its address will be listed here when it is. +

Deposits

diff --git a/web/src/Flows.tsx b/web/src/Flows.tsx index 19c3752..0762c7d 100644 --- a/web/src/Flows.tsx +++ b/web/src/Flows.tsx @@ -36,7 +36,7 @@ import { actionReason, Receiver, } from "./components"; -import { DepositState, hoursLine } from "./Vault"; +import { DepositState } from "./Vault"; import type { Wallet } from "./wallet"; type Props = { snapshot: Snapshot; wallet: Wallet }; // Always shown at the top of the Deposit and Redeem forms (open or closed), so @@ -260,8 +260,6 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { } // From the last finished vault read, kept while a new read runs so the form // does not jump; undefined before the first read or when unreadable. - const r = s.status; - const reason = r?.ok ? Number(r.value[0]) : undefined; // Dollar value of n units at the feed price of the last vault read, 18 // decimals; undefined when that price is unreadable. const worth = (a: Asset, n: bigint) => @@ -406,16 +404,7 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) {
- {/* One line while the vault takes deposits; otherwise, and while the - status is unread or unreadable, the full box with its reason. */} - {reason === 0 && open.length > 0 ? ( -

- Deposits are open. Hours:{" "} - {hoursLine(s.globals.settings)} -

- ) : ( - - )} +

Choose amounts, preview, approve each stock, press Deposit.

@@ -493,11 +482,6 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { ) : ( <> {!ready &&

Reading your wallet...

} -

- Showing {w.account.slice(0, 6)}…{w.account.slice(-4)}. To - use another wallet, switch the address in your wallet app: - this page follows it. -

{unread ? (

Some balances could not be read. Press Retry balances @@ -861,18 +845,12 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { First deposit: 0.001 BASK is locked forever. The preview already subtracts this lock and any fee.

-
- Transaction details -

- Each short allowance is set to exactly that stock’s amount. - Approvals are separate wallet prompts; the deposit follows after - simulation. -

-

- The deadline is ten minutes from preparation. Your wallet receives - the BASK. -

-
+

+ You approve each stock for exactly the amount you deposit. Each approval is its own wallet prompt; the deposit prompt comes last. +

+

+ A prepared deposit must be sent within ten minutes. The BASK goes to your connected wallet. +

Feeds lag the market. Issuer restrictions and pool checks can stop deposits. @@ -940,7 +918,7 @@ export function RedeemPage({ snapshot: s, wallet: w }: Props) { > Receive your share of every stock. -
+

Redeem shares

@@ -1086,21 +1064,6 @@ export function RedeemPage({ snapshot: s, wallet: w }: Props) { {error}

-
); @@ -1141,11 +1104,45 @@ export function Claims({ snapshot: s, wallet: w }: Props) { const claimable = s.assets.filter( (a) => owed[a.token] !== undefined && owed[a.token]! > 0n, ); - const zeros = s.assets.filter((a) => owed[a.token] === 0n); const visible = [ ...claimable, ...s.assets.filter((a) => owed[a.token] === undefined), ]; + // Presentation only: keep the last finished visibility through refreshes. + // Capture old account values so neither its owed result nor its status can + // reveal the new account's panel before that account's first read finishes. + const visit = useRef({ account: w.account, initialOwed: owed, progress: w.progress, error }); + const readStart = useRef(owed); + useLayoutEffect(() => { + readStart.current = owed; + }, [w.account, s.loadedAt, retry]); + const [display, setDisplay] = useState({ account: w.account, finished: false, shown: false, items: [] as Asset[], count: 0 }); + useLayoutEffect(() => { + if (visit.current.account !== w.account) { + visit.current = { account: w.account, initialOwed: owed, progress: w.progress, error }; + setDisplay({ account: w.account, finished: false, shown: false, items: [], count: 0 }); + return; + } + if (!w.account) return; + const finished = !reading && !s.loading && owed !== readStart.current; + const status = Object.keys(w.progress).some( + (key) => (key === "claim-all" || key.startsWith("Claim ")) && + w.progress[key] !== visit.current.progress[key], + ); + const shown = working || (error !== "" && error !== visit.current.error) || status || + (finished && (!s.complete || visible.length > 0)); + if (finished || shown) + setDisplay((old) => ({ + account: w.account, finished: old.finished || finished, shown: old.shown || shown, + items: finished ? visible : old.items, count: finished ? claimable.length : old.count, + })); + }, [w.account, owed, reading, s.loading, s.complete, working, error, w.progress]); + if (!w.account) + return

Connect the receiver wallet to read and claim its owed stocks.

; + if (display.account !== w.account || !display.shown) + return display.account === w.account && display.finished + ?

Nothing is owed to this wallet.

+ : null; return (

Claim owed stocks

@@ -1230,7 +1227,7 @@ export function Claims({ snapshot: s, wallet: w }: Props) { />
- {visible.map((a) => ( + {display.items.map((a) => (
{a.symbol}

@@ -1260,12 +1257,6 @@ export function Claims({ snapshot: s, wallet: w }: Props) {

))}
- {zeros.length > 0 && ( -
- Nothing owed for {zeros.length} other stocks -

{zeros.map((a) => a.symbol).join(", ")}

-
- )} {!s.assets.length && (

{s.loading @@ -1275,12 +1266,11 @@ export function Claims({ snapshot: s, wallet: w }: Props) { : "Listed stocks unreadable. Retry the vault."}

)} -

- Batches of up to 10 stocks, with one wallet prompt per batch. If one - stock’s payment fails, the vault reverts that whole batch: nothing is - lost and every stock in it stays owed. Use each stock’s own Claim - button to collect the others. -

+ {display.count > 1 && ( +

+ Claim all collects up to 10 stocks per wallet prompt. If one stock can’t be paid, that batch stays owed: collect the others with their own Claim buttons. +

+ )} )} {Object.keys(w.progress) diff --git a/web/src/Vault.tsx b/web/src/Vault.tsx index 8ec0195..b270c95 100644 --- a/web/src/Vault.tsx +++ b/web/src/Vault.tsx @@ -118,7 +118,36 @@ function useVaultClock(s: Snapshot, closedForHours: boolean) { }); return now; } -export function DepositState({ snapshot: s }: { snapshot: Snapshot }) { +// Owner-approved display: no reads, requests or transaction state. +const DEPOSIT_LETTERS: Record = { + O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"], + E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"], + S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"], + D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], +}; + +function DepositLedSign() { + const label = "Closed"; + const word = label.toUpperCase(); + const step = 4.8; + const start = (220 - ((word.length * 6 - 2) * step)) / 2; + return ( + + + + + + {[...word].flatMap((letter, index) => DEPOSIT_LETTERS[letter].flatMap((row, y) => [...row].map((on, x) => ( + + ))))} + + + ); +} + +export function DepositState({ snapshot: s, showSign = false }: { snapshot: Snapshot; showSign?: boolean }) { const r = s.status; const reason = r?.ok && !s.loading ? Number(r.value[0]) : undefined; const now = useVaultClock(s, reason === 3); @@ -126,36 +155,37 @@ export function DepositState({ snapshot: s }: { snapshot: Snapshot }) { reason !== undefined ? closedWords(reason, s.globals.settings, s.blockTime, now) : undefined; + // The owner wants a notice only for closed deposits or an unreadable status. + const statusUnreadable = + !s.complete || !r?.ok || s.globals.genesisFinalized === undefined; + const operational = + !statusUnreadable && reason === 0 && s.globals.genesisFinalized === true && + s.assets.some((a) => a.open && !a.retired); + if (s.loading || operational || closed === mayBeOpen) return null; + const issueReason = s.globals.genesisFinalized === false ? 1 + : !s.assets.length ? 4 + : s.assets.every((a) => !a.open || a.retired) ? 5 + : reason === 13 ? 9 : reason; + const issue = reasonWords(issueReason, s.globals.settings).split(" (")[0]; + const weekendHours = reason === 3 && + s.globals.settings?.hoursFrom === 72000n && s.globals.settings?.hoursTo === 504000n; return ( -
- Vault deposit status -

- {s.loading - ? "Reading..." - : !s.assets.length && - s.complete && - s.globals.genesisFinalized === false - ? "No Stock Tokens are listed yet. Deposits start after the owner finishes setup." - : s.assets.length > 0 && - s.assets.every((a) => !a.open || a.retired) && - s.globals.genesisFinalized - ? "Every stock is closed. The owner must reopen a stock before deposits." - : !r || !r.ok - ? "unreadable — Retry vault" - : reasonWords(Number(r.value[0]), s.globals.settings)} -

- {r?.ok && r.value[1] !== zeroAddress && ( -

- Stock at fault:{" "} - {s.assets.find( - (a) => a.token.toLowerCase() === r.value[1].toLowerCase(), - )?.symbol ?? r.value[1]} -

- )} - {closed &&

{closed}

} -

Hours: {s.loading ? "Reading..." : hoursLine(s.globals.settings)}

- {!s.loading && (!r || !r.ok) && ( - +
+ {statusUnreadable ? ( + <> + {!showSign && Vault status:} +

unreadable — Retry vault

+ + + ) : showSign ? ( + <> + +

{weekendHours + ? "Deposits reopen when the stock feed prices update around 8pm on Sundays." + : `Deposits will be back open when “${issue}” is resolved.`}

+ + ) : ( + <>Vault status:

Closed

)}
); @@ -166,7 +196,7 @@ export function VaultPage({ snapshot: s }: { snapshot: Snapshot }) { return ( <> - Meet Basket (BASK): your share of the Stock Tokens in this vault. - + />

Inside the basket

@@ -205,7 +233,7 @@ export function VaultPage({ snapshot: s }: { snapshot: Snapshot }) { {s.loading ? "Reading..." : fmt(supply)}
- Size limit + Maximum Basket Capacity(eligible to be raised) {s.loading ? "Reading..." : usd(s.globals.NAV_CAP)}
@@ -216,7 +244,9 @@ export function VaultPage({ snapshot: s }: { snapshot: Snapshot }) { )}
- +
+ +
Fees

diff --git a/web/src/components.tsx b/web/src/components.tsx index c0dbcb7..5254ded 100644 --- a/web/src/components.tsx +++ b/web/src/components.tsx @@ -79,7 +79,7 @@ export function PageTitle({ }: { eyebrow?: string; title: string; - children: ReactNode; + children?: ReactNode; character?: { kind: "vault" | "deposit" | "redeem"; src: string; @@ -99,7 +99,7 @@ export function PageTitle({ > {eyebrow &&

{eyebrow}

}

{title}

-

{children}

+ {children != null &&

{children}

} {character && (
)} - {page === "Redeem" && } + + +