diff --git a/DESIGN.md b/DESIGN.md index dbd429c..ceed93a 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -33,7 +33,7 @@ H1 is 64px on desktop and 36px on phones. Title eyebrows use bold Arial at 14px/ 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 Stock shelves labels fill as many columns as fit (5 big and 7 small labels a row at 1440px, 2 big and 3 small on a 375px phone; 2 small at 360px and below, 1 big at 320px). At 960px, role and owner grids become one column. At 660px, forms, docs and claims use one column; the menu retains four legible hanging signs. Metric panels retain two columns. Each stock is a shelf label with its words written out, never a horizontally scrolling table. The data refresh action stays beside its status. Phones omit the duplicate global transaction summary, keeping the status beside its action. Phone header, title and shelf spacing is reduced so task guidance appears sooner. Long addresses wrap. Required screenshots cover 1440px and 375px; additional checks and their limits are recorded with validation. +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. ## Elevation & Depth @@ -41,7 +41,7 @@ This is a flat print-inspired system. Flyer panels use a 5px sunshine border wit ## Shapes -Panels and receipts are rectangular; controls use 3px corner radii and at least 44px height. Art combines original SVG/CSS with the manifest files kept byte for byte: the owner’s character (the only person on the site) and Microsoft Fluent Emoji flat food (MIT). The three pictures show a produce crate, an offered basket and a full grocery bag. The project’s BASKET PROTOCOL name badge is allowed. The only word-only starburst is Fresh! on the stock shelves (a CSS polygon). The only checker pattern is a decorative floor/title strip. The favicon is a red panel with a yellow frame and the header's basket mark in yellow. Decoration has no currency, numerical labels, real product marks or borrowed characters. +Panels and receipts are rectangular; controls use 3px corner radii and at least 44px height. The one exception is the header's Disconnect button: 24px tall (13px text, 12px on phones) so that the header keeps its height where the button fits on the address line (from 343px up, see Layout), with an unseen touch area that reaches 12px above and below it. Art combines original SVG/CSS with the manifest files kept byte for byte: the owner’s character (the only person on the site) and Microsoft Fluent Emoji flat food (MIT). The three pictures show a produce crate, an offered basket and a full grocery bag. The project’s BASKET PROTOCOL name badge is allowed. The only word-only starburst is Fresh! on the stock shelves (a CSS polygon). The only checker pattern is a decorative floor/title strip. The favicon is a red panel with a yellow frame and the header's basket mark in yellow. Decoration has no currency, numerical labels, real product marks or borrowed characters. ## Components @@ -51,7 +51,7 @@ Panels and receipts are rectangular; controls use 3px corner radii and at least - 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. - `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 Use full balance controls, search-to-add stocks, previews immediately after selected amounts, named approval steps and owed-first claim cards. Claim all precedes the cards; zero balances fold away. Receivers remain full, wrapped and labelled. Preview data is invalidated when inputs/accounts change and after successful deposits. +- `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. - `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. diff --git a/README.md b/README.md index 766f00f..798c57d 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Publish the **contents of the committed `dist/` directory** to the existing stat - 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. -- The only browser storage is the pasted listing rows (kept for this tab and copied for new tabs) and pending transaction recovery; there are no cookies, trackers or new services. +- The only browser storage is the pasted listing rows (kept for this tab and copied for new tabs), pending transaction recovery and, since v11, the flag that Disconnect sets (local storage key `basket-disconnected-4663`); there are no cookies, trackers or new services. - Governance sends `propose(Action)` with the `BaskVault.Action` struct (kind, token, target, pool, quoteFeed, value, value2, setting), fields per kind as in the pinned source and unused fields zero. Proposals wait two days and are executable until nine days after creation (exclusive); the Proposed event's `executableAt` and `expiresAt` are shown after sending. Settings are in vault 6 order with `Dst` at index 6. The UI additionally requires deposits paused to execute Retire and Resync, and to propose Resync. - Dates are written in words with UTC and New York time, for example "Sun 11 Oct 2026, 04:40 UTC (00:40 New York)", never as a numeric date. A start ("executable from", "Wait until", a loss's recognition date) is rounded up to the next whole minute and an end is written "until just before" a time rounded down, so a waiting window never looks longer than it is. @@ -52,7 +52,7 @@ Compilation uses solc 0.8.26+commit.8a97fa7a via `--standard-json`, optimizer 20 ## Validation -`npm run validate` checks decimal-normalized NAV, indicative flags, all eleven proposal kinds as the Action struct (including Hours and Dst), minimums, receivers, listing input, revert wording, live aggregate reads and bytecode identity. `check-interface.ts` adds the New York hours parser, the `NewYorkTime` port's inside/nextOpening vectors for all three `dst` modes, setting words and the pool `minLiquidity` rule. `check-preservation.mjs` checks unchanged protected configuration/contracts, forbids every abandoned vault address fragment in every file and keeps the send-check invariants. `check-calldata.mjs` compares the source with the commit it was applied to (HEAD): see "Site update v9" below for what it allows. +`npm run validate` checks decimal-normalized NAV, indicative flags, all eleven proposal kinds as the Action struct (including Hours and Dst), minimums, receivers, listing input, revert wording, live aggregate reads and bytecode identity. `check-interface.ts` adds the New York hours parser, the `NewYorkTime` port's inside/nextOpening vectors for all three `dst` modes, setting words and the pool `minLiquidity` rule. `check-preservation.mjs` checks unchanged protected configuration/contracts, forbids every abandoned vault address fragment in every file and keeps the send-check invariants. `check-calldata.mjs` compares the source with the commit it was applied to (HEAD): see "What the checks allow in this update" under the latest site update below for what it allows now. The bounded browser/fork runner covers a 25-row listing with a declined prompt, receipt timeout, pending nonce and reload, a US market holiday (every feed three hours old) and a Saturday, the Hours and Dst proposals and every transaction control. It requires Anvil, solc 0.8.26, Playwright and Chromium (WebKit for `check-art.mjs`). The runner requires an unfinalized empty genesis state so that it can exercise initial listing. Install Playwright outside the repository if necessary, and set `PLAYWRIGHT_MODULE` to its `index.mjs` when it is not at the runner's environment default. @@ -121,7 +121,7 @@ What the checks allow in this update: - `check-visibility.mjs`: only the Vault tagline may disappear from the first phone screen; title text pushed down by her picture is listed with its reason. - `check-interface.ts`: unit cases for the fixes and the date rounding. -The fork runner's 24 screenshots are JPEG quality 36 (40 before): with the Docs FAQ and the longer Owner texts the quality-40 set no longer fits the 8 MiB delivery budget. So run `run-browser.mjs` before `check-bundle.mjs` (as in the list above): until the runner rewrites the 24 screenshots, the tree still holds the quality-40 set and the bundle reads over the limit. +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 @@ -137,3 +137,27 @@ What the checks allow in this update: - `check-art.mjs` (Chromium and WebKit, 12 widths): the tag is first in the Deposit and Redeem forms with the exact words, at least 14px, white on royal blue, inside its panel with whole words and clear of the character and her bubble; the Redeem title and Docs sentences; no price time on any label or in the drawer, and "old price" only for reason 9. `inspect-export.mjs` and `run-browser.mjs` check the tag (deposits open and closed) and the drawer too. - 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 + +- 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. +- Deposit, figures: a running "Total: about $..." of the typed amounts, called an estimate from the last vault read (Preview gives the exact figure); a stock whose amount cannot be counted is named. While the vault takes deposits the status is one line, "Deposits are open." with the hours; otherwise the full status box with its reason is shown as before. Without a wallet the form says "Connect your wallet to see the stocks you hold." beside a Connect wallet button, and the search and the list still work. With a wallet it says which address it shows and how to use another one: "switch the address in your wallet app: this page follows it". +- Deposit, afterwards: under a confirmed deposit the page says "You received ... BASK." The figure is the shares in the Deposit event of that deposit's own receipt, not the preview figure, and it is shown only to the account that received them. +- Redeem: the BASK amount box is emptied after a confirmed redeem. +- Header: a small Disconnect button beside the address. After a press the page forgets the wallet and stays disconnected, also after a reload, until Connect wallet is pressed and answered (a flag in the browser's local storage, key `basket-disconnected-4663`); the top message says how to use another wallet. Disconnect affects this page only: it leaves the site's saved permission in the wallet app, where the visitor can remove it. It does nothing while a transaction is being sent. It is a compact control: on phones 343px wide and wider (measured in Chromium) it sits on the address line, so the header keeps its height and the first phone screen keeps everything it showed; on narrower phones it takes a second line and the header is 34px taller. +- No transaction changed: Preview, the approvals, Deposit, Redeem and Claim read, build and send exactly what they did. v11 adds one chain read (`getTransactionReceipt` of the visitor's own confirmed deposit, for the "You received" line) and no wallet request. Disconnect makes no provider call, so no delayed permission removal can undo a later Connect. Connect uses the existing account request immediately. A late initial account reply cannot overwrite a newer Connect, Disconnect or wallet account change. + +What the checks allow in this update: + +- `check-bundle.mjs`: in `web/src` only Flows, wallet, main and styles change; `web/public`, `web/pinned`, the page shell and the build configuration are unchanged; no file is added. +- `check-calldata.mjs`: every other source is byte-identical to HEAD; Flows, wallet and main equal HEAD once their listed v11 pieces are put back (the report records each piece by length and SHA-256). The rest does not depend on those pieces: + - Added calls: the complete ordered additions in Flows, wallet and main are pinned by full-expression hashes and readable ordered call names, outside the generated lists. Direct writes, aliases and mutating calls involving transaction state are compared with HEAD. Refreshing the generated pieces or attribute lists does not refresh these pins. + - Transactions: `send()` in wallet.tsx, `approveAll()` and every `onSubmit` and `getSpec` in Flows.tsx (the two previews, Deposit, Redeem and Claim) equal HEAD word for word; no attribute that builds or starts a transaction is added, removed or changed in any file; HEAD's and the new encoders give the same calldata, add no refusal and refuse with the same words. + - Reads: the calls that read the chain, parse amounts, encode or send equal HEAD in order and place, apart from two listed additions: the one new chain read (`client.getTransactionReceipt` of the confirmed deposit, pinned together with everything done with its answer) and the `amount()` of the estimated total (display only, outside every handler). + - Wallet requests: every send, provider request and signing method, and every use of a listed name that reaches the network (`fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `sendBeacon`, `importScripts`, a dynamic import; written as a name, as a plain string or reached through `window[...]`), equals HEAD apart from one exactly pinned guard around the initial account reply, which ignores a reply superseded by a newer connection action or account event. The request itself is unchanged and no provider request is added; the methods named in `web/src` equal HEAD; `disconnect()` equals a pinned text; the hook hands the pages HEAD's list plus `disconnect`; `main.tsx` still has no send or request. + - Loads and navigation: what a page can load or open without those names equals HEAD in every file, with no exception: 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, the JSX attributes that carry an address, a style or HTML, and attributes spread onto an element (the names are listed in the script). v11 adds no link, picture, frame, style attribute or navigation. This is a list of known ways, not a proof that no other exists. + - Everything else that could reach a transaction: the action attributes that differ from HEAD are listed one by one with a reason, and none holds a read, a parser or a send (Claim all and every other handler not listed equal HEAD); every call of HEAD is still there apart from the listed calls of the old form; each new use of the state HEAD had (the typed amounts, the search text, the Redeem amount, the account) is listed with its place; the names HEAD declares at the top of the three files and at the top of their components are declared as before, and no new name hides one; browser storage is used as at HEAD plus the disconnected flag. +- `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`. diff --git a/web/scripts/check-art.mjs b/web/scripts/check-art.mjs index a42f8aa..4404acd 100644 --- a/web/scripts/check-art.mjs +++ b/web/scripts/check-art.mjs @@ -46,6 +46,11 @@ const styles = fs.readdirSync(path.join(root, "dist/assets")).filter(f => 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), +// 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 gone of ["Search to add stocks"]) assert.ok(!(bundle + styles).includes(gone), "removed from the bundle (v11): " + 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. diff --git a/web/scripts/check-bundle.mjs b/web/scripts/check-bundle.mjs index 23857de..588af08 100644 --- a/web/scripts/check-bundle.mjs +++ b/web/scripts/check-bundle.mjs @@ -6,12 +6,13 @@ 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); -// v10 (on top of v9 = HEAD: the Docs deposits sentence, the 24/7 redemption tag on Deposit and Redeem, no price -// times on the Stock shelves, and the slogan strip fix in Scenery.tsx and styles.css): only these web/src files may -// change; every other source stays as at HEAD and the changed ones 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 v10Source=['Docs.tsx','Flows.tsx','Vault.tsx','model.ts','styles.css','Scenery.tsx'].map(f=>'web/src/'+f); +// 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); // 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'); @@ -23,7 +24,7 @@ for(const p of changed){ // The page shell and build configuration stay as at HEAD (no script can be added outside web/src); in web/validation only the README text changes. assert.ok(!['web/index.html','web/vite.config.ts','web/tsconfig.json'].includes(p)&&(!p.startsWith('web/validation/')||p==='web/validation/README.md'),'Page shell, build configuration or validation fixture changed: '+p); assert.ok(!/(^|\/)(lib|node_modules|\.github|\.git)(\/|$)|(^|\/)\.env(?:\.|$)|(^|\/)(foundry\.toml|foundry\.lock|remappings\.txt|\.gitmodules|package\.json|[^/]*lock[^/]*)$/.test(p),'Protected path changed: '+p); - if(p.startsWith('web/src/'))assert.ok(v10Source.includes(p),'Source outside the v10 scope changed: '+p); + if(p.startsWith('web/src/'))assert.ok(v11Source.includes(p),'Source outside the v11 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'); @@ -33,7 +34,7 @@ for(const p of files) assert.ok(!/(^|\/)(node_modules|\.npm|\.cache|\.vite|\.pla 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 v10 scope (Docs: deposits sentence; Flows: 24/7 redemption tag; Vault and model: no price times on the Stock shelves; styles: the tag and the removed age rules; Scenery and styles: slogan strip fix); every other source frozen','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 36 since v9, 40 before); no additional artifact images','Both copies of all 37 manifest assets included']}; +const 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 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 the quality-40 set, run scripts/run-browser.mjs first: it rewrites them at quality 36): "+JSON.stringify(result));console.log('PASS complete bundle',result.uncompressedDeliveryBytes,'/',result.limitBytes,'bytes; lib included'); +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'); diff --git a/web/scripts/check-calldata.mjs b/web/scripts/check-calldata.mjs index a541f23..2e49cae 100644 --- a/web/scripts/check-calldata.mjs +++ b/web/scripts/check-calldata.mjs @@ -2,6 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { pathToFileURL } from 'node:url'; import ts from 'typescript'; import { build } from 'esbuild'; @@ -9,100 +10,279 @@ const root=process.env.BASKET_SOURCE_ROOT||path.resolve('..'); 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'); -// v10 (on top of v9, which is HEAD): the Docs deposits sentence starts with a capital letter; Deposit and Redeem show -// an always-visible "Redemptions are open 24/7. Deposit hours apply to deposits only." tag (the Redeem title drops -// "Redemption is always open."); the Stock shelves show no price time on the labels or in the drawer (only a reason-9 -// price keeps its "old price" tag), so age() leaves model.ts; the slogan strip's two halves each move as their own Web -// Animation started by Marquee (Scenery.tsx). Every other web/src source is byte-identical to HEAD. -const protectedFiles=['Losses.tsx','poolMath.ts','deployment.ts','vault.abi.json','main.tsx','components.tsx','Owner.tsx','wallet.tsx','governance.ts','chain.ts','newYork.ts']; +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']; for(const f of protectedFiles) assert.equal(read('web/src/'+f),baseline('web/src/'+f),f); -// The v10 files: byte-identical to HEAD once their documented pieces [v10, HEAD] are put back (each exactly once), so -// every word, call and attribute in them is pinned, the 24/7 tag text included. Scenery.tsx: the slogan strip fix, two -// pieces (the react import with the whole Marquee hook that animates the halves, and the track's ref), so the hook -// text is pinned too (check-art.mjs and check-motion.mjs test what it does in the browser). +// The v11 files: byte-identical to HEAD once their documented pieces [v11, 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) const editedFiles={ - "Docs.tsx":[ - ["

\n Deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US\n market holidays, redemptions always open\n", - "

\n deposits open Sunday 8 pm to Friday 8 pm New York time, closed on US\n market holidays, redemptions always open\n"] - ], "Flows.tsx":[ - ["type Props = { snapshot: Snapshot; wallet: Wallet };\n// Always shown at the top of the Deposit and Redeem forms (open or closed), so\n// nobody reads the deposit hours as redemption hours.\nfunction HoursTag() {\n return (\n

\n Redemptions are open 24/7.{\" \"}\n Deposit hours apply to deposits only.\n

\n );\n}\nexport function DepositPage({ snapshot: s, wallet: w }: Props) {\n", - "type Props = { snapshot: Snapshot; wallet: Wallet };\nexport function DepositPage({ snapshot: s, wallet: w }: Props) {\n"], - ["
\n \n \n", - "
\n \n"], - [" >\n Receive your share of every stock.\n \n", - " >\n Receive your share of every stock. Redemption is always open.\n \n"], - ["
\n \n

Redeem shares

\n", - "
\n

Redeem shares

\n"] - ], - "Vault.tsx":[ - [" usd,\n pairingMatches,\n", - " usd,\n age,\n pairingMatches,\n"], - ["// cents in two digits (\"$669.5\" shows as $669 and 50 cents).\nfunction ShelfPrice({ a, stale }: { a: Asset; stale: boolean }) {\n", - "// cents in two digits (\"$669.5\" shows as $669 and 50 cents).\n// age() as today, but on the labels under 1 minute reads \"just now\" (the long\n// words wrapped and made the row taller; the drawer keeps age()'s full words)\n// and 48 hours or more reads in days (\"4 days ago\")\nfunction shelfAge(a: Asset): string {\n const t = age(a.updatedAt);\n if (!t.endsWith(\" ago\") || a.updatedAt === undefined) return t;\n const s = Math.floor(Date.now() / 1000 - Number(a.updatedAt));\n return s < 60 ? \"just now\" : s >= 2 * 86400 ? `${Math.floor(s / 86400)} days ago` : t;\n}\nfunction ShelfPrice({ a, stale }: { a: Asset; stale: boolean }) {\n"], - ["}\n// No price time on the labels or in the drawer: feeds post only on a 0.5% move\n// or every 24 hours, so an age like \"17h 55m ago\" looked stale when it was not.\n// Only a price the vault refuses as too old (reason 9) gets the \"old price\" tag.\nfunction OldPriceTag({ stale }: { stale: boolean }) {\n return stale ? (\n

\n old price\n

\n ) : null;\n}\n", - "}\nfunction PriceAge({ a, stale }: { a: Asset; stale: boolean }) {\n const t = shelfAge(a);\n return (\n

\n {stale && old price}\n {stale && \" \"}\n {t.endsWith(\" ago\") || t === \"just now\" ? (\n <>\n {/* narrower labels show a small clock (or nothing) instead of the words; still read out */}\n price updated \n \n \n {t.charAt(0).toLowerCase() + t.slice(1)}\n \n \n ) : (\n `price time: ${t}`\n )}\n

\n );\n}\n"], - [" \n \n \n", - " \n \n \n"], - ["
\n
Feed price
\n
\n", - "
\n
Feed price / age
\n
\n"], - [" ? \"unreadable / invalid feed\"\n : \"$\" + fmt(a.answer, a.feedDecimals)}\n
\n", - " ? \"unreadable / invalid feed\"\n : \"$\" + fmt(a.answer, a.feedDecimals)}{\" \"}\n {age(a.updatedAt)}\n \n"] + ["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 {wallet.chainId !== 4663 && (\n", + " \n {wallet.chainId !== 4663 && (\n"] ] }; -for(const [f,edits] of Object.entries(editedFiles)){ - let text=read('web/src/'+f); - for(const [n,o] of edits){assert.equal(text.split(n).length,2,f+': expected once: '+n);text=text.split(n).join(o);} - assert.equal(text,baseline('web/src/'+f),f+' changed beyond the documented edit'); -} +// <<< v11 pieces +assert.deepEqual(Object.keys(editedFiles),['Flows.tsx','wallet.tsx','main.tsx'],'the v11 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']); -const actionAttributes=new Set(['onClick','onSubmit','onChange','getSpec','disabled','disabledReason','label','scope','role','primary','value','claim','reason','build']); +// 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. +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. +const TX_ATTRIBUTES=new Set(['onSubmit','getSpec','build','claim']); // A6 G-A: wallet sends and raw provider requests in any form are guarded calls too: a callee ending in one of these // names (any receiver, optional chaining, casts, element access, string folding) or naming the provider (ethereum). const SEND=new Set(['send','request','sendTransaction','sendRawTransaction','writeContract','sendAsync','sendCalls','signTransaction','sendUnsignedTransaction']); const lit=n=>ts.isStringLiteralLike(n)?n.text:ts.isParenthesizedExpression(n)?lit(n.expression):ts.isBinaryExpression(n)&&n.operatorToken.kind===ts.SyntaxKind.PlusToken&&lit(n.left)!==undefined&&lit(n.right)!==undefined?lit(n.left)+lit(n.right):undefined; const unwrap=n=>ts.isParenthesizedExpression(n)||ts.isNonNullExpression(n)||ts.isAsExpression(n)||ts.isTypeAssertionExpression(n)||ts.isSatisfiesExpression?.(n)?unwrap(n.expression):n; const calleeName=c=>{const e=unwrap(c.expression);return ts.isIdentifier(e)?e.text:ts.isPropertyAccessExpression(e)?e.name.text:ts.isElementAccessExpression(e)?lit(e.argumentExpression):undefined;}; -const isGuarded=(n,tree)=>ts.isCallExpression(n)&&(names.has(n.expression.getText(tree))||SEND.has(calleeName(n))||/ethereum|(^|[.?\]])\s*(send|request|sendTransaction|sendRawTransaction|writeContract)\b/.test(n.expression.getText(tree))); +// 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. +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); +const printerOf=tree=>{const printer=ts.createPrinter({removeComments:true});return n=>printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');}; +// Where a node sits: the named functions around it, outermost first ("useWallet > disconnect"), then the action +// attribute it is inside, if any ("DepositPage, in onChange"). +const place=(n,tree)=>{const fns=[];let attr='';for(let p=n.parent;p;p=p.parent){if(ts.isFunctionDeclaration(p)&&p.name)fns.unshift(p.name.text);if(!attr&&ts.isJsxAttribute(p)&&isAction(p.name.getText(tree)))attr=p.name.getText(tree);}return (fns.join(' > ')||'module')+(attr?', in '+attr:'');}; +// The whole expression a name stands in: the property reads and calls chained to it ("p.request({...}).then(f)"). +const wraps=(p,n)=>(ts.isPropertyAccessExpression(p)||ts.isElementAccessExpression(p)||ts.isCallExpression(p))&&p.expression===n||ts.isNonNullExpression(p)||ts.isParenthesizedExpression(p)||ts.isAsExpression(p)||ts.isTypeAssertionExpression(p)||!!ts.isSatisfiesExpression?.(p); +const top=n=>{if(n.parent&&ts.isPropertyAccessExpression(n.parent)&&n.parent.name===n)n=n.parent;while(n.parent&&wraps(n.parent,n))n=n.parent;return n;}; function calls(text,file) { - const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),found=[],all=[],attributes=[]; - const printer=ts.createPrinter({removeComments:true}); - const print=n=>printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' '); + const tree=parse(text,file),print=printerOf(tree),found=[],all=[],attributes=[]; const guardedIn=node=>{const g=[];(function v(n){if(isGuarded(n,tree))g.push(print(n));ts.forEachChild(n,v);})(node);return g;}; function where(attr){const owner=attr.parent.parent,tag=owner.tagName?.getText(tree)??'?';const named=attr.parent.properties.find(p=>ts.isJsxAttribute(p)&&['title','label','scope'].includes(p.name.getText(tree))&&p.initializer);return tag+(named?' '+named.name.getText(tree)+'='+named.initializer.getText(tree).replace(/\s+/g,' ').slice(0,60):'');} function visit(n){ - if(ts.isCallExpression(n)){all.push(print(n));if(isGuarded(n,tree))found.push(print(n));} - if(ts.isJsxAttribute(n)&&actionAttributes.has(n.name.getText(tree)))attributes.push({name:n.name.getText(tree),text:print(n),guarded:guardedIn(n),where:where(n)}); + if(ts.isCallExpression(n)){const c={text:print(n),guarded:isGuarded(n,tree),place:place(n,tree),callee:calleeName(n)??'(expression)'};all.push(c);if(c.guarded)found.push(c);} + if(ts.isJsxAttribute(n)&&isAction(n.name.getText(tree)))attributes.push({name:n.name.getText(tree),tag:n.parent.parent.tagName?.getText(tree)??'?',text:print(n),guarded:guardedIn(n),where:where(n)}); ts.forEachChild(n,visit); } visit(tree);return {found,all,attributes}; } -// Remove the first occurrence of each listed item; every listed item must be present. -function without(list,items,what){const rest=[...list];for(const x of items){const i=rest.indexOf(x);assert.ok(i>=0,what+': expected '+x);rest.splice(i,1);}return rest;} -// Guarded calls: v10 changes none, so in every file they equal HEAD in order (no documented difference). -const expected={}; -const putBack=(text,pieces,what)=>{for(const [n,o] of pieces){assert.equal(text.split(n).length,2,what+': expected once: '+n);text=text.split(n).join(o);}return text;}; -// Every call expression must equal HEAD in these files, with no exception in v10. -const allCallsEqual={'components.tsx':[], 'Docs.tsx':[], 'Flows.tsx':[]}; +// The difference of two lists of texts in order (longest common subsequence): the positions only in a (removed) and +// only in b (added). Everything else is the same text in the same order in both. +function difference(a,b){ + const n=a.length,m=b.length,L=Array.from({length:n+1},()=>new Uint16Array(m+1)); + for(let i=n-1;i>=0;i--)for(let j=m-1;j>=0;j--)L[i][j]=a[i]===b[j]?L[i+1][j+1]+1:Math.max(L[i+1][j],L[i][j+1]); + const removed=[],added=[];let i=0,j=0; + while(i=L[i][j+1])removed.push(i++);else added.push(j++);} + while(i>> v11 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."]]} +}; +// <<< v11 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. +// Watched names: the names that build, parse, read or send (vault, token, read, many, simulate, encode, amount, +// 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; +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:[]}, +}; +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. +function stateNames(text,file){ + const tree=parse(text,file),out=[]; + (function v(n){ + if(ts.isVariableDeclaration(n)&&n.initializer&&ts.isCallExpression(n.initializer)){ + const made=n.initializer.expression.getText(tree); + if(made==='useState'&&ts.isArrayBindingPattern(n.name)&&n.name.elements.length===2&&ts.isBindingElement(n.name.elements[1])&&ts.isIdentifier(n.name.elements[1].name))out.push(n.name.elements[1].name.text); + if(made==='useRef'&&ts.isIdentifier(n.name))out.push(n.name.text); + } + ts.forEachChild(n,v); + })(tree); + return out; +} +// Every use of a watched name with its place: not where it is declared or imported, not as the name of a property, +// not inside a type. +function usesOf(text,file,watched){ + const tree=parse(text,file),print=printerOf(tree),out=[]; + const named=p=>(ts.isVariableDeclaration(p)||ts.isParameter(p)||ts.isBindingElement(p)||ts.isFunctionDeclaration(p)||ts.isFunctionExpression(p)||ts.isImportSpecifier(p)||ts.isImportClause(p)||ts.isPropertyAssignment(p)||ts.isPropertyAccessExpression(p)||ts.isJsxAttribute(p)||ts.isMethodDeclaration(p)||ts.isPropertySignature(p))&&p.name; + (function v(n){ + if(ts.isTypeNode(n))return; + if(ts.isIdentifier(n)&&watched.has(n.text)&&named(n.parent)!==n&&!(ts.isBindingElement(n.parent)&&n.parent.propertyName===n)&&!(ts.isImportSpecifier(n.parent)&&n.parent.propertyName===n))out.push({text:print(top(n)),place:place(n,tree)}); + ts.forEachChild(n,v); + })(tree); + return out; +} // 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, filters, search, Show all), which only change what is shown. v10 changes none of them (only label and -// drawer words). The StockShelves handlers behind those controls are pinned too. -const vaultAttributes=['onClick={s.retry}','value={VAULT}','onClick={onToggle}','onClick={onClose}','value={a.token}','value={a.feed}','role="list"','role="group"','onClick={() => show(f, search)}','value={search}','onChange={(e) => show(filter, e.target.value)}','role="status"','onClick={() => show("all", "")}','onClick={() => show("all", "")}']; +// 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", "")}']; 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);', @@ -118,16 +298,144 @@ 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 v10 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 v10 file'); +// 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'); +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); + // A call that holds a documented piece is compared as HEAD wrote it. + const restore=text=>c.pieces.reduce((t,[n,o],i)=>{const parts=t.split(n);assert.ok(parts.length<=2,f+': the call piece occurs twice in one call: '+n);if(parts.length===2)used[i]++;return parts.join(o);},text); + const restored=now.all.map(x=>restore(x.text)); + const watched=new Set([...WATCHED,...stateNames(baseline('web/src/'+f),f)]),usesOld=usesOf(baseline('web/src/'+f),f,watched),usesNow=usesOf(read('web/src/'+f),f,watched),uses=difference(usesOld.map(x=>x.text+' @ '+x.place),usesNow.map(x=>restore(x.text)+' @ '+x.place)); + // Two calls are the same when their text and their place are; two attributes when their element and their text are. + const attributes=difference(old.attributes.map(a=>a.tag+' '+a.text),now.attributes.map(a=>a.tag+' '+a.text)),guarded=difference(old.found.map(x=>x.text+' @ '+x.place),now.found.map(x=>restoreAccountReply(x,f)+' @ '+x.place)),all=difference(old.all.map(x=>x.text+' @ '+x.place),restored.map((x,i)=>x+' @ '+now.all[i].place)); + scans[f]={old,now,c,used,attributes,guarded,all,usesOld,usesNow,uses}; + if(attributes.removed.length+attributes.added.length)facts.attributes[f]={removed:attributes.removed.map(i=>[old.attributes[i].tag,old.attributes[i].text]),added:attributes.added.map(i=>[now.attributes[i].tag,now.attributes[i].text])}; + if(guarded.removed.length+guarded.added.length)facts.guarded[f]={removed:guarded.removed.map(i=>old.found[i].text),added:guarded.added.map(i=>[now.found[i].text,now.found[i].place])}; + if(all.removed.length+all.added.length)facts.calls[f]={removed:all.removed.map(i=>old.all[i].text),uses:uses.added.map(i=>[usesNow[i].text,usesNow[i].place]),usesRemoved:uses.removed.map(i=>usesOld[i].text),added:all.added.length,addedCalls:all.added.map(i=>[now.all[i].text,now.all[i].place]),callees:all.added.map(i=>now.all[i].callee).join(' ')}; +} +// For the tool that generated the two lists between marker lines (it is not part of this tree): when BASKET_GATE_FACTS +// names a file, the differences computed from this tree are written to it before they are checked. Nothing is +// skipped or loosened by it. +if(process.env.BASKET_GATE_FACTS)fs.writeFileSync(process.env.BASKET_GATE_FACTS,JSON.stringify(facts,null,1)+'\n'); +// Hand-reviewed complete ordered additions. SHA-256 binds JSON.stringify([[full printed call, place], ...]); the +// ordered callee names make the list readable without repeating large nested JSX bodies. No regeneration block +// 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={ + "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" + }, + "wallet.tsx": { + "sha256": "76209004bfb699f033e9ba9e7e42a5b08029064f660c535033933e46caf4cdb8", + "callees": "getItem useRef savedDisconnected useRef accounts removeItem setItem setAccount setHash setMessage" + }, + "main.tsx": { + "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", + "callees": "" + } +}; +// <<< reviewed calls +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'); + assert.equal(sha256(JSON.stringify(list)),addedCallPins[f]?.sha256,f+': the complete ordered added-call list differs from its reviewed pin'); +} +// 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. +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=[]; + const symbol=n=>checker.getSymbolAtLocation(n),union=(...lists)=>[...new Set(lists.flat())]; + const member=(from,key)=>from.map(x=>x.startsWith('copy:')?x.slice(5):x.startsWith('spread:')?member([x.slice(7)],key)[0]:x==='s'?'s.'+key:x); + const copied=(from,kind='copy:')=>from.map(x=>(x.match(/copy:|spread:/g)?.length??0)>=4?x:kind+x); + const origin=n=>{ + if(!n)return [];n=unwrap(n); + if(ts.isIdentifier(n))return union(roots.has(n.text)?[n.text]:[],aliases.get(symbol(n))??[]); + if(ts.isPropertyAccessExpression(n))return member(origin(n.expression),n.name.text); + if(ts.isElementAccessExpression(n))return member(origin(n.expression),lit(n.argumentExpression)??'*'); + if(ts.isConditionalExpression(n))return union(origin(n.whenTrue),origin(n.whenFalse)); + if(ts.isBinaryExpression(n)&&[ts.SyntaxKind.AmpersandAmpersandToken,ts.SyntaxKind.BarBarToken,ts.SyntaxKind.QuestionQuestionToken].includes(n.operatorToken.kind))return union(origin(n.left),origin(n.right)); + if(ts.isBinaryExpression(n)&&[ts.SyntaxKind.CommaToken,ts.SyntaxKind.EqualsToken].includes(n.operatorToken.kind))return origin(n.right); + if(ts.isArrayLiteralExpression(n))return union(...n.elements.map(x=>copied(origin(ts.isSpreadElement(x)?x.expression:x)))); + if(ts.isObjectLiteralExpression(n))return union(...n.properties.map(x=>copied(origin(ts.isPropertyAssignment(x)?x.initializer:ts.isShorthandPropertyAssignment(x)?x.name:ts.isSpreadAssignment(x)?x.expression:undefined),ts.isSpreadAssignment(x)?'spread:':'copy:'))); + return []; + }; + let changed; + const bind=(name,from)=>{ + if(ts.isIdentifier(name)){const key=symbol(name);if(!key||!from.length)return;const was=aliases.get(key)??[],next=union(was,from);if(next.length!==was.length){aliases.set(key,next);changed=true;}} + else if(ts.isObjectBindingPattern(name)||ts.isArrayBindingPattern(name)){ + for(const [i,e] of name.elements.entries())if(ts.isBindingElement(e))bind(e.name,union(e.dotDotDotToken?copied(from,ts.isObjectBindingPattern(name)?'spread:':'copy:'):member(from,ts.isObjectBindingPattern(name)?(e.propertyName?.getText(tree)??e.name.getText(tree)):i),origin(e.initializer))); + } + else if(ts.isObjectLiteralExpression(name)){ + for(const e of name.properties){ + if(ts.isPropertyAssignment(e)||ts.isShorthandPropertyAssignment(e))bind(ts.isPropertyAssignment(e)?e.initializer:e.name,union(member(from,e.name.getText(tree)),ts.isShorthandPropertyAssignment(e)?origin(e.objectAssignmentInitializer):[])); + else if(ts.isSpreadAssignment(e))bind(e.expression,copied(from,'spread:')); + } + } + else if(ts.isArrayLiteralExpression(name))name.elements.forEach((e,i)=>bind(ts.isSpreadElement(e)?e.expression:e,ts.isSpreadElement(e)?copied(from):member(from,i))); + else if(ts.isBinaryExpression(name)&&name.operatorToken.kind===ts.SyntaxKind.EqualsToken)bind(name.left,union(from,origin(name.right))); + else if(ts.isPropertyAccessExpression(name)||ts.isElementAccessExpression(name))bind(name.expression,copied(from)); + }; + // Fixed point catches alias chains even across declaration order. Symbols distinguish shadowed local names. + do{changed=false;(function v(n){ + if(ts.isVariableDeclaration(n)&&n.initializer)bind(n.name,origin(n.initializer)); + if(ts.isBinaryExpression(n)&&[ts.SyntaxKind.EqualsToken,ts.SyntaxKind.BarBarEqualsToken,ts.SyntaxKind.AmpersandAmpersandEqualsToken,ts.SyntaxKind.QuestionQuestionEqualsToken].includes(n.operatorToken.kind))bind(n.left,origin(n.right)); + if(ts.isForOfStatement(n)){ + const from=member(origin(n.expression),'*'); + if(ts.isVariableDeclarationList(n.initializer))for(const d of n.initializer.declarations)bind(d.name,from); + else bind(n.initializer,from); + } + ts.forEachChild(n,v); + })(tree);}while(changed); + const protectedOrigins=n=>origin(n).filter(x=>!/^copy:|^spread:/.test(x)&&(x!=='s'&&!x.startsWith('s.')||x==='s.assets'||x==='s.*')); + const put=(n,target)=>{const from=protectedOrigins(target);if(from.length)out.push({text:print(n),place:place(n,tree),from:[...new Set(from)].sort()});}; + const target=(n,whole)=>{if(!n)return;n=unwrap(n);if(ts.isArrayLiteralExpression(n))n.elements.forEach(x=>target(x,whole));else if(ts.isObjectLiteralExpression(n))n.properties.forEach(x=>target(ts.isPropertyAssignment(x)?x.initializer:ts.isShorthandPropertyAssignment(x)?x.name:x.expression,whole));else put(whole,n);}; + const mutators=new Set(['sort','reverse','copyWithin','fill','push','pop','shift','unshift','splice','set','delete','clear']); + (function v(n){ + if(ts.isBinaryExpression(n)&&n.operatorToken.kind>=ts.SyntaxKind.FirstAssignment&&n.operatorToken.kind<=ts.SyntaxKind.LastAssignment)target(n.left,n); + if(ts.isDeleteExpression(n))target(n.expression,n); + if((ts.isPrefixUnaryExpression(n)||ts.isPostfixUnaryExpression(n))&&[ts.SyntaxKind.PlusPlusToken,ts.SyntaxKind.MinusMinusToken].includes(n.operator))target(n.operand,n); + if(ts.isCallExpression(n)){ + const e=unwrap(n.expression),method=calleeName(n); + if((ts.isPropertyAccessExpression(e)||ts.isElementAccessExpression(e))&&mutators.has(method))put(n,e.expression); + if((ts.isPropertyAccessExpression(e)||ts.isElementAccessExpression(e))&&['Object','Reflect'].includes(e.expression.getText(tree))&&['assign','defineProperty','defineProperties','set','deleteProperty','setPrototypeOf','freeze','seal','preventExtensions'].includes(method)&&n.arguments[0])put(n,n.arguments[0]); + } + ts.forEachChild(n,v); + })(tree); + return out; +} +const transactionStateWrites={}; +for(const f of Object.keys(editedFiles)){ + const h=stateWrites(baseline('web/src/'+f),f),n=stateWrites(read('web/src/'+f),f); + assert.deepEqual(n,h,f+': a direct or aliased write to transaction state differs from HEAD'); + transactionStateWrites[f]=n.length; +} const counts={}; for(const f of sources){ - const old=calls(baseline('web/src/'+f),f),now=calls(read('web/src/'+f),f),e=expected[f]; - if(e) assert.deepEqual(without(now.found,e.added,f),without(old.found,e.removed,f),f+' changed guarded calls beyond the documented ones'); - else assert.deepEqual(now.found,old.found,f+' changed calldata/read/check call expressions'); - // Action/send/check attributes: equal HEAD in every file, in order (no exception in v10). - assert.equal(now.attributes.length,old.attributes.length,f+' action attribute count'); - assert.deepEqual(now.attributes.map((a,i)=>[old.attributes[i],a]).filter(([o,a])=>o.text!==a.text).map(([,a])=>a.where+': '+a.text),[],f+' changed an action, send or check attribute'); + const {old,now,c,used,attributes,guarded,all,usesOld,usesNow,uses}=scans[f],e=expected[f]??{removed:[],added:[]},a=attributeChanges[f]??{removed:[],added:[]}; + // Guarded calls: equal HEAD in order and place, apart from the documented ones, each added one in its documented place. + assert.deepEqual({removed:guarded.removed.map(i=>old.found[i].text),added:guarded.added.map(i=>[now.found[i].text,now.found[i].place])},{removed:e.removed,added:e.added},f+' changed calldata/read/check/send call expressions beyond the documented ones'); + // Action attributes: equal HEAD in order, apart from the documented ones. + assert.deepEqual({removed:attributes.removed.map(i=>[old.attributes[i].tag,old.attributes[i].text]),added:attributes.added.map(i=>[now.attributes[i].tag,now.attributes[i].text])},{removed:a.removed.map(x=>x.slice(0,2)),added:a.added.map(x=>x.slice(0,2))},f+' changed an action, send or check attribute beyond the documented ones'); + for(const x of [...a.removed,...a.added])assert.ok(typeof x[2]==='string'&&x[2].trim().length>20,f+': write the why of the documented attribute '+x[1]); + for(const [list,i] of [...attributes.removed.map(i=>[old.attributes,i]),...attributes.added.map(i=>[now.attributes,i])]){ + assert.ok(!TX_ATTRIBUTES.has(list[i].name),f+': an attribute that builds or starts a transaction was added, removed or changed: '+list[i].text); + assert.deepEqual(list[i].guarded,[],f+' '+list[i].where+': a guarded call inside an added or removed attribute: '+list[i].text); + } + // The attributes that build or start a transaction, spelled out: the same texts as at HEAD, in order. + assert.deepEqual(now.attributes.filter(x=>TX_ATTRIBUTES.has(x.name)).map(x=>x.text),old.attributes.filter(x=>TX_ATTRIBUTES.has(x.name)).map(x=>x.text),f+': an onSubmit, getSpec, build or claim attribute differs from HEAD'); + // No guarded call moved into or out of an attribute: inside the attributes they are HEAD's, in order. + assert.deepEqual(now.attributes.flatMap(x=>x.guarded),old.attributes.flatMap(x=>x.guarded),f+': the guarded calls inside action attributes differ from HEAD'); + // Every call expression (see callChanges). + assert.deepEqual(all.removed.map(i=>old.all[i].text),c.removed,f+': a call of HEAD is gone or moved beyond the documented ones'); + c.pieces.forEach(([n],i)=>assert.ok(used[i]>0,f+': the call piece is in no call: '+n)); + assert.deepEqual(all.added.filter(i=>now.all[i].guarded).map(i=>now.all[i].text),e.added.map(x=>x[0]),f+': an added call is a guarded call that is not documented'); + // Watched names (see callChanges): the new uses are the documented ones; a use that is gone left with a removed call. + assert.deepEqual(uses.added.map(i=>[usesNow[i].text,usesNow[i].place]),c.uses,f+': a new use of a name that builds, reads or sends, or of a state or ref of HEAD, is not documented'); + for(const i of uses.removed)assert.ok(c.removed.includes(usesOld[i].text),f+': a use of a watched name is gone or moved, and not with a documented removed call: '+usesOld[i].text); if(f==='Vault.tsx'){ assert.deepEqual(now.found,[],'Vault.tsx makes no read, check or send call'); assert.deepEqual(now.attributes.map(a=>a.text),vaultAttributes,'Vault.tsx action attributes differ from the pinned Stock shelves controls'); @@ -135,28 +443,28 @@ for(const f of sources){ const h=localDeclarations('web/src/Vault.tsx',read('web/src/Vault.tsx'),'StockShelves',Object.keys(vaultHandlers)); for(const [k,v] of Object.entries(vaultHandlers)) assert.equal(h[k],v,'Vault.tsx StockShelves '+k+' differs from the pinned text'); } - if(allCallsEqual[f]){ - const pairs=allCallsEqual[f]; - assert.deepEqual(now.all,old.all.map(x=>pairs.find(p=>p[0]===x)?.[1]??x),f+' changed a call expression'); - } counts[f]={guarded:now.found.length,calls:now.all.length,attributes:now.attributes.length}; } -// A6 G-A: no web/src file gains, loses or changes a wallet send or raw request, with no exception. Listed in order per -// file: every use of a send/request name or of the provider (ethereum), with the call around it; every string that -// names a sending or signing method (folded across +); computed access to the global object; eval, Function, Reflect. +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'); +// 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, +// 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. +// 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']),GLOBAL=new Set(['window','globalThis','self','top','parent','frames']); +const ESCAPES=new Set(['ethereum','eval','Function','Reflect','globalThis','fetch','XMLHttpRequest','WebSocket','EventSource','sendBeacon','importScripts']),GLOBAL=new Set(['window','globalThis','self','top','parent','frames']); +const sendText=s=>SEND_TEXT.test(s)||ESCAPES.has(s); function sendsOf(text,file){ - const tree=ts.createSourceFile(file,text,ts.ScriptTarget.Latest,true,file.endsWith('tsx')?ts.ScriptKind.TSX:ts.ScriptKind.TS),printer=ts.createPrinter({removeComments:true}),out=[]; - const print=n=>printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' '); - const wraps=(p,n)=>(ts.isPropertyAccessExpression(p)||ts.isElementAccessExpression(p)||ts.isCallExpression(p))&&p.expression===n||ts.isNonNullExpression(p)||ts.isParenthesizedExpression(p)||ts.isAsExpression(p)||ts.isTypeAssertionExpression(p)||!!ts.isSatisfiesExpression?.(p); - const top=n=>{if(n.parent&&ts.isPropertyAccessExpression(n.parent)&&n.parent.name===n)n=n.parent;while(n.parent&&wraps(n.parent,n))n=n.parent;return n;}; + const tree=parse(text,file),print=printerOf(tree),out=[],put=(n,shown)=>out.push({text:print(shown),place:place(n,tree)}); (function v(n){ const s=lit(n); - if(ts.isIdentifier(n)&&(SEND.has(n.text)||ESCAPES.has(n.text)))out.push(print(top(n))); - else if(ts.isElementAccessExpression(n)&&GLOBAL.has(unwrap(n.expression).getText(tree))&&lit(n.argumentExpression)===undefined)out.push(print(n)); - else if(s!==undefined&&!(n.parent&&lit(n.parent)!==undefined)&&SEND_TEXT.test(s))out.push(print(top(n))); - else if((ts.isTemplateExpression(n)||ts.isNoSubstitutionTemplateLiteral(n))&&SEND_TEXT.test(ts.isTemplateExpression(n)?n.head.text+n.templateSpans.map(x=>x.literal.text).join(''):n.text))out.push(print(n)); + if(ts.isIdentifier(n)&&(SEND.has(n.text)||ESCAPES.has(n.text)))put(n,top(n)); + else if(ts.isElementAccessExpression(n)&&GLOBAL.has(unwrap(n.expression).getText(tree)))put(n,n); + else if(s!==undefined&&!(n.parent&&lit(n.parent)!==undefined)&&sendText(s))put(n,top(n)); + else if((ts.isTemplateExpression(n)||ts.isNoSubstitutionTemplateLiteral(n))&&sendText(ts.isTemplateExpression(n)?n.head.text+n.templateSpans.map(x=>x.literal.text).join(''):n.text))put(n,n); + else if(ts.isCallExpression(n)&&n.expression.kind===ts.SyntaxKind.ImportKeyword)put(n,n); ts.forEachChild(n,v); })(tree); return out; @@ -164,27 +472,222 @@ 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); - assert.deepEqual(n,h,f+': a wallet send, provider request or signing method was added, removed or changed'); - if(NO_SEND.includes(f))assert.ok(n.every(x=>x==='window.ethereum'),f+' must contain no send or request (only the read of window.ethereum in main.tsx)'); - if(n.length)sendUses[f]=n; + // 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. + 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'); + 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)'); + if(n.length)sendUses[f]=n.map(x=>x.text); } // components.tsx (the shared transaction button) keeps exactly HEAD's single wallet.send. assert.deepEqual(sendUses['components.tsx'].filter(x=>/\(/.test(x)),['wallet.send(await getSpec(), scope, scope)'],'components.tsx sends'); -// wallet.tsx send(), the only function that prompts the wallet: printed without comments it must equal HEAD (v10 -// documents no piece), so from, to, data, gas (estimate x 1.3), nonce, the chain and account rechecks, the simulation -// and the eth_sendTransaction request are unchanged. -const sendEdits=[]; -function sendText(text){ - const tree=ts.createSourceFile('wallet.tsx',text,ts.ScriptTarget.Latest,true,ts.ScriptKind.TSX),printer=ts.createPrinter({removeComments:true});let out; - (function v(n){if(ts.isFunctionDeclaration(n)&&n.name?.text==='send')out=printer.printNode(ts.EmitHint.Unspecified,n,tree).replace(/\s+/g,' ');ts.forEachChild(n,v);})(tree); - assert.ok(out,'wallet.tsx send() found');return out; -} -let sendNow=sendText(read('web/src/wallet.tsx')); -for(const [n,o] of sendEdits){assert.equal(sendNow.split(n).length,2,'wallet.tsx send(): expected once: '+n);sendNow=sendNow.split(n).join(o);} -assert.equal(sendNow,sendText(baseline('web/src/wallet.tsx')),'wallet.tsx send() changed (v10 changes nothing in it)'); +// v11: 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 +// 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. +const LOADS=new Set(['Image','Audio','Worker','SharedWorker','serviceWorker','RTCPeerConnection','location','history','navigator','open','createElement','createElementNS','innerHTML','outerHTML','insertAdjacentHTML','setAttribute','setAttributeNS','write','writeln','src','srcset','srcdoc','href','action','formAction','style','cssText']); +const LOAD_ATTRIBUTES=new Set(['href','xlinkhref','xlink:href','src','srcset','srcdoc','action','formaction','data','poster','ping','style','dangerouslysetinnerhtml','httpequiv']); +function loadsOf(text,file){ + const tree=parse(text,file),print=printerOf(tree),out=[],put=(n,shown)=>out.push({text:print(shown),place:place(n,tree)}); + const plainOpen=n=>n.text==='open'&&!(ts.isPropertyAccessExpression(n.parent)&&n.parent.name===n)&&!(ts.isCallExpression(n.parent)&&n.parent.expression===n); + (function v(n){ + const s=lit(n); + if(ts.isJsxAttribute(n)){if(LOAD_ATTRIBUTES.has(n.name.getText(tree).toLowerCase()))put(n,n);if(n.initializer)v(n.initializer);return;} + if(ts.isJsxSpreadAttribute(n))put(n,n); + else if(ts.isIdentifier(n)&&LOADS.has(n.text)&&!plainOpen(n))put(n,top(n)); + else if(s!==undefined&&!(n.parent&&lit(n.parent)!==undefined)&&LOADS.has(s))put(n,top(n)); + ts.forEachChild(n,v); + })(tree); + return out; +} +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'); + 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 +// 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; + const put=(n,value)=>out.push({method:value?lit(value)??'not a plain string: '+print(value):'not a plain string: shorthand',place:place(n,tree)}); + (function v(n){ + if(ts.isPropertyAssignment(n)&&key(n.name)==='method')put(n,n.initializer); + else if(ts.isShorthandPropertyAssignment(n)&&n.name.text==='method')put(n); + else if(ts.isBinaryExpression(n)&&n.operatorToken.kind===ts.SyntaxKind.EqualsToken&&(ts.isPropertyAccessExpression(n.left)&&n.left.name.text==='method'||ts.isElementAccessExpression(n.left)&&lit(n.left.argumentExpression)==='method'))put(n,n.right); + ts.forEachChild(n,v); + })(tree); + return out; +} +const methods={}; +for(const f of sources){ + const h=methodsOf(baseline('web/src/'+f),f),n=methodsOf(read('web/src/'+f),f); + if(f==='wallet.tsx')assert.deepEqual(n.filter(x=>x.place==='useWallet > disconnect'),[],'wallet.tsx: no wallet or RPC method inside disconnect()'); + 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 +// 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){ + const tree=parse(text,file),print=printerOf(tree),out=[]; + (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']]}; +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'); +} +// The functions named `name` in a file (at any depth), printed without comments. +function functionsNamed(text,file,name){ + const tree=parse(text,file),print=printerOf(tree),out=[]; + (function v(n){if(ts.isFunctionDeclaration(n)&&n.name?.text===name)out.push(print(n));ts.forEachChild(n,v);})(tree); + return out; +} +const pinnedFunctions={}; +function sameFunction(f,name,what){ + const h=functionsNamed(baseline('web/src/'+f),f,name),n=functionsNamed(read('web/src/'+f),f,name); + assert.equal(h.length,1,f+': one '+name+'() at HEAD'); + 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=[]; +{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 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'); + const returns=hook[0].body.statements.filter(ts.isReturnStatement); + 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 +// 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 +// 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); + const put=(map,name,what)=>(map[name]??=[]).push(what); + const bound=(b,f)=>{if(ts.isIdentifier(b))f(b.text);else for(const e of b.elements)if(!ts.isOmittedExpression(e))bound(e.name,f);}; + const level=(statements,map)=>{for(const st of statements){ + if(ts.isVariableStatement(st))for(const d of st.declarationList.declarations)bound(d.name,name=>put(map,name,print(d))); + if((ts.isFunctionDeclaration(st)||ts.isClassDeclaration(st))&&st.name)put(map,st.name.text,ts.isFunctionDeclaration(st)?'function':'class'); + }}; + for(const st of tree.statements)if(ts.isImportDeclaration(st)&&st.importClause){ + const from=' from '+st.moduleSpecifier.getText(tree),c=st.importClause,b=c.namedBindings; + if(c.name)put(module,c.name.text,'import default'+from); + if(b&&ts.isNamespaceImport(b))put(module,b.name.text,'import *'+from); + if(b&&ts.isNamedImports(b))for(const e of b.elements)put(module,e.name.text,(c.isTypeOnly||e.isTypeOnly?'import type ':'import ')+(e.propertyName?e.propertyName.text+' as ':'')+e.name.text+from); + } + level(tree.statements,module); + 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 +// 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, +// so no function or block put around unchanged transaction code can give one of its names another meaning. +function wrappedNames(text,file,functionNames){ + const tree=parse(text,file),print=printerOf(tree),out=[]; + const each=(b,f)=>{if(ts.isIdentifier(b))f(b.text);else for(const e of b.elements)if(!ts.isOmittedExpression(e))each(e.name,f);}; + // What a function, block, catch or loop declares itself: name -> printed declaration. + const made=s=>{const d=new Map(),add=(b,node)=>each(b,name=>d.set(name,print(node))); + if(ts.isFunctionLike(s)){for(const p of s.parameters)add(p.name,p);if(ts.isFunctionExpression(s)&&s.name)d.set(s.name.text,'its own name');} + if(ts.isCatchClause(s)&&s.variableDeclaration)add(s.variableDeclaration.name,s.variableDeclaration); + if((ts.isForStatement(s)||ts.isForOfStatement(s)||ts.isForInStatement(s))&&s.initializer&&ts.isVariableDeclarationList(s.initializer))for(const x of s.initializer.declarations)add(x.name,x); + if(ts.isBlock(s)||ts.isCaseClause(s)||ts.isDefaultClause(s))for(const st of s.statements){if(ts.isVariableStatement(st))for(const x of st.declarationList.declarations)add(x.name,x);if((ts.isFunctionDeclaration(st)||ts.isClassDeclaration(st))&&st.name)d.set(st.name.text,'function or class '+st.name.text);} + return d;},known=new Map(),declares=s=>known.get(s)??known.set(s,made(s)).get(s); + const within=(n,x)=>{for(;n;n=n.parent)if(n===x)return true;return false;}; + let count=0; + const code=(x,label)=>{const k=++count,component=(()=>{let c;for(let p=x;p;p=p.parent)if(ts.isFunctionDeclaration(p)&&ts.isSourceFile(p.parent))c=p;return c;})(); + (function v(n){ + if(ts.isTypeNode(n))return; + if(ts.isIdentifier(n))for(let s=n.parent;s&&s!==component;s=s.parent){ + const how=declares(s).get(n.text); + if(how===undefined)continue; + if(!within(s,x)&&s!==component?.body)out.push('#'+k+' '+label+': '+n.text+' is declared around it by '+how); + break; + } + ts.forEachChild(n,v); + })(x);}; + (function v(n){ + if(ts.isFunctionDeclaration(n)&&n.name&&functionNames.includes(n.name.text))code(n,n.name.text+'()'); + else if(ts.isJsxAttribute(n)&&isAction(n.name.getText(tree))&&(TX_ATTRIBUTES.has(n.name.getText(tree))||(function g(m){return isGuarded(m,tree)||!!ts.forEachChild(m,g);})(n)))code(n,place(n.parent,tree)+' '+n.name.getText(tree)); + ts.forEachChild(n,v); + })(tree); + return {count,names:[...new Set(out)]}; +} +const transactionCode={}; +for(const f of Object.keys(editedFiles)){ + const pinnedHere=f==='wallet.tsx'?['send']:f==='Flows.tsx'?['approveAll']:[],h=wrappedNames(baseline('web/src/'+f),f,pinnedHere),n=wrappedNames(read('web/src/'+f),f,pinnedHere); + assert.deepEqual(n,h,f+': a name used by the transaction code is declared around it differently from HEAD'); + 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':{}, +}; +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'); + 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'); + 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; + } +} // 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; v10 adds no refusal, never accepts what HEAD -// refused, and refuses with HEAD's words. +// 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). 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))){ @@ -294,7 +797,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 (v10 documents no piece: [v10, HEAD] lists are empty), or equals the pinned v9 text: the Guardian and +// HEAD (v11 documents no piece: the [v11, 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={ @@ -340,25 +843,37 @@ 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 v10 adds none. +// still made, by HEAD and now alike, and v11 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)); fs.rmSync(scratch,{recursive:true,force:true}); -fs.writeFileSync('../artifacts/calldata-comparison.json',JSON.stringify({result:'PASS',baseline:git(['rev-parse','HEAD']).trim(),protectedFiles,editedFiles:Object.fromEntries(Object.entries(editedFiles).map(([f,x])=>[f,x.length+' documented pieces [v10, HEAD]'])),v10Pieces:editedFiles,counts, - documentedGuardedCallChanges:expected,vaultStockShelves:{attributes:vaultAttributes,handlers:vaultHandlers},walletSendDocumentedPieces:sendEdits.map(([now,head])=>({now,head})), +// 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 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, + 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, 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))}, + 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}, 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:['v10 scope: Losses, poolMath, deployment, the ABI, main, components, Owner, wallet, governance, chain and newYork byte-identical to HEAD; Docs.tsx, Flows.tsx, Vault.tsx and model.ts byte-identical to HEAD once their documented v10 pieces are put back (Docs: capital D; Flows: the HoursTag "Redemptions are open 24/7. Deposit hours apply to deposits only." at the top of the Deposit and Redeem forms and "Redemption is always open." out of the Redeem title; Vault: no price time on the labels or in the drawer, the reason-9 "old price" tag kept; model: age() removed); Scenery.tsx byte-identical to HEAD once its two slogan strip pieces are put back (the react import with the Marquee hook that runs each half as its own Web Animation, and the track ref); no source file added or removed', - 'Every vault/token/feed/read call, transaction argument parser, encoding, simulation and send (guarded names) in every web/src source equals the HEAD AST in order (v10 changes none)', - 'Action/send/check JSX attributes equal HEAD in every file, in order (v10 changes none); Vault.tsx has no guarded call at all, its attributes equal the pinned list (Retry vault button and address links plus the Stock shelves controls, none with a guarded call) and its StockShelves handlers equal their pinned text', - 'Wallet sends and raw requests: in every web/src file the uses of a send/request name or of the provider (ethereum) with their calls, every string naming a sending or signing method, computed access to the global object and eval/Function/Reflect equal HEAD in order, with no exception; Vault, Scenery, main and Docs have no send or request (main only reads window.ethereum), components keeps the single wallet.send of HEAD', + 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', - 'wallet.tsx send() (the only wallet prompt) equals HEAD: from, to, data, gas, nonce, chain and account rechecks, simulation and the eth_sendTransaction request unchanged', - 'Every call expression in components.tsx, Docs.tsx and Flows.tsx equals HEAD (the 24/7 tag is markup only)', '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 v10 files change in web/src (check-bundle.mjs)']},null,2)+'\n'); + 'Only the v11 files change in web/src (check-bundle.mjs)']},null,2)+'\n'); console.log('PASS baseline call-expression comparison',counts,'differential encoding',tally,'send builders',sends); diff --git a/web/scripts/check-visibility.mjs b/web/scripts/check-visibility.mjs index 3aeadba..bb39493 100644 --- a/web/scripts/check-visibility.mjs +++ b/web/scripts/check-visibility.mjs @@ -2,10 +2,12 @@ import fs from 'node:fs';import http from 'node:http';import path from 'node:pat const server=http.createServer((req,res)=>{const ps=req.url.split('?')[0].split('/'),root=ps[1]==='old'?process.env.BASKET_BASELINE_DIST:path.resolve('../dist'),p=path.join(root,ps.slice(2).join('/')||'index.html');try{res.setHeader('Content-Type',({'.js':'application/javascript','.css':'text/css','.html':'text/html','.svg':'image/svg+xml','.webp':'image/webp','.woff2':'font/woff2'})[path.extname(p)]||'text/plain');res.end(fs.readFileSync(p));}catch{res.writeHead(404).end();}});await new Promise(r=>server.listen(0,'127.0.0.1',r)); const b=await chromium.launch(),page=await b.newPage({viewport:{width:375,height:812},reducedMotion:'reduce'});const results=[]; const owner=JSON.parse(fs.readFileSync('../artifacts/live-validation.json')).globals.owner; -// v10 (the previous build is v9): nothing may leave the first phone screen. Every element visible on the first screen +// 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. The v10 changes (the 24/7 tag at the top of the Deposit and Redeem forms, the shorter Redeem -// title sentence, no price times on the Stock shelves) all sit below the first screen on phones. +// 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. 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();}}); diff --git a/web/scripts/run-browser.mjs b/web/scripts/run-browser.mjs index 4a21000..2129784 100644 --- a/web/scripts/run-browser.mjs +++ b/web/scripts/run-browser.mjs @@ -11,6 +11,7 @@ import { http as transport, encodeFunctionData, decodeFunctionData, + decodeEventLog, keccak256, zeroAddress, parseAbi, @@ -20,6 +21,7 @@ import { } from "viem"; import { VAULT, RUNTIME_HASH } from "../src/deployment.ts"; import { chain, vaultAbi, GAS } from "../src/chain.ts"; +import { fmt } from "../src/model.ts"; import { inside, nextOpening, weekSecond, WEEK } from "../src/newYork.ts"; import { mainnet } from "viem/chains"; const { chromium } = await import( @@ -177,6 +179,8 @@ const proc = spawn( ], { stdio: ["ignore", "pipe", "pipe"] }, ); +// Drain child output even when an Anvil build logs with --silent. +proc.stdout.resume(); let forkLog = ""; proc.stderr.on("data", (d) => (forkLog += d)); let browser, server, page; @@ -454,6 +458,16 @@ try { page.on("response", (r) => { if (r.status() >= 400) issues.push(`${r.status()} ${r.url()}`); }); + // v11: the page's console errors; every wallet request by method, in order; + // whether the page wrongly asked for wallet_revokePermissions; the hash of + // the last send. + const consoleErrors = [], + asked = [], + revoked = []; + let lastSent; + page.on("console", (m) => { + if (m.type() === "error") consoleErrors.push(m.text()); + }); let currentAccount = owner, currentChain = "0x1237", now = Number((await pc.getBlock()).timestamp) * 1000, @@ -461,6 +475,7 @@ try { declineNext = false, pendingNext = false; await page.exposeFunction("testWallet", async ({ method, params }) => { + asked.push(method); if (method === "eth_accounts" || method === "eth_requestAccounts") return [currentAccount]; if (method === "eth_chainId") return currentChain; @@ -486,7 +501,14 @@ 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 raw(method, params); + return (lastSent = await raw(method, params)); + } + if (method === "wallet_revokePermissions") { + // Disconnect is local to this page and must never make this request. + // Keep the trap: both the Disconnect step and the final check require + // this record to stay empty. It is never counted as a send. + revoked.push({ params, refused: true }); + throw Object.assign(new Error("Method not found"), { code: -32601 }); } return raw(method, params); }); @@ -508,8 +530,17 @@ try { window.__fastReceipt && delay === 180000 ? 500 : delay, ...args, ); + // 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. window.ethereum = { - request: (a) => window.testWallet(a), + request: async (a) => { + try { + return await window.testWallet(a); + } finally { + window.__walletAnswers = (window.__walletAnswers ?? 0) + 1; + } + }, on() {}, removeListener() {}, }; @@ -659,6 +690,67 @@ try { if (await checkbox.count()) await checkbox.check(); await sendClick(box.getByRole("button", { name: button, exact: true }), fn); } + // v11 Deposit form. One row per stock, found by its amount box. + const depositRow = (symbol) => + page.locator(".deposit-row").filter({ + has: page.getByLabel(`${symbol} amount`, { exact: true }), + }); + // The rows as a visitor reads them, top to bottom: ticker, balance line and + // the text in the amount box. + const depositRows = () => + page + .locator(".deposit-row") + .evaluateAll((rows) => + rows.map((row) => [ + row.querySelector("label").textContent, + row.querySelector("small").textContent, + row.querySelector("input").value, + ]), + ); + const search = page.getByLabel("Add another stock", { exact: true }); + // The Add buttons the search offers now. + const offered = () => + page.getByRole("button", { name: /^Add / }).allInnerTexts(); + // Waits until a row button, found by its exact name, can be pressed (Max is + // off while the wallet's balances are read again). + const pressable = (name) => + page.waitForFunction( + (name) => + document.querySelector(`button[aria-label="${name}"]`)?.disabled === + 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() { + 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(); + } + // previewDeposit reads the page has made so far. + const previewDeposit = encodeFunctionData({ + abi: vaultAbi, + functionName: "previewDeposit", + args: [[], []], + }).slice(0, 10); + const previews = () => + rpcCalls.filter( + (c) => + c.method === "eth_call" && c.params[0].data?.startsWith(previewDeposit), + ).length; + // Runs a step and waits until the page has had the wallet's answers to the + // `count` requests the step makes. + async function walletAnswers(count, step) { + const before = await page.evaluate(() => window.__walletAnswers ?? 0); + await step(); + await page.waitForFunction( + (n) => window.__walletAnswers >= n, + before + count, + ); + } const shots = []; async function screenshots(state) { for (const width of [1440, 375]) { @@ -673,10 +765,13 @@ try { ]) { await nav(name); await page.evaluate(() => document.fonts.ready); + // v11: the Deposit form's rows come with the wallet's balances. await page.waitForFunction( () => - ![...document.querySelectorAll("p, small, button")].some((e) => - e.textContent.includes("Reading..."), + ![...document.querySelectorAll("p, small, button")].some( + (e) => + e.textContent.includes("Reading...") || + e.textContent.includes("Reading your wallet..."), ), ); await page.waitForFunction(() => { @@ -697,12 +792,14 @@ try { `${state} ${name} overflow ${width}`, ); const file = `${state}-${name.toLowerCase()}-${width}.jpg`; - // Quality 36 (40 before v9): the Docs FAQ and the longer Owner texts made - // the quality-40 set about 163 KB larger, over the 8 MiB delivery budget. + // 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. await page.screenshot({ path: path.join(artifacts, file), type: "jpeg", - quality: 36, + quality: 32, fullPage: true, }); shots.push(file); @@ -861,6 +958,26 @@ try { // A US market holiday inside the hours: every feed three hours old. for (const f of feeds) await tx(f, compiled.TestFeed.abi, "setLag", [3 * 3600]); assert.equal(Number((await rv("depositStatus", [[]]))[0]), 14); + // v11: the Deposit form opens with the stocks the wallet holds. The test + // wallet (the owner) holds 1,000,000 of each of the 25 listed stocks; for its + // first look at the form it holds none of S22 and one base unit of S21 (far + // under one cent). Both get their million back before deposits reopen. + const listed = indices.map((i) => symbols[i]), + million = 1000000n * 10n ** 18n, + full = "Wallet balance: 1,000,000 · about $100,000,000", + walletBalance = (i) => + pc.readContract({ + address: stocks[i], + abi: compiled.TestStock.abi, + functionName: "balanceOf", + args: [owner], + }); + await tx(stocks[25], compiled.TestStock.abi, "burn", [owner, million]); + await tx(stocks[24], compiled.TestStock.abi, "burn", [owner, million - 1n]); + assert.deepEqual( + [await walletBalance(24), await walletBalance(25)], + [1n, 0n], + ); // v10: the 24/7 redemption tag is always shown on Deposit (closed here, open below) and Redeem. const hoursTag = "Redemptions are open 24/7. Deposit hours apply to deposits only."; await nav("Deposit"); @@ -868,33 +985,250 @@ try { 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(hoursTag, { exact: true }).waitFor(); assert.equal(await page.getByText(/Deposits reopen /).count(), 0, "no reopen time on a holiday"); - await page.getByRole("button", { name: "Add FIG", exact: true }).click(); + // v11: closed, so the status is the full box: title, reason, when it + // reopens, hours. + 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); + // 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(); + assert.deepEqual( + await depositRows(), + listed.slice(0, 23).map((s) => [s, full, ""]), + ); + assert.deepEqual(await offered(), []); + checks.push( + "Deposit form lists the test wallet's stocks without any Add press: 23 rows, not S22 (none held) nor S21 (under one cent)", + ); await page.getByLabel("FIG amount", { exact: true }).fill("10"); await page.getByRole("button", { name: "Preview deposit", exact: true }).click(); await page.getByText(/too few fresh prices/).first().waitFor(); + // v11: the status box says these words too, so wait for the refusal itself. + await page + .getByText("too few fresh prices. Stock at fault: none.", { exact: true }) + .waitFor(); checks.push("US market holiday: Freshness refusal (reason 14) with no set time"); + // v11: a balance worth under one cent waits behind a toggle. + await page + .getByRole("button", { name: "Show small balances (1)", exact: true }) + .click(); + await page.getByLabel("S21 amount", { exact: true }).waitFor(); + assert.deepEqual(await depositRows(), [ + ...listed.slice(0, 23).map((s) => [s, full, s === "FIG" ? "10" : ""]), + ["S21", "Wallet balance: <0.0001 · under $0.01", ""], + ]); + await page + .getByRole("button", { name: "Hide small balances", exact: true }) + .click(); + await page + .getByLabel("S21 amount", { exact: true }) + .waitFor({ state: "detached" }); + await page + .getByRole("button", { name: "Show small balances (1)", exact: true }) + .waitFor(); + checks.push( + "Deposit form keeps a balance under one cent behind Show small balances (1)", + ); + // v11: the search offers Add buttons only while text is typed, never for a + // stock that is on screen, and six at most. "s2" matches S20, S21 and S22; + // S20 has a row. With S10 to S16 removed, "s1" matches those seven. + await search.fill("s2"); + await page.getByRole("button", { name: "Add S22", exact: true }).waitFor(); + assert.deepEqual(await offered(), ["Add S21", "Add S22"]); + for (const s of listed.slice(12, 19)) + await page + .getByRole("button", { name: `Remove ${s}`, exact: true }) + .click(); + await search.fill("s1"); + await page.getByText("1 more: keep typing.", { exact: true }).waitFor(); + assert.deepEqual( + await offered(), + listed.slice(12, 18).map((s) => `Add ${s}`), + ); + await search.fill(""); + await page + .getByRole("button", { name: "Add S10", exact: true }) + .waitFor({ state: "detached" }); + assert.deepEqual(await offered(), []); + assert.equal( + await page.getByLabel("FIG amount", { exact: true }).inputValue(), + "10", + ); + checks.push( + "Deposit search: Add buttons only while text is typed, at most six, never for a stock on screen", + ); + await tx(stocks[25], compiled.TestStock.abi, "mint", [owner, million]); + await tx(stocks[24], compiled.TestStock.abi, "mint", [owner, million - 1n]); + assert.deepEqual( + [await walletBalance(24), await walletBalance(25)], + [million, million], + ); // One feed updates and deposits pass again. await tx(feeds[1], compiled.TestFeed.abi, "setLag", [0]); 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], + ]); + 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", + ); + // 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 + // were removed above, so only they can be pressed. A press adds that + // stock's row, empty, in its place, with the focus in its amount box. (This + // fixture lists its stocks in A to Z order, so the walk does not show the + // list's own sorting.) The reload below clears it all. + const allStocks = page.locator("details.all-stocks"), + tickers = () => + allStocks + .locator("ul button") + .evaluateAll((list) => list.map((b) => [b.textContent, b.disabled])); + assert.equal(await allStocks.count(), 1); + assert.equal(await allStocks.evaluate((d) => d.open), false); + assert.equal( + await allStocks.locator("summary").textContent(), + "All stocks you can deposit (25)", + ); + await allStocks.locator("summary").click(); + await allStocks + .getByText("Press a stock to add it to your deposit.", { exact: true }) + .waitFor(); + assert.deepEqual( + await tickers(), + listed.map((s) => [s, !listed.slice(12, 19).includes(s)]), + ); + await allStocks.getByRole("button", { name: "S10", exact: true }).click(); + await page.getByLabel("S10 amount", { exact: true }).waitFor(); + assert.equal( + await page.locator(":focus").getAttribute("aria-label"), + "S10 amount", + ); + assert.deepEqual( + await tickers(), + listed.map((s) => [s, !listed.slice(13, 19).includes(s)]), + ); + assert.deepEqual( + await depositRows(), + listed + .filter((s) => !listed.slice(13, 19).includes(s)) + .map((s) => [s, full, s === "FIG" ? "10" : ""]), + ); + assert.deepEqual(await offered(), []); + checks.push( + "All stocks you can deposit (25): closed until pressed, the tickers of all 25 open stocks, only the stocks without a row can be pressed, and a press adds that stock's empty row with the focus in its amount box", + ); // Same hash: reload so the holiday attempt's chosen stock is cleared. await page.reload(); await nav("Deposit"); await page.getByText(hoursTag, { exact: true }).waitFor(); - await page.getByRole("button", { name: "Add FIG", exact: true }).click(); - await page - .getByRole("button", { name: "Use full balance", exact: true }) - .click(); + // v11: nothing of the form is kept across a reload: all 25 rows, empty. + await page.getByLabel("FIG amount", { exact: true }).waitFor(); + assert.deepEqual( + await depositRows(), + listed.map((s) => [s, full, ""]), + ); + // v11: a stock that fails its own check has a greyed row. S03's feed goes + // older than maxAge (reason 9). S03 is not deposited below and the vault + // holds none of it, so the vault's own deposit status stays 0. + const grey = 6, + feedLag = { + address: feeds[grey], + abi: compiled.TestFeed.abi, + functionName: "lag", + }, + lagBefore = await pc.readContract(feedLag), + ownReason = async () => + (await rv("allAssets"))[indices.indexOf(grey)].reason, + blockedWords = + "Deposits of this stock are blocked right now: feed price missing or too old.", + look = (symbol) => + depositRow(symbol).evaluate((row) => [ + getComputedStyle(row.querySelector("label")).color, + getComputedStyle(row.querySelector("input")).backgroundColor, + ]), + plain = await look("S03"); + await tx(feeds[grey], compiled.TestFeed.abi, "setLag", [ + (await rv("settings")).maxAge + 3600n, + ]); + assert.equal(await ownReason(), 9); + assert.equal(Number((await rv("depositStatus", [[]]))[0]), 0); + await refresh(); + await depositRow("S03").getByText(blockedWords).waitFor(); + // Balances are read again, so Max S03 is off for the row's own reason. + await pressable("Max FIG"); + assert.equal( + await depositRow("S03").locator("p").textContent(), + blockedWords + " Refresh", + ); + assert.equal( + await page + .getByText(/^Deposits of this stock are blocked right now: /) + .count(), + 1, + ); + assert.deepEqual( + [ + await page.getByLabel("S03 amount", { exact: true }).isDisabled(), + await page + .getByRole("button", { name: "Max S03", exact: true }) + .isDisabled(), + await page + .getByRole("button", { name: "Remove S03", exact: true }) + .isEnabled(), + await page + .getByRole("button", { name: "Refresh S03", exact: true }) + .isEnabled(), + await page.getByLabel("FIG amount", { exact: true }).isEnabled(), + ], + [true, true, true, true, true], + ); + 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], + ]); + // 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); + assert.equal(await ownReason(), 0); + await page.getByRole("button", { name: "Refresh S03", exact: true }).click(); + await page.getByText(blockedWords).waitFor({ state: "detached" }); + await pressable("Max S03"); + assert.ok(await page.getByLabel("S03 amount", { exact: true }).isEnabled()); + assert.deepEqual(await look("S03"), plain); + assert.deepEqual( + await depositRows(), + listed.map((s) => [s, full, ""]), + ); + checks.push( + "Greyed row: a stock failing its own check (feed too old) says why; amount box and Max off, Remove and Refresh on; Refresh clears it once the feed is back", + ); + // v11: "Max FIG" (v10: "Add FIG", then "Use full balance"). + await page.getByRole("button", { name: "Max FIG", exact: true }).click(); assert.equal( await page.getByLabel("FIG amount", { exact: true }).inputValue(), "1000000", ); - await page.getByLabel("Search to add stocks", { exact: true }).fill("OAT"); + await search.fill("OAT"); assert.equal( await page.getByLabel("FIG amount", { exact: true }).inputValue(), "1000000", ); + // v11: OAT has a row, so the search does not offer it. + assert.deepEqual(await offered(), []); checks.push( "Exact full stock balance and chosen amount retained while searching", ); @@ -910,17 +1244,90 @@ try { .waitFor(); checks.push("CapExceeded explained before approvals"); await page.getByLabel("FIG amount", { exact: true }).fill("10"); + // v11: OAT and S01 have rows already. Remove takes OAT's row away, the search + // (any letter case) then offers it, and Add OAT brings back an empty row + // with the focus in its amount box. + await page.getByRole("button", { name: "Remove OAT", exact: true }).click(); + await page + .getByLabel("OAT amount", { exact: true }) + .waitFor({ state: "detached" }); + assert.deepEqual( + await depositRows(), + listed + .filter((s) => s !== "OAT") + .map((s) => [s, full, s === "FIG" ? "10" : ""]), + ); + await search.fill("oat"); + await page.getByRole("button", { name: "Add OAT", exact: true }).waitFor(); + assert.deepEqual(await offered(), ["Add OAT"]); await page.getByRole("button", { name: "Add OAT", exact: true }).click(); + await page.getByLabel("OAT amount", { exact: true }).waitFor(); + assert.equal( + await page.locator(":focus").getAttribute("aria-label"), + "OAT amount", + ); + assert.equal(await search.inputValue(), ""); + assert.deepEqual( + await depositRows(), + listed.map((s) => [s, full, s === "FIG" ? "10" : ""]), + ); + checks.push( + "Deposit row: Remove takes OAT away, the search then offers Add OAT, and that brings back an empty row", + ); await page.getByLabel("OAT amount", { exact: true }).fill("20"); - await page.getByLabel("Search to add stocks", { exact: true }).fill("S01"); - await page.getByRole("button", { name: "Add S01", exact: true }).click(); + // v11: Enter in the search never runs Preview. With no match it does + // nothing; with matches it adds the first one (S01, not S02) and the focus + // stays in the search. v10 typed "S01" and pressed "Add S01". + const previewsBefore = previews(); + for (const s of ["S01", "S02"]) + await page + .getByRole("button", { name: `Remove ${s}`, exact: true }) + .click(); + await search.fill("zzz"); + await page.getByText("No open stock matches.", { exact: true }).waitFor(); + await search.press("Enter"); + assert.equal(await search.inputValue(), "zzz"); + await search.fill("s0"); + await page.getByRole("button", { name: "Add S01", exact: true }).waitFor(); + assert.deepEqual(await offered(), ["Add S01", "Add S02"]); + await search.press("Enter"); + await page.getByLabel("S01 amount", { exact: true }).waitFor(); + assert.equal(await page.locator(":focus").getAttribute("type"), "search"); + assert.equal(await search.inputValue(), ""); + assert.deepEqual( + await depositRows(), + listed + .filter((s) => s !== "S02") + .map((s) => [s, full, { FIG: "10", OAT: "20" }[s] ?? ""]), + ); await page.getByLabel("S01 amount", { exact: true }).fill("30"); + // v11: the running total of the three typed amounts, called an estimate. + const total = await page.getByText(/^Total: about /).innerText(); + await page + .getByText( + "An estimate from the last vault read. Preview gives the exact figure.", + { exact: true }, + ) + .waitFor(); await page .getByRole("button", { name: "Preview deposit", exact: true }) .click(); await page .getByRole("heading", { name: "Deposit preview", exact: true }) .waitFor(); + // Every read passes the route in order: had an Enter press started a + // preview, its read would be counted here beside this one. + assert.equal(previews(), previewsBefore + 1); + checks.push("Enter in the search adds the first match and starts no preview"); + const dollars = await page + .locator(".receipt dl > div") + .filter({ has: page.locator("dt", { hasText: /^Dollar value$/ }) }) + .locator("dd") + .innerText(); + assert.deepEqual([total, dollars], ["Total: about $6,000", "$6,000"]); + checks.push( + "Running total equals the preview's Dollar value for the three-stock deposit", + ); await page .getByRole("button", { name: "Preview deposit", exact: true }) .hover(); @@ -947,7 +1354,12 @@ try { page.getByRole("button", { name: "Deposit", exact: true }), "deposit", ); - assert.equal(await page.getByLabel("FIG amount", { exact: true }).count(), 0); + // v11: the typed amounts are gone with the preview, but FIG keeps its row + // (in v10 the chosen rows went away: count 0). + assert.equal( + await page.getByLabel("FIG amount", { exact: true }).inputValue(), + "", + ); assert.equal( await page .getByRole("heading", { name: "Deposit preview", exact: true }) @@ -965,6 +1377,120 @@ try { checks.push( "Three-stock deposit succeeds with estimated gas +30%, exact approvals, preview invalidated after success", ); + // v11: once the balances are read again, every wallet stock still has its + // row with an empty box, S02 too although it was removed. The three + // deposited stocks are worth less now, so they come last, largest first. + await page + .getByText("Wallet balance: 999,990 · about $99,999,000", { exact: true }) + .waitFor(); + assert.deepEqual(await depositRows(), [ + ...listed + .filter((s) => !["FIG", "OAT", "S01"].includes(s)) + .map((s) => [s, full, ""]), + ["FIG", "Wallet balance: 999,990 · about $99,999,000", ""], + ["OAT", "Wallet balance: 999,980 · about $99,998,000", ""], + ["S01", "Wallet balance: 999,970 · about $99,997,000", ""], + ]); + checks.push( + "After the deposit the wallet's rows are still there with empty boxes", + ); + // 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 + .filter((l) => l.address.toLowerCase() === VAULT) + .map((l) => { + try { + return decodeEventLog({ + abi: vaultAbi, + data: l.data, + topics: l.topics, + }); + } catch { + return undefined; + } + }) + .find((l) => l?.eventName === "Deposit"); + assert.equal(minted.args.receiver.toLowerCase(), owner.toLowerCase()); + await page + .getByText(`You received ${fmt(minted.args.shares)} BASK.`, { + exact: true, + }) + .waitFor(); + assert.equal(await page.getByText(/^You received /).count(), 1); + checks.push( + `"You received ${fmt(minted.args.shares)} BASK." equals the shares of the Deposit event in the deposit's receipt`, + ); + // 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. + const header = page.locator("header"), + address = page.locator(".connected-address"), + short = `${owner.slice(0, 6)}…${owner.slice(-4)}`, + before = [asked.length, sends.length, consoleErrors.length]; + assert.equal(await address.innerText(), short); + await page.getByRole("button", { name: "Disconnect", exact: true }).click(); + await header + .getByRole("button", { name: "Connect wallet", exact: true }) + .waitFor(); + assert.equal( + await page.locator(".wallet-status").textContent(), + "Wallet disconnected from this page. To use another wallet, switch the address in your wallet app, then press Connect wallet.", + ); + await page + .getByText("Connect your wallet to see the stocks you hold.", { + exact: true, + }) + .waitFor(); + assert.deepEqual( + [ + await address.count(), + await page + .getByRole("button", { name: "Disconnect", exact: true }) + .count(), + await page.locator(".deposit-row").count(), + await page.getByText(/^You received /).count(), + ], + [0, 0, 0, 0], + ); + assert.deepEqual(asked.slice(before[0]), []); + assert.deepEqual(revoked, []); + assert.equal(sends.length, before[1]); + assert.deepEqual([issues, consoleErrors.slice(before[2])], [[], []]); + // After a reload the wallet still answers eth_accounts with the account, + // and the page does not take it. + await page.reload(); + await nav("Deposit"); + await page.waitForFunction(() => window.__walletAnswers >= 2); + await page + .getByText("Connect your wallet to see the stocks you hold.", { + exact: true, + }) + .waitFor(); + assert.deepEqual( + [...new Set(asked.slice(before[0]))], + ["eth_accounts", "eth_chainId"], + ); + assert.deepEqual( + [await address.count(), await page.locator(".deposit-row").count()], + [0, 0], + ); + await header + .getByRole("button", { name: "Connect wallet", exact: true }) + .waitFor(); + await walletAnswers(2, () => + header.getByRole("button", { name: "Connect wallet", exact: true }).click(), + ); + await address.waitFor(); + assert.equal(await address.innerText(), short); + assert.deepEqual(asked.slice(-2), ["eth_requestAccounts", "eth_chainId"]); + assert.equal(await page.locator(".wallet-status").textContent(), ""); + await page.getByLabel("FIG amount", { exact: true }).waitFor(); + assert.equal(await page.locator(".deposit-row").count(), 25); + assert.equal(sends.length, before[1]); + assert.deepEqual([issues, consoleErrors.slice(before[2])], [[], []]); + checks.push( + "Disconnect: address gone, Connect wallet shown, the exact top message, still disconnected after a reload, Connect wallet brings the address back; Disconnect makes no wallet request or send", + ); // A Saturday: deposits refused (Hours) with the reopen time; redeem and claim still work. await warpTo(await saturday()); assert.equal(await rv("insideHours"), false); @@ -974,10 +1500,20 @@ try { 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(hoursTag, { exact: true }).waitFor(); - await page.getByRole("button", { name: "Add FIG", exact: true }).click(); + // 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.equal(await address.innerText(), short); await page.getByLabel("FIG amount", { exact: true }).fill("1"); await page.getByRole("button", { name: "Preview deposit", exact: true }).click(); await page.getByText(/outside deposit hours/).first().waitFor(); + // v11: the status box says these words too, so wait for the refusal itself. + await page + .getByText("outside deposit hours. Stock at fault: none.", { exact: true }) + .waitFor(); checks.push("Saturday deposit refused (Hours) with the Sunday 8:00 pm reopening shown"); // Force a deferred leg, then claim it from the receiver wallet. await tx(stocks[0], compiled.TestStock.abi, "setFail", [true]); @@ -1000,6 +1536,12 @@ try { page.getByRole("button", { name: "Redeem", exact: true }), "redeem", ); + // v11: a confirmed redeem empties the amount box. + assert.equal( + await page.getByLabel("BASK amount", { exact: true }).inputValue(), + "", + ); + 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); await tx(stocks[0], compiled.TestStock.abi, "setFail", [false]); @@ -1020,6 +1562,10 @@ try { page.getByRole("button", { name: "Redeem", exact: true }), "redeem", ); + assert.equal( + await page.getByLabel("BASK amount", { exact: true }).inputValue(), + "", + ); for (const t of stocks.slice(0, 2)) await tx(t, compiled.TestStock.abi, "setFail", [false]); await refresh(); @@ -1279,6 +1825,11 @@ try { await nav("Deposit"); await refresh(); await page.getByText(/^Deposits reopen Monday 9:30 am UTC-5 \(Monday, /).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"); await nav("Owner"); // Cancel owner and guardian proposal boundaries, without changing the deployed roles. @@ -1341,6 +1892,18 @@ try { [10n * 10n ** 18n, 20n * 10n ** 6n, 30n * 10n ** 18n], ); assert.deepEqual(issues, []); + // v11: Disconnect adds no wallet request. Connect wallet is pressed only + // in the Disconnect step; the v10 walk never pressed it. + assert.deepEqual( + [...new Set(asked)].sort(), + [ + "eth_accounts", + "eth_chainId", + "eth_requestAccounts", + "eth_sendTransaction", + ], + ); + assert.equal(revoked.length, 0); fs.writeFileSync( path.join(artifacts, "browser-fork.json"), JSON.stringify( diff --git a/web/src/Flows.tsx b/web/src/Flows.tsx index ac548b7..19c3752 100644 --- a/web/src/Flows.tsx +++ b/web/src/Flows.tsx @@ -1,5 +1,5 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import type { Address } from "viem"; +import { decodeEventLog, type Address, type Hex } from "viem"; import { read, many, @@ -9,6 +9,9 @@ import { simulate, InputError, VAULT, + client, + vaultAbi, + reasonWords, } from "./chain"; import { amount, @@ -19,6 +22,7 @@ import { exact, usd, same, + type Asset, type Snapshot, } from "./model"; import { @@ -32,7 +36,7 @@ import { actionReason, Receiver, } from "./components"; -import { DepositState } from "./Vault"; +import { DepositState, hoursLine } 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 @@ -65,6 +69,18 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { >({}), [simulated, setSimulated] = useState(false), [retry, setRetry] = useState(0); + const [received, setReceived] = useState<{ + hash: Hex; + receiver: Address; + shares: bigint; + }>(); + // The rows come from the last finished balance read, so they stay in place + // while a new read runs. Never an input to a transaction. + const [held, setHeld] = useState>(); + const [removed, setRemoved] = useState>({}); + const [showSmall, setShowSmall] = useState(false); + // The stock an Add button just added, until its row is on screen. + const focusRow = useRef
(undefined); const sequence = useRef(0), lock = useRef(false); useLayoutEffect(() => { @@ -82,6 +98,56 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { sequence.current++; } }, [w.confirmed]); + useLayoutEffect(() => { + if (w.confirmed?.functionName === "deposit") setRemoved({}); + }, [w.confirmed]); + // Another wallet's rows must not stay on screen. + useLayoutEffect(() => { + setHeld(undefined); + setRemoved({}); + }, [w.account]); + // The pressed Add button is gone: the new row's amount box takes its focus, + // or the row itself when the stock is blocked and its box is off. + useLayoutEffect(() => { + const box = focusRow.current + ? document.getElementById(`amount-${focusRow.current}`) + : null; + if (box instanceof HTMLInputElement && box.disabled) + box.closest(".deposit-row")?.focus(); + else box?.focus(); + focusRow.current = undefined; + }, [inputs]); + // The BASK a confirmed deposit minted, from the Deposit event in its receipt. + useEffect(() => { + const done = w.confirmed; + if (done?.functionName !== "deposit") return; + let live = true; + 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(() => {}); + return () => { + live = false; + }; + }, [w.confirmed]); useEffect(() => { let live = true; setBalances({}); @@ -104,6 +170,7 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { al[a.token] = ar.ok ? ar.value : undefined; }); setBalances(b); + setHeld(b); setAllowances(al); setReading(false); }); @@ -191,6 +258,134 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { setWorking(false); } } + // 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) => + a.answer !== undefined && a.answer > 0n + ? (n * a.answer * 10n ** 18n) / + 10n ** BigInt(a.tokenDecimals + a.feedDecimals) + : undefined; + // "about $1,234.56"; usd() alone writes a value under one cent as "$<0.01". + const about = (value: bigint) => + value < 10n ** 16n ? "under $0.01" : `about ${usd(value)}`; + // Display only. Preview and Deposit read `inputs` in the vault's order. + const stocks = open.map((a) => { + const balance = held?.[a.token] ?? 0n, + value = balance > 0n ? worth(a, balance) : undefined; + return { + a, + value, + inWallet: balance > 0n, + // Under one cent. An unreadable price is unknown, never small. + small: value !== undefined && value < 10n ** 16n, + added: inputs[a.token] !== undefined, + }; + }); + type Stock = (typeof stocks)[number]; + // A wallet stock the user has not removed. + const kept = (x: Stock) => x.inWallet && !removed[x.a.token]; + const onScreen = (x: Stock) => + x.added || (kept(x) && (!x.small || showSmall)); + const hiddenSmall = stocks.filter((x) => kept(x) && !onScreen(x)).length; + // Wallet stocks by value, largest first, unpriced ones after them; then the + // stocks added by hand. Ties keep the vault's order. + const rows = [ + ...stocks + .filter((x) => onScreen(x) && x.inWallet) + .sort((x, y) => + x.value === y.value + ? 0 + : x.value === undefined + ? 1 + : y.value === undefined || x.value > y.value + ? -1 + : 1, + ), + ...stocks.filter((x) => onScreen(x) && !x.inWallet), + ]; + // The last finished read covers every stock now listed. + const ready = !!held && open.every((a) => a.token in held); + // A gap in that read; it stays so while the next read runs. + const unread = ready && open.some((a) => held[a.token] === undefined); + // Tickers first (the exact one, then those that start with the text, then + // those that contain it), then token addresses from four characters on. + const query = search.trim().toLowerCase(); + const rank = (a: Asset) => { + const ticker = a.symbol.toLowerCase(); + return ticker === query + ? 0 + : ticker.startsWith(query) + ? 1 + : ticker.includes(query) + ? 2 + : query.length >= 4 && a.token.toLowerCase().includes(query) + ? 3 + : 4; + }; + const matches = query + ? stocks + .filter((x) => !onScreen(x) && rank(x.a) < 4) + .map((x) => x.a) + .sort((x, y) => rank(x) - rank(y)) + : []; + // Every open stock by ticker, A to Z, for the list under the search. A copy + // for display only: `open` keeps the vault's order. + const listed = [...open].sort((x, y) => { + const a = x.symbol.toLowerCase(), + b = y.symbol.toLowerCase(); + return a < b ? -1 : a > b ? 1 : 0; + }); + // `focus` is false for Enter in the search box, which keeps the focus. + function add(t: Address, focus = true) { + if (focus) focusRow.current = t; + setRemoved((old) => ({ ...old, [t]: false })); + setInputs((old) => ({ ...old, [t]: "" })); + setSearch(""); + } + function remove(t: Address) { + // The pressed button goes with its row: focus a row beside it or search. + const row = document.getElementById(`amount-${t}`)?.closest(".deposit-row"), + beside = row?.nextElementSibling ?? row?.previousElementSibling; + if (row?.contains(document.activeElement)) { + if (beside instanceof HTMLElement) beside.focus({ preventScroll: true }); + else + document + .querySelector(".stock-search input") + ?.focus({ preventScroll: true }); + } + setRemoved((old) => ({ ...old, [t]: true })); + if (inputs[t] !== undefined) + setInputs((old) => { + const next = { ...old }; + delete next[t]; + return next; + }); + } + // An estimate of the typed amounts, for the same stocks Preview would send. + let total = 0n, + counted = 0; + const badAmount: string[] = [], + noPrice: string[] = []; + for (const a of open) { + const text = inputs[a.token]; + if (!text?.trim()) continue; + let value: bigint | undefined; + try { + value = worth(a, amount(text, false, a.tokenDecimals)); + } catch { + badAmount.push(a.symbol); + continue; + } + if (value === undefined) noPrice.push(a.symbol); + else { + total += value; + counted++; + } + } return ( <> -
+
- + {/* 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.

@@ -275,101 +479,240 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { } }} > - {open.length > 6 && ( + {!w.account ? ( +
+

Connect your wallet to see the stocks you hold.

+ +
+ ) : ( <> - -
- {open - .filter( - (a) => - inputs[a.token] === undefined && - (a.symbol - .toLowerCase() - .includes(search.toLowerCase()) || - a.token - .toLowerCase() - .includes(search.toLowerCase())), - ) - .map((a) => ( - - ))} -
+ {!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 + below. +

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

No open listed stock in this wallet.

+ ) + )} )} - {open - .filter( - (a) => open.length <= 6 || inputs[a.token] !== undefined, - ) - .map((a) => ( -
-
+ )} @@ -497,6 +840,16 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { )} + {/* Only under the status of that same deposit, never under a later + attempt. */} + {received && + received.hash === w.confirmed?.hash && + received.hash === w.progress.Deposit?.hash && + same(received.receiver, w.account) && ( +

+ You received {fmt(received.shares)} BASK. +

+ )}

{error}

@@ -551,6 +904,9 @@ export function RedeemPage({ snapshot: s, wallet: w }: Props) { setQuote(undefined); setWorking(false); }, [input, to, w.account, s.loadedAt]); + useLayoutEffect(() => { + if (w.confirmed?.functionName === "redeem") setInput(""); + }, [w.confirmed]); useEffect(() => { let live = true; setBalance(undefined); diff --git a/web/src/main.tsx b/web/src/main.tsx index 1828dc1..ad97a60 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -112,6 +112,13 @@ function App() { {wallet.account.slice(0, 6)}…{wallet.account.slice(-4)} + {wallet.chainId !== 4663 && (