diff --git a/DESIGN.md b/DESIGN.md index 99a0ac3..e020dd9 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -74,3 +74,19 @@ The link preview is a 1200×630 JPEG of the blue framed panel, red Basket Protoc ## v13 presentation details On the Deposit page the list under the search, "All stocks you can deposit (N)", is shown open while the form is empty: with no wallet connected, or with a connected wallet whose read balances hold no listed open stock while no row is in the form. It is then a div in the fold's place with the same words as a plain bold 14px heading line (no underline, no marker), the same two sentences, and the same stock buttons in the existing auto-fill grid, which fills the whole panel width (as many even columns as fit, left-aligned, nothing centred; a long ticker still takes two columns and wraps inside its button). Once shown for an account it stays open for that account until the account changes or the page is reloaded, also after a press has added a row. With a wallet that holds listed stock the list is the closed fold as before. Nothing moves above the list; what follows it (the Preview deposit button and, below the panel, the "Before you deposit" box) moves down by the list's height. On phones the open list is the same grid with fewer columns. + +## ITEM tokenomics presentation + +The existing Docs route contains local Docs and Tokenomics tabs, with Docs selected initially. The tokenomics panel retains the selected full-width layout, yellow title rule, supply bar, two-column legend, release curves and both tables. At 660px and below, table records stack and the plot uses the compact SVG. The three prose lead phrases retain their approved emphasis. Figures have descriptive accessible labels; semantic tables carry the same numbers. There is no chart animation. + +The present-tense presentation describes the intended operating state. It does not configure deployment addresses. Five null addresses hide every part of the live block. The existing many() helper handles configured reads; a failed read shows only the approved error and Retry control. + +## BASK educational tab + +The local Docs tab row contains Docs, Tokenomics and BASK on one line down to 320px. The original ITEM module is unchanged. The new BASK panel follows its yellow heading rule, blue emphasis, pale formula areas and site typography. Mint examples sit side by side above 900px and stack below; stock tables keep semantic row/column headers and show labeled records on phones. All four worked examples and their equations use one integer data module. Inactive documentation modules unmount. No global navigation, transaction form or price-read behavior changes. + +## v14 presentation details + +Deposit, wider than 960px: the panel's body under the "Make a deposit" heading is a two-column grid (equal columns, 40px gap, items aligned to the top). The left column holds the form as before (rows, search, total, Preview deposit), the preview box and its buttons, the status lines, and then, under a thin line, the "Before you deposit" notes with their heading; the right column holds the stock list, always open: the heading line "All stocks you can deposit (N)" as plain bold text, the sentence "Press a stock to add it to your deposit." and the stock buttons in an auto-fill grid of at least 84px columns. At 960px and below the markup and look are v13's: one column, the list inside the form (open while the form is empty, a closed fold when the wallet holds stock), the notes as a separate panel below. The sentence about grey stocks is gone on every width; the warning note reads "Issuer restrictions and pool checks can temporarily halt deposits." + +Docs, default tab: five more panels in the same grid between "Risks" and "Find Basket" (ITEM; Earn ITEM by providing liquidity, with a gauge table in the module's table style; Earn BASK by staking ITEM; Rewards FAQ with its questions as h3 like the FAQ panel; ITEM risks). BASK tab: two more sections and a fifth worked example in the module's own blocks (paragraphs, bold formula lines, a two-column table). diff --git a/README.md b/README.md index e177bfd..6819ea0 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ What the checks allow in this update: - check-calldata permits exactly Vault, components, Docs, Flows, wallet and the approved main mounting change. Every other TypeScript source and the ABI equal HEAD. Exact pieces restore HEAD, while independent raw protected-block comparisons, connect/disconnect text, counter uses, ordered call hashes, transaction-state writes and reviewed source hashes remain outside regeneration. All chain/parser/encoding/send calls, provider methods, page-load sinks and storage uses equal HEAD. Differential encoding retains 496 identical and 1,773 mutually refused cases. - check-bundle additionally allows styles, the exact share-tag shell and only share-card.jpg as a new public file. Existing public assets, configuration, packages, lockfile, pinned source and contracts remain protected. The delivery limit remains 8,388,608 bytes. - check-art retains manifest, pixel collision, title, strip and shelf assertions, adds the exact approved words and a portable widened 24/7 case, and checks the Vault heading and panel through 320–2560px. check-visibility compares v11 at 375×812, retaining header, strip and character geometry and documenting the original v12 removals and Docs displacement together with the later approved issue-only Vault/Deposit status, Closed sign and removal of both wallet instruction lines. Final measurements are recorded in the build evidence. -- check-fixes retains the pending-transaction tests and exercises Claims blank/quiet/full states, failed reads, retry, retained results, refresh, hidden navigation and account resets. The fork keeps all 74 earlier check positions and the same 70 page sends. Two status-result names change. Of 149 inherited assertion calls, 140 remain byte-identical and ordered; nine status-presentation assertions are replaced explicitly to check no normal notice, the Closed sign and exact short sentences, and absence of the removed label/hours detail. All transaction and on-chain Hours/Dst checks remain. The v12 cases also check the compact Vault notice and both removed wallet instruction lines, along with text/layout, served share image, same-nonce deposit repricing, widened label and late Connect. It expects 84 checks, 70 page sends and 24 screenshots; generated artifacts record actual results. The 24 screenshots use JPEG quality 26 (32 in v11); final evidence records the complete delivery budget and the next-higher-quality comparison. +- check-fixes retains the pending-transaction tests and exercises Claims blank/quiet/full states, failed reads, retry, retained results, refresh, hidden navigation and account resets. The fork keeps all 74 earlier check positions and the same 70 page sends. Two status-result names change. Of 149 inherited assertion calls, 140 remain byte-identical and ordered; nine status-presentation assertions are replaced explicitly to check no normal notice, the Closed sign and exact short sentences, and absence of the removed label/hours detail. All transaction and on-chain Hours/Dst checks remain. The v12 cases also check the compact Vault notice and both removed wallet instruction lines, along with text/layout, served share image, same-nonce deposit repricing, widened label and late Connect. It expects 84 checks, 70 page sends and 24 screenshots; generated artifacts record actual results. The 24 screenshots used JPEG quality 26 in v12 and v13 (32 in v11) and use quality 18 from v14; final evidence records the complete delivery budget and the next-higher-quality comparison. ## Site update v13 @@ -192,3 +192,34 @@ What the checks allow in this update: - check-calldata permits exactly Flows and wallet (styles through check-bundle); Vault, components, Docs and main are frozen again. The two files restore v12 through documented pieces, and independently: the reviewed source digests, the ordered added-call pins (four display-only calls in DepositPage: the new state, the emptiness test, the effect and its setter), the single removed count call, the byte-identical send head, the one tail piece, the ordered mined-hash state writes, the connect/disconnect texts, the counter uses, the hook return, the raw protected blocks (approveAll, every onSubmit, getSpec and transaction button, the Claim all handler, the owed read, the Deposit effects) and the differential encoding (496 identical, 1,773 refused) stay. - check-bundle's source scope is Flows, wallet and styles; the shell, both share JPEG copies, public files, configuration, packages, lockfile, pinned source and contracts remain protected. The delivery limit remains 8,388,608 bytes. - check-art keeps the fixed words ("All stocks you can deposit" and "Press a stock to add it to your deposit." among them) and every title, strip and shelf assertion. check-visibility compares v12 at 375×812: every v12 state must equal the baseline, and the only named exception is the Deposit list (heading line in the fold line's place, exact displacement by the open list's height); in both Deposit variants it asserts the open list, its words, its start and that it spans the panel. check-fixes is unchanged (34 regressions). The fork keeps all 84 v12 check positions and the same 70 page sends and adds two checks: the open list in both empty states with a press that keeps it open and the owner's wallet keeping the closed fold, and the Claim all result line. + +## ITEM tokenomics update + +Docs has local Docs and Tokenomics tabs; Docs opens by default. Tokenomics uses the approved charts-plus-tables presentation, one data source for the allocations and release formulas, semantic tables and responsive inline SVGs. Arrow keys, Home and End select the tabs. Main navigation and routes are unchanged. The existing Docs fee sentence uses the approved present tense and its not-live sentence is removed. + +All five ITEM addresses remain null. The complete live block is absent and no ITEM read occurs until every address is configured. Optional reads use the existing chain helper and verified multicall/fallback, with a retryable unreadable state. No wallet, storage or transaction behavior changes. + +Run the existing checks plus `npx tsx scripts/check-tokenomics.tsx` and `node scripts/check-tokenomics-live.mjs` from web. The latter uses PLAYWRIGHT_MODULE like the browser checks. BASKET_TOKEN_BRIEF optionally supplies the original brief for the numeric test; otherwise its exact numeric excerpt is included in the test. + +## Combined ITEM and BASK Docs update + +This combined patch targets live v13 (`2b7d55bc32d09610fed936d963d9a8c492f4eecf`) and replaces the separate ITEM tokenomics patch. Apply one combined patch to v13, not both. The local tabs are Docs, Tokenomics and BASK; Docs remains the default, with three-tab arrow wrapping, Home and End. The ITEM sources and their full CSS prefix are preserved. + +BASK explains managed NAV, initial and established-supply minting, fee rounding, proportional redemption and queued claims. Four static worked examples derive displayed amounts from bigint calculations in `baskGuideData.ts`; this is educational material, not a transaction preview or calculator. The helpers cover these examples and tested arithmetic boundaries, not every vault error or preview edge case. BASK adds no chain, wallet or storage access. + +Also run `npx tsx scripts/check-bask-guide.tsx`. `BASKET_BASK_CONTENT` may point to the canonical content draft; the shipped `scripts/bask-guide-content.md` is the portable exact-copy fixture. Browser checks cover all three tabs and six widths, without additional RPC, wallet requests or storage writes. Source gates independently pin Docs, both ITEM files and both BASK files; transaction files remain byte-identical to v13. + +## Site update v14 + +The current patch is applied to v13, commit 2b7d55bc32d09610fed936d963d9a8c492f4eecf. The owner's decisions of 2026-10-13, in one update: +- Docs: the Docs-local Tokenomics and BASK tabs (the module described above), with the live fee sentence in "What Basket is" and the "not live" sentence removed; and, in the default Docs tab, five ITEM panels after "Risks" and before "Find Basket": ITEM; Earn ITEM by providing liquidity (with the gauge table); Earn BASK by staking ITEM; Rewards FAQ; ITEM risks. Written as live; no addresses. +- BASK tab: two sections after "Redeeming BASK", "The arbitrage flywheel" and "How TVL and liquidity support the flywheel", with a fifth worked example, "A simple example: ten stocks in the basket". The four earlier examples are unchanged. The example's figures ($99.50 and $109.45 per holding, $796, $218.90, $1,014.90, $14.90; 5 BASK fee, 995 burned, 0.995%) are reproduced by the module's audited helpers in check-bask-guide. +- Deposit, desktop only (wider than 960px): the panel has two columns. Left: the form (rows or the empty-form sentence, the search box, Preview deposit, the preview and its buttons, the status lines) with the "Before you deposit" notes under it, inside the panel; right: the stock list, always open, with the fold's words as a plain heading line. On phones and tablets nothing changes: one column, the list inside the form, open while the form is empty and the closed fold when the wallet holds stock, the notes as their own panel below. The width is read once through matchMedia and kept in a display-only state. +- Deposit words: "Grey stocks are already in the form above." is removed; the warning note reads "Issuer restrictions and pool checks can temporarily halt deposits." +- No transaction input, send preparation, chain read, wallet request, storage key or application network call changes. wallet.tsx, Vault.tsx, components.tsx, main.tsx and every other HEAD source are byte-identical to v13. The four module sources are pinned by reviewed digests and scanned for sinks; the ITEM statistics read stays behind the null-address guard. + +What the checks allow in this update: + +- check-calldata (the v13 gate with the module's pins): edited files are Docs.tsx and Flows.tsx, restored to v13 through documented pieces; wallet.tsx is frozen whole; the four module files are pinned by digest; the reviewed source digests, the ordered added-call pins (Docs: the tab state, its keyboard handler and the tab buttons' map; Flows: the desktop state and its matchMedia listener, and the stock list's and notes' calls moved into JSX constants), the four moved display calls, the one changed declaration (listOpen also open on desktop), the byte-identical send(), connect/disconnect texts, counter uses, hook return, raw protected blocks and the differential encoding (496 identical, 1,773 refused) all stay. The approved module CSS is an exact prefix of styles.css; the v14 Deposit rules follow it. +- check-bundle: source scope Docs, Flows, Tokenomics, tokenomicsData, BaskGuide, baskGuideData and styles; exactly the four module files are new; shell, share JPEG copies, public files, configuration, packages, lockfile, pinned source and contracts protected; the limit stays 8,388,608 bytes. +- check-visibility compares v13 at 375×812 (phones unchanged): the Docs panels are matched by title, the five ITEM panels sit between Risks and Find Basket, every retained panel keeps its size and is displaced only by the tab row, the copy change and the inserted panels. check-art keeps its pinned words. The fork keeps all 86 v13 check positions and the same 70 page sends, replaces the words of three checks for the owner's desktop decisions (the list open in the right-hand column, the notes under the form, the new note) and adds the module's Tokenomics and BASK checks and one for the five ITEM panels; its 24 screenshots keep their names and order at JPEG quality 18 (26 before), because the Docs page is five panels and a tab row longer and the delivery must stay under 8,388,608 bytes. check-tokenomics, check-tokenomics-live and check-bask-guide (five examples, five tables, ten formula lines) are part of the suite. diff --git a/web/scripts/bask-guide-browser.mjs b/web/scripts/bask-guide-browser.mjs new file mode 100644 index 0000000..762a19d --- /dev/null +++ b/web/scripts/bask-guide-browser.mjs @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict'; +import {INTRO, SECTIONS} from '../src/baskGuideData.ts'; +export async function checkBaskGuide(page) { + const tabs=page.getByRole('tab'), initial=page.viewportSize(); + const selected=async i=>{ + assert.equal(await tabs.nth(i).getAttribute('aria-selected'),'true'); + assert.equal(await tabs.nth(i).getAttribute('tabindex'),'0'); + assert.equal(await tabs.nth(i).evaluate(e=>e===document.activeElement),true); + for(let j=0;j<3;j++) { + assert.equal(await tabs.nth(j).getAttribute('aria-controls'),'docs-panel-'+j); + assert.equal(await page.locator('#docs-panel-'+j).getAttribute('aria-labelledby'),'docs-tab-'+j); + assert.equal(await page.locator('#docs-panel-'+j).isVisible(),j===i); + if(j!==i)assert.equal(await tabs.nth(j).getAttribute('tabindex'),'-1'); + } + }; + await tabs.nth(0).focus();await page.keyboard.press('End');await selected(2); + const module=page.locator('.bg-module');await module.waitFor(); + assert.equal(await module.locator('.bg-intro').textContent(),INTRO); + const blocks=SECTIONS.flatMap(s=>[...s.blocks,...s.examples.flatMap(e=>e.blocks)]); + assert.deepEqual(await module.locator('p').allTextContents(),[INTRO,...blocks.filter(b=>b.kind==='p').map(b=>b.text)]); + assert.deepEqual(await module.locator('.bg-formula').allTextContents(),blocks.filter(b=>b.kind==='formula').map(b=>b.text)); + assert.deepEqual(await module.locator('h3').allTextContents(),SECTIONS.map(s=>s.title)); + assert.deepEqual(await module.locator('h4').allTextContents(),SECTIONS.flatMap(s=>s.examples.map(e=>e.title))); + const tables=module.locator('table'),expected=blocks.filter(b=>b.kind==='table'); + assert.equal(await tables.count(),5); + for(let i=0;i<4;i++){ + assert.deepEqual(await tables.nth(i).locator('thead th').allTextContents(),expected[i].headers); + assert.deepEqual(await tables.nth(i).locator('tbody tr').evaluateAll(rows=>rows.map(r=>[...r.children].map(c=>c.textContent))),expected[i].rows); + } + assert.equal(await page.locator('#tokenomics').count(),0); + for(const width of [320,375,661,1000,1440,1920]) { + await page.setViewportSize({width,height:1000}); + const geometry=await page.evaluate(()=>{ + const module=document.querySelector('.bg-module'),bounds=module.getBoundingClientRect(); + const tabs=[...document.querySelectorAll('[role=tab]')].map(e=>e.getBoundingClientRect()); + return {overflow:document.documentElement.scrollWidth>innerWidth,tabRows:new Set(tabs.map(b=>b.top)).size,escaped:[...module.querySelectorAll('table,article,.bg-formula')].filter(e=>{const b=e.getBoundingClientRect();return b.leftbounds.right+.5||e.scrollWidth>e.clientWidth+1;}).length}; + }); + assert.deepEqual(geometry,{overflow:false,tabRows:1,escaped:0},'BASK layout at '+width); + } + await page.setViewportSize(initial); + await tabs.nth(2).focus();await page.keyboard.press('ArrowRight');await selected(0); + assert.equal(await page.locator('.bg-module').count(),0); + await page.keyboard.press('ArrowLeft');await selected(2); + await page.keyboard.press('ArrowLeft');await selected(1); + assert.equal(await page.locator('.bg-module').count(),0); + await page.keyboard.press('ArrowRight');await selected(2); + await page.keyboard.press('Home');await selected(0); + assert.equal(await page.locator('.bg-module').count(),0); + return 'BASK: exact canonical prose/formulas/five tables; three-tab ARIA, focus, Home/End/arrow wrap; no overflow at 320/375/661/1000/1440/1920; inactive modules unmounted'; +} diff --git a/web/scripts/bask-guide-content.md b/web/scripts/bask-guide-content.md new file mode 100644 index 0000000..e41df8f --- /dev/null +++ b/web/scripts/bask-guide-content.md @@ -0,0 +1,145 @@ +# BASK + +BASK represents a share of the Stock Tokens accounted for by the vault. Deposits create new BASK. Redemptions burn BASK and release a proportional amount of each stock. Its supply grows and shrinks with those actions; there is no fixed supply. + +## What one BASK represents + +The vault's net asset value, or NAV, is the sum of each stock's managed quantity multiplied by its feed price. Dividing that value by the total BASK supply gives the feed-based backing per BASK. The total supply includes fee BASK and the permanently locked first-deposit amount. BASK's trading price can be different. + +**NAV = sum of managed stock quantities × their feed prices** + +**Backing per BASK = NAV ÷ BASK supply** + +Managed quantities are the vault's accounted holdings. Stock already owed to earlier redeemers is kept separate. Direct donations are not counted until a Resync updates the accounting. + +## Minting BASK + +You can deposit one or more eligible Stock Tokens. The vault values each amount using its feed price, then adds those values together. Your deposit receives the same backing per BASK as the existing supply, before the fee. + +**Deposit value = sum of deposited quantities × their feed prices** + +**Gross BASK = deposit value × BASK supply ÷ NAV before the deposit** + +**BASK you receive = gross BASK − deposit fee** + +The deposit fee is 0.5% of gross BASK. Fee BASK is minted to the fee recipient alongside the BASK minted to you. The Stock Tokens stay in the vault. + +### Example 1: the first deposit + +When the supply is zero, the first deposit creates 1 gross BASK per $1 of deposit value. It also locks 0.001 BASK forever. That lock is taken only on the first deposit. Here, you deposit 10 Stock A at a feed price of $100 each. + +| Step | Amount | +| --- | --- | +| Deposit value: 10 Stock A × $100 | $1,000 | +| Gross BASK created | 1,000 BASK | +| Deposit fee: 1,000 × 0.5% | 5 BASK | +| Permanently locked | 0.001 BASK | +| You receive | 994.999 BASK | + +The total supply is 1,000 BASK: your 994.999, the 5 paid as the fee and the 0.001 permanently locked. + +### Example 2: adding to an existing basket + +Suppose the vault has $20,000 of NAV and a total supply of 10,000 BASK. Each BASK has $2 of feed-based backing. You deposit 5 eligible Stock C at a feed price of $200 each, worth $1,000 in total. + +| Step | Amount | +| --- | --- | +| Gross BASK: $1,000 × 10,000 ÷ $20,000 | 500 BASK | +| Deposit fee: 500 × 0.5% | 2.5 BASK | +| You receive | 497.5 BASK | +| NAV after the deposit | $21,000 | +| BASK supply after the deposit | 10,500 BASK | + +At unchanged feed prices, $21,000 ÷ 10,500 is still $2 of backing per BASK. The fee changes how the new BASK is divided between you and the fee recipient. + +## Redeeming BASK + +You submit BASK and receive a share of each stock in the vault. The 0.5% redemption fee is transferred in BASK to the fee recipient. The remaining BASK is burned. + +**BASK burned = BASK submitted − redemption fee** + +**Your fraction = BASK burned ÷ BASK supply before the redemption** + +**Each stock amount = that stock's redeemable quantity × your fraction** + +For a readable balance, the redeemable quantity is the smaller of its managed quantity and the balance available after earlier claims are reserved. If the balance cannot be read, the calculation uses the managed quantity and an unsuccessful payment is queued. Redemption calculates quantities without using price feeds. + +### Example 3: receiving both stocks + +Consider a separate basket with a total supply of 10,000 BASK and the two stocks below. You redeem 1,000 BASK: 5 BASK is the fee, 995 BASK is burned, and your fraction is 995 ÷ 10,000 = 9.95%. + +| Stock | Redeemable quantity | Your fraction | You receive | +| --- | --- | --- | --- | +| Stock A | 100 | 9.95% | 9.95 Stock A | +| Stock B | 60 | 9.95% | 5.97 Stock B | + +For illustration, if Stock A's feed price is $80 and Stock B's is $200, the basket's NAV is $20,000 and these stock amounts have a combined feed value of $1,990. Those prices explain the value; they are not inputs to the redemption calculation. The BASK supply becomes 9,005 because only the 995 BASK is burned; the 5 fee BASK remains in circulation. + +### Example 4: one stock is queued + +Use the same redemption, but suppose Stock B cannot be transferred and the basket is within the direct-payout limit. Stock A can still be paid. The receiver's claim to Stock B is recorded separately. + +| Stock | Paid now | Owed to the receiver | +| --- | --- | --- | +| Stock A | 9.95 Stock A | 0 | +| Stock B | 0 | 5.97 Stock B | + +Only the receiver wallet can claim the queued stock. Choose a receiver you control. Claiming does not burn more BASK or charge another redemption fee; gas still applies. Payment depends on the token becoming transferable and the vault having an available balance. If the basket exceeds the direct-payout limit, all stock amounts are queued for claiming. + +## The arbitrage flywheel + +As the prices of the stocks inside BASK move, BASK’s market price can drift away from the value of the stocks it represents. That gap creates an opportunity for arbitrageurs: + +• BASK trades above its backing value: deposit stocks to mint BASK, then sell that BASK on the market. + +• BASK trades below its backing value: buy BASK on the market, then redeem it for its share of the underlying stocks. + +When the difference covers fees and trading costs, arbitrageurs can profit from closing the gap. Their trades help bring BASK’s market price closer to its backing value. Each mint or redemption pays a 0.5% fee in BASK to ITEM staking. + +**Stock prices move → price gaps open → arbitrageurs mint or redeem → BASK fees flow to ITEM stakers.** + +## How TVL and liquidity support the flywheel + +TVL is the total value of the stocks held by the vault. As TVL and basket diversity grow, so does the potential for arbitrage: a larger basket provides more backing to trade against, while a wider mix of stocks creates more individual price movements that can open up opportunities. + +Liquidity in the BASK/USDG pool helps turn those opportunities into trades. A deeper pool lets arbitrageurs buy and sell larger amounts of BASK with less price impact, making more price gaps practical to capture. + +Together, a larger and more diverse basket and deeper BASK/USDG liquidity can create more arbitrage opportunities and support larger trades. Each opportunity captured through minting or redemption generates BASK fees for ITEM stakers. + +**More backing + more stock diversity + deeper liquidity → more room for arbitrage → more minting and redemption activity → more BASK fees for ITEM stakers.** + +### A simple example: ten stocks in the basket + +The basket holds 10 stocks worth $10,000 each, for $100,000 total. With 100,000 BASK outstanding, each BASK represents $1 of backing. + +Two stocks rise 10%. Those holdings are now worth $11,000 each, while the other eight remain worth $10,000 each. + +The basket is now worth $102,000, or $1.02 per BASK. But BASK is still available on the market for $1. + +An arbitrageur: + +1. Buys 1,000 BASK for $1,000. + +2. Redeems it. The 0.5% fee sends 5 BASK to ITEM staking, and the remaining 995 BASK is burned. + +3. Receives 0.995% of each stock holding: + +| Holding | Received | +| --- | --- | +| Eight unchanged stocks: $99.50 each | $796 | +| Two stocks that rose: $109.45 each | $218.90 | +| Total stock value received | $1,014.90 | + +4. Sells those stocks at the illustrated prices, capturing $14.90 before other trading costs and gas. + +The arbitrageur captures the price difference, buying pressure helps move BASK’s market price toward its backing value, and ITEM stakers receive 5 BASK in fees. + +## Reading a real preview + +These are independent worked examples, with the 0.5% fee enabled and gas excluded. They are not live quotes. The contract charges the fee when a fee recipient is configured; otherwise it is zero. + +BASK has 18 decimals. The contract rounds the BASK fee up to the smallest BASK unit, gross minting down to the smallest BASK unit, and each redemption amount down to that stock's smallest unit. The examples use amounts that divide exactly. + +Deposits must pass the vault's price, balance, availability and size-limit checks. A new deposit is rejected if the vault's NAV falls to zero. Your transaction also carries an expiry and minimum amounts: a deposit reverts if it would mint less BASK than your minimum, and a redemption reverts if a stock allocation falls below its supplied minimum. A queued allocation can still satisfy a redemption minimum; the minimum does not guarantee immediate delivery. + +Use the Deposit or Redeem preview for the amounts calculated from the current vault state. diff --git a/web/scripts/check-art.mjs b/web/scripts/check-art.mjs index d4d5bae..596057d 100644 --- a/web/scripts/check-art.mjs +++ b/web/scripts/check-art.mjs @@ -62,8 +62,7 @@ const V12_DOCS = [ "One token, BASK, backed by a basket of Stock Tokens held in a vault on Robinhood Chain. Deposit one or more listed Stock Tokens at once and receive BASK. Redeeming BASK pays a share of every stock in the vault.", "The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.", "BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.", - "The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM.", - "ITEM is not live yet. Its address will be listed here when it is." + "The 0.5% fee on each deposit and redemption goes to ITEM staking, so more trading through the vault means more fees for ITEM.", ]; const V12_DEPOSIT = [ "You approve each stock for exactly the amount you deposit. Each approval is its own wallet prompt; the deposit prompt comes last.", @@ -617,9 +616,16 @@ try { } assert.equal(await page.locator('.deposit-led-sign').count(),0,'Normal operation and other routes have no sign'); if(name==='deposit') { - const aside=page.locator('.flow-layout > aside.panel'); - assert.equal(await aside.locator('details').count(),0); - assert.deepEqual((await aside.locator(':scope > p').allTextContents()).slice(2).map(t=>t.replace(/\s+/g,' ').trim()),V12_DEPOSIT); + // v14: the Before-you-deposit notes are an aside at 960px and below and sit under the form inside the panel above that. + const notes=page.locator(width>960?'.deposit-main > .deposit-notes':'.flow-layout > aside.panel'); + assert.equal(await notes.count(),1,`${engine} deposit ${width}: one Before-you-deposit container`); + assert.equal(await page.locator(width>960?'.flow-layout > aside.panel':'.deposit-notes').count(),0,`${engine} deposit ${width}: the notes live in one place only`); + assert.equal(await notes.locator('details').count(),0); + assert.deepEqual((await notes.locator(':scope > p').allTextContents()).slice(2).map(t=>t.replace(/\s+/g,' ').trim()),V12_DEPOSIT); + assert.equal(await notes.getByText('Issuer restrictions and pool checks can temporarily halt deposits.',{exact:true}).count(),1,`${engine} deposit ${width}: the approved note`); + assert.equal(await page.getByText(/Feeds lag the market\. Issuer/).count(),0,`${engine} deposit ${width}: the old note is gone`); + assert.equal(await page.getByText('Grey stocks are already in the form above.',{exact:true}).count(),0,`${engine} deposit ${width}: the grey-stocks sentence is gone`); + if(width>960){const two=await page.evaluate(()=>{const m=document.querySelector('.deposit-main'),r=document.querySelector('.deposit-right');if(!m)return null;const a=m.getBoundingClientRect(),b=r?.getBoundingClientRect();return {columns:getComputedStyle(document.querySelector('.deposit-columns')).gridTemplateColumns.split(' ').length,right:b?b.left>=a.right-1&&Math.abs(b.top-a.top)<2:null,open:!!document.querySelector('.deposit-right .all-stocks.open-list'),fold:document.querySelectorAll('details.all-stocks').length};});assert.deepEqual(two,{columns:2,right:two?.right,open:two?.open,fold:0},`${engine} deposit ${width}: two columns`);assert.ok(two.right!==false&&two.open!==false,`${engine} deposit ${width}: list open on the right ${JSON.stringify(two)}`);} } if(name==='redeem') { assert.equal(await page.locator('.flow-layout.one-column').count(),1); diff --git a/web/scripts/check-bask-guide.tsx b/web/scripts/check-bask-guide.tsx new file mode 100644 index 0000000..ffcd4f9 --- /dev/null +++ b/web/scripts/check-bask-guide.tsx @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { BaskGuide } from "../src/BaskGuide"; +import { fee, value, deposit, redeem, allocation, format, UNIT, LOCK, INITIAL, ADDED, REDEEMED, OUTPUTS, FRACTION, OUTPUT_VALUE, INTRO, SECTIONS, FLYWHEEL, FLYWHEEL_NAV_BEFORE, FLYWHEEL_NAV_AFTER, FLYWHEEL_REDEEMED, FLYWHEEL_OUTPUT, FLYWHEEL_UNCHANGED_VALUE, FLYWHEEL_RISEN_VALUE, FLYWHEEL_RECEIVED, FLYWHEEL_GAIN } from "../src/baskGuideData"; + +// Independent expectations audited against web/pinned/src/BaskVault.sol, not computed by the page. +assert.equal(fee(1n), 1n); +assert.equal(fee(200n), 1n); +assert.equal(fee(201n), 2n); +assert.equal(fee(201n, false), 0n); +assert.equal(value(1_234_567n, 100_000_000n, 6, 8), 1_234_567_000_000_000_000n); +assert.equal(value(1_234_567_890_123_456_789n, 12_345_678n, 18, 8), 152_415_776_406_035_777n); +assert.deepEqual(INITIAL, { gross: 1000n * UNIT, fee: 5n * UNIT, locked: 10n ** 15n, received: 994_999n * 10n ** 15n, supplyAfter: 1000n * UNIT, navAfter: 1000n * UNIT }); +assert.equal(deposit(2n * LOCK, 0n, 0n, false).received, LOCK); +assert.throws(() => deposit(LOCK, 0n, 0n, false), /Zero shares/); +assert.equal(deposit(10n, 7n, 3n, false).gross, 23n); +assert.throws(() => deposit(UNIT, UNIT, 0n), /Zero NAV/); +assert.equal(ADDED.gross, 500n * UNIT); +assert.equal(ADDED.received, 4975n * 10n ** 17n); +assert.equal(ADDED.fee, 25n * 10n ** 17n); +assert.equal(ADDED.navAfter, 21000n * UNIT); +assert.equal(ADDED.supplyAfter, 10500n * UNIT); +assert.equal(INITIAL.received + INITIAL.fee + INITIAL.locked, INITIAL.supplyAfter); +assert.deepEqual(REDEEMED, { fee: 5n * UNIT, burned: 995n * UNIT, supplyAfter: 9005n * UNIT }); +assert.equal(REDEEMED.supplyAfter, 10000n * UNIT - 1000n * UNIT + 5n * UNIT, "fee transfers; only net BASK burns"); +assert.deepEqual(redeem(201n, 1000n, false), { fee: 0n, burned: 201n, supplyAfter: 799n }); +assert.deepEqual(OUTPUTS, [995n * 10n ** 16n, 597n * 10n ** 16n]); +assert.equal(FRACTION, "9.95%"); +assert.equal(OUTPUT_VALUE, 1990n * UNIT); +// Raw stock units deliberately differ: zero, six and eighteen token decimals. +assert.equal(allocation(7n, 7n, 0n, 1n, 3n), 2n); +assert.equal(allocation(1_000_001n, 1_000_001n, 0n, 1n, 3n), 333_333n); +assert.equal(allocation(10n ** 18n + 1n, null, 999n, 1n, 3n), 333_333_333_333_333_333n); +assert.equal(allocation(100n, 90n, 30n, 1n, 3n), 20n, "reserved debt cap"); +assert.equal(allocation(100n, 500n, 0n, 1n, 3n), 33n, "donations do not increase managed"); +assert.equal(allocation(100n, 20n, 30n, 1n, 3n), 0n, "deficit floors available at zero"); +assert.equal(allocation(100n, null, 1000n, 1n, 3n), 33n, "unreadable balance falls back to managed"); +// The arbitrage flywheel example (owner, 2026-10-13): the figures in the text come from the audited helpers. +assert.equal(FLYWHEEL_NAV_BEFORE, 100_000n * UNIT, "$100,000 of backing for 100,000 BASK, $1 each"); +assert.equal(FLYWHEEL_NAV_AFTER, 102_000n * UNIT, "two holdings up 10%: $102,000, $1.02 per BASK"); +assert.equal(FLYWHEEL_NAV_AFTER * UNIT / FLYWHEEL.supply, 102n * UNIT / 100n); +assert.deepEqual(FLYWHEEL_REDEEMED, { fee: 5n * UNIT, burned: 995n * UNIT, supplyAfter: 99_005n * UNIT }); +assert.equal(FLYWHEEL_REDEEMED.burned * 100n * UNIT / FLYWHEEL.supply, 995n * UNIT / 1000n, "0.995% of each holding"); +assert.equal(FLYWHEEL_OUTPUT, 995n * UNIT / 1000n, "0.995 of each 100-stock holding"); +assert.equal(FLYWHEEL_UNCHANGED_VALUE, 995n * UNIT / 10n, "$99.50 from an unchanged holding"); +assert.equal(FLYWHEEL_RISEN_VALUE, 10_945n * UNIT / 100n, "$109.45 from a holding that rose"); +assert.equal(FLYWHEEL_UNCHANGED_VALUE * 8n, 796n * UNIT); +assert.equal(FLYWHEEL_RISEN_VALUE * 2n, 2189n * UNIT / 10n); +assert.equal(FLYWHEEL_RECEIVED, 101_490n * UNIT / 100n, "$1,014.90 of stock received"); +assert.equal(FLYWHEEL_GAIN, 1490n * UNIT / 100n, "$14.90 before other trading costs and gas"); +assert.equal(format(333333n, 6), "0.333333"); +assert.equal(format(2n, 0), "2"); + +const file = process.env.BASKET_BASK_CONTENT || new URL("./bask-guide-content.md", import.meta.url); +const content = fs.readFileSync(file, "utf8"); +const actual: string[] = ["BASK", INTRO]; +for (const section of SECTIONS) { + actual.push(section.title); + for (const group of [section, ...section.examples]) { + if (group !== section) actual.push(group.title); + for (const block of group.blocks) { + if (block.kind === "table") actual.push(...block.headers, ...block.rows.flat()); + else actual.push(block.text); + } + } +} +const expected = content.split(/\r?\n/).filter(l => l.trim() && !/^\|[ -]+\|/.test(l)).flatMap(l => l.startsWith("|") ? l.slice(1, -1).split("|").map(s => s.trim()) : [l.replace(/^#+ /, "").replace(/^\*\*|\*\*$/g, "")]); +assert.deepEqual(actual, expected, "every displayed paragraph, formula, table cell and heading equals the canonical draft"); +const markup = renderToStaticMarkup(); +assert.equal((markup.match(//^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, build configuration and validation fixtures unchanged; v13 source scope independently pinned; exact share shell and both share JPEG copies pinned; both favicon copies unchanged','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 26 in v12, 32 in v11, 36 in v9/v10, 40 before); no additional artifact images','Both copies of all 37 manifest assets included']}; +const result={limitBytes:8388608,uncompressedDeliveryBytes:0,fileCount:files.length,productionExportBytes:Object.values(productionFiles).reduce((a,b)=>a+b.bytes,0),productionFiles,accounting:'Complete candidate file contents including tracked lib sources and this report. Excludes Git metadata, removed task inputs and disposable test/scratch. No Git metadata was modified.',checks:['Protected configuration, dependencies, root contracts, web/pinned, build configuration and validation fixtures unchanged; ITEM and BASK source scope independently pinned; exact share shell and both share JPEG copies pinned; both favicon copies unchanged','No git submodules or nested dependency/cache/archive payloads','24 required empty/25-stock JPEGs retained (quality 18 in v14, 26 in v12 and v13, 32 in v11, 36 in v9/v10, 40 before); no additional artifact images','Both copies of all 37 manifest assets included']}; const report=path.join(root,'artifacts/bundle-check.json'); for(let i=0;i<4;i++){fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');result.uncompressedDeliveryBytes=files.reduce((sum,p)=>sum+fs.statSync(path.join(root,p)).size,0);} -fs.writeFileSync(report,JSON.stringify(result,null,2)+'\n');assert.ok(result.uncompressedDeliveryBytes<=result.limitBytes,"Over the 8 MiB budget (if the 24 screenshots are still an older set, run scripts/run-browser.mjs first: it rewrites them at quality 26): "+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 18): "+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 83f4f34..ac1f3aa 100644 --- a/web/scripts/check-calldata.mjs +++ b/web/scripts/check-calldata.mjs @@ -11,18 +11,19 @@ const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8'}); const baseline=p=>git(['show','HEAD:'+p]); const read=p=>fs.readFileSync(path.join(root,p),'utf8'); const sha256=s=>createHash('sha256').update(s).digest('hex'); -// v13 on HEAD=v12: two owner decisions of 2026-10-13. Flows.tsx: the Deposit stock list is shown open across the -// panel while the form is empty (no wallet, or a wallet without a listed stock) and then for that account. -// wallet.tsx: the Claim all result detail loses its "Nothing owed for N other stocks." sentence. Transactions -// and all chain reads remain HEAD's; every other web/src file is byte-identical to HEAD. +// v14 on HEAD=v13: the owner's decisions of 2026-10-13. Flows.tsx (DepositPage only): on desktop the stock list is +// always open in a right-hand column and the "Before you deposit" notes sit under the form; one sentence removed and +// the warning note re-worded. Docs.tsx: the Docs-local Tokenomics and BASK tabs (four new display-only sources, +// pinned by reviewed digests below), the live fee sentence, and five ITEM panels in the default tab. Transactions +// and all chain reads remain HEAD's; wallet.tsx and every other web/src file are byte-identical to HEAD. const protectedFiles=[ - "Docs.tsx", "Losses.tsx", "Owner.tsx", "Scenery.tsx", "Vault.tsx", "components.tsx", "main.tsx", + "wallet.tsx", "chain.ts", "deployment.ts", "governance.ts", @@ -32,34 +33,68 @@ const protectedFiles=[ "vault.abi.json" ]; for(const f of protectedFiles) assert.equal(read('web/src/'+f),baseline('web/src/'+f),f); -// The v13 files: byte-identical to HEAD once their documented pieces [v13, HEAD] are put back (each exactly once), so +// The Docs module (owner approved A and Docs-local tabs): exactly four new display-only sources, each pinned by an +// independently reviewed digest and scanned for wallet, storage, network and send sinks; the ITEM statistics read is +// behind the null-address guard. styles.css starts with HEAD's text followed by the exact approved module CSS; v14's +// Deposit desktop rules are appended after that prefix. +const NEW_SOURCES=['Tokenomics.tsx','tokenomicsData.ts','BaskGuide.tsx','baskGuideData.ts']; +const allSources=fs.readdirSync(path.join(root,'web/src'),{recursive:true}).filter(p=>fs.statSync(path.join(root,'web/src',p)).isFile()).map(p=>p.replaceAll('\\','/')).sort(); +assert.deepEqual(allSources,[...git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').map(p=>p.slice('web/src/'.length)),...NEW_SOURCES].sort(),'exactly four new sources'); +const modulePins={ + "Tokenomics.tsx": "9090a62ee2c36373beb7712fea6489ba6aa5f4852ba110be71beb1c11c27f1f6", + "tokenomicsData.ts": "18c92542419fcde454a8640705ebe2b1579d3e01eaa88cbf5edfb1e7053eccfe", + "BaskGuide.tsx": "9427416f3e67521e3805bf0cc480ef4960d4d5058677327782a7175b61e8d01c", + "baskGuideData.ts": "009ed45d5af35bbb34bc62c89bcc90b46253894d2aca60f793ec3e4ac465b169", +}; +for(const [f,digest] of Object.entries(modulePins))assert.equal(sha256(read('web/src/'+f)),digest,'independent reviewed module pin: '+f); +for(const f of NEW_SOURCES){ + const s=read('web/src/'+f); + assert.ok(!/ethereum|\b(?:fetch|localStorage|sessionStorage|indexedDB|sendTransaction|writeContract|sendRawTransaction|eth_call)\b|\.request\s*\(/.test(s),f+': no wallet, storage, raw network or send sink'); + assert.deepEqual([...s.matchAll(/https?:[^\s\x60"']+/g)].map(m=>m[0]),f==='Tokenomics.tsx'?['https://robin.etherscan.io/address/${address}']:[],'only explorer link pattern'); +} +assert.match(read('web/src/Tokenomics.tsx'),/if \(!hasAddresses\(addresses\)\) return null;[\s\S]*const results = await reader/,'null guard before the sole optional existing many reader'); +assert.ok(read('web/src/styles.css').startsWith(baseline('web/src/styles.css')),'CSS additions only'); +assert.equal(sha256(fs.readFileSync(path.join(root,'web/src/styles.css')).subarray(0,63880)),'556616a829d32cd47a07e81280a030b9adcd49ffa1a665776ca894c63e593ad8','entire approved module CSS is an exact prefix; v14 Deposit rules follow it'); +// The v14 files: byte-identical to HEAD once their documented pieces [v14, 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. -// >>> v13 pieces (generated from the difference to HEAD, not edited by hand) +// >>> v14 pieces (generated from the difference to HEAD, not edited by hand) const editedFiles={ - "Flows.tsx":[ - [" const [showSmall, setShowSmall] = useState(false);\n // v13: the account (\"\" for none) for which the stock list under the search\n // was shown open because the form was empty; it stays open for it.\n const [listOpenFor, setListOpenFor] = useState();\n // The stock an Add button just added, until its row is on screen.\n", - " const [showSmall, setShowSmall] = useState(false);\n // The stock an Add button just added, until its row is on screen.\n"], - [" });\n // v13: the form is empty with no wallet, or with a wallet whose balances\n // are read and hold no listed stock while no row is in the form. Then the\n // stock list is shown open across the panel, and it stays open for that\n // account until the account changes or the page is reloaded.\n const emptyForm =\n !w.account ||\n (ready && !unread && !rows.length && !stocks.some((x) => x.inWallet));\n const accountKey = w.account ?? \"\";\n useEffect(() => {\n if (emptyForm) setListOpenFor(accountKey);\n }, [emptyForm, accountKey]);\n const listOpen = emptyForm || listOpenFor === accountKey;\n const StockList = listOpen ? \"div\" : \"details\";\n // `focus` is false for Enter in the search box, which keeps the focus.\n", - " });\n // `focus` is false for Enter in the search box, which keeps the focus.\n"], - [" \n {/* Every stock the vault takes now. Closed until pressed while\n the form has rows; shown open across the panel while the\n form is empty (no wallet, or a wallet without a listed\n stock), and then for the rest of the visit for that account\n (v13). A press is the same Add as in the search; a stock\n that has a row stays in the list, switched off. */}\n \n {listOpen ? (\n

\n All stocks you can deposit ({open.length})\n

\n ) : (\n \n All stocks you can deposit ({open.length})\n \n )}\n

Press a stock to add it to your deposit.

\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"], - [" \n \n {counted + badAmount.length + noPrice.length > 0 && (\n", - " \n
\n {counted + badAmount.length + noPrice.length > 0 && (\n"] + "Docs.tsx":[ + ["import { useRef, useState, type KeyboardEvent } from \"react\";\nimport { Tokenomics } from \"./Tokenomics\";\nimport { BaskGuide } from \"./BaskGuide\";\nimport { PageTitle, AddressLink } from \"./components\";\n", + "import { PageTitle, AddressLink } from \"./components\";\n"], + ["export function DocsPage() {\n const [tab, setTab] = useState(0);\n const buttons = useRef<(HTMLButtonElement | null)[]>([]);\n function move(event: KeyboardEvent) {\n const next = event.key === \"Home\" ? 0 : event.key === \"End\" ? 2 : event.key === \"ArrowRight\" ? (tab + 1) % 3 : event.key === \"ArrowLeft\" ? (tab + 2) % 3 : undefined;\n if (next === undefined) return;\n event.preventDefault();\n setTab(next);\n buttons.current[next]?.focus();\n }\n return (\n", + "export function DocsPage() {\n return (\n"], + [" \n
\n {[\"Docs\", \"Tokenomics\", \"BASK\"].map((label, index) => (\n \n ))}\n
\n
\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
GaugeTotal ITEMYear 1Year 2Year 3Year 4
BASK/USDG490,000,00043.75%31.25%18.75%6.25%
ITEM/ETH210,000,00043.75%31.25%18.75%6.25%
\n \n

\n Rewards are shared among everyone staked at the time. Fewer stakers means a larger share each; more stakers means a smaller one.\n

\n \n
\n

Earn BASK by staking ITEM

\n

\n The 0.5% fee on each deposit and redemption goes to ITEM staking, so more trading through the vault means more fees for ITEM. Stake ITEM on the Earn page and you receive those fees in BASK, in proportion to your stake. The staking contract can also receive USDG and WETH, so other income can be sent to stakers the same way.\n

\n

\n Fees are not paid out the moment they arrive. Each batch is streamed to stakers evenly over 7 days, second by second, and you earn only for the time you are staked. This stops anyone from staking just before a large fee lands and leaving with it. While one stream is running, new fees wait and start their own 7-day stream when it ends.\n

\n

\n Stake and unstake at any time, with no lock or cooldown. Claim is per token: BASK, USDG and WETH are claimed separately, each paying what you have earned so far. Unclaimed amounts stay owed to you. A reward token that cannot be transferred can never block staking or unstaking; it can only delay its own claim. BASK you claim can be redeemed for the Stock Tokens in the vault or sold in the BASK/USDG pool.\n

\n
\n
\n

Rewards FAQ

\n

Can I claim whenever I want?

\n

Yes. Every claim pays what has accrued to that second.

\n

Is anything locked?

\n

No. Staked pool tokens and staked ITEM can be withdrawn at any time.

\n

What if nobody uses the vault?

\n

\n Then no fees are collected and ITEM stakers earn nothing for that time. Liquidity rewards keep running as long as someone is staked. Nothing is lost either way.\n

\n

Do rewards expire?

\n

No. Unclaimed rewards wait for you.

\n

Can the team change the rewards?

\n

No. The allocations, schedules and the 7-day stream are fixed in the contracts.

\n
\n
\n

ITEM risks

\n

\n Rewards depend on use. Liquidity rewards fall every day by design, and fee rewards are zero when nobody deposits or redeems. Providing liquidity carries the usual pool risk: if the two tokens move apart in price, you can end up with less value than holding them. ITEM's price can fall. The contracts cannot be changed or paused, which also means a defect could not be fixed.\n

\n
\n
\n", + "
\n
\n"], + [" \n \n \n \n \n", + " \n \n"] ], - "wallet.tsx":[ - [" });\n detail =\n", - " });\n const zeros = values.filter((v) => v.ok && v.value === 0n).length;\n detail =\n"], - [" (fresh.complete\n ? remaining.join(\"; \") || \"Nothing owed.\"\n : \"Stock list unreadable — Retry vault.\");\n", - " (fresh.complete\n ? (remaining.join(\"; \") || \"Nothing owed.\") +\n (zeros ? ` Nothing owed for ${zeros} other stocks.` : \"\")\n : \"Stock list unreadable — Retry vault.\");\n"] + "Flows.tsx":[ + [" const [listOpenFor, setListOpenFor] = useState();\n // v14: wider than 960px the panel has two columns (the form left, the stock\n // list always open on the right); phones and tablets keep the one column.\n const [desktop, setDesktop] = useState(\n () => window.matchMedia(\"(min-width: 961px)\").matches,\n );\n useEffect(() => {\n const query = window.matchMedia(\"(min-width: 961px)\");\n const update = () => setDesktop(query.matches);\n query.addEventListener(\"change\", update);\n return () => query.removeEventListener(\"change\", update);\n }, []);\n // The stock an Add button just added, until its row is on screen.\n", + " const [listOpenFor, setListOpenFor] = useState();\n // The stock an Add button just added, until its row is on screen.\n"], + [" }, [emptyForm, accountKey]);\n const listOpen = desktop || emptyForm || listOpenFor === accountKey;\n const StockList = listOpen ? \"div\" : \"details\";\n", + " }, [emptyForm, accountKey]);\n const listOpen = emptyForm || listOpenFor === accountKey;\n const StockList = listOpen ? \"div\" : \"details\";\n"], + [" }\n // Every stock the vault takes now. On phones: inside the form, closed until\n // pressed while the form has rows, open across the panel while the form is\n // empty (no wallet, or a wallet without a listed stock) and then for the\n // rest of the visit for that account (v13). On desktop: always open, in the\n // right-hand column (v14). A press is the same Add as in the search; a stock\n // that has a row stays in the list, switched off.\n const stockList = (\n \n {listOpen ? (\n

All stocks you can deposit ({open.length})

\n ) : (\n All stocks you can deposit ({open.length})\n )}\n

Press a stock to add it to your deposit.

\n
    \n {listed.map((a) => (\n
  • 5 ? \"wide\" : undefined}>\n x.a.token === a.token)\n }\n onClick={() => add(a.token)}\n >\n {a.symbol}\n \n
  • \n ))}\n
\n
\n );\n // \"Before you deposit\": its own panel under the form on phones, under the\n // form inside the panel on desktop (v14).\n const notes = (\n <>\n

Before you deposit

\n

Size limit: {usd(s.globals.NAV_CAP)}.

\n

\n First deposit: 0.001 BASK is locked forever. The preview already\n subtracts this lock and any fee.\n

\n

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

\n

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

\n \n Issuer restrictions and pool checks can temporarily halt deposits.\n \n \n );\n return (\n", + " }\n return (\n"], + ["

Make a deposit

\n {/* v14: two columns on desktop (the form, its preview and buttons and\n the notes left; the stock list right), one column otherwise. */}\n
\n
\n {!open.length ? (\n", + "

Make a deposit

\n {!open.length ? (\n"], + ["
\n {/* The stock list (above): in the form on phones, in the\n right-hand column on desktop. */}\n {!desktop && stockList}\n {counted + badAmount.length + noPrice.length > 0 && (\n", + "
\n {/* Every stock the vault takes now. Closed until pressed while\n the form has rows; shown open across the panel while the\n form is empty (no wallet, or a wallet without a listed\n stock), and then for the rest of the visit for that account\n (v13). A press is the same Add as in the search; a stock\n that has a row stays in the list, switched off. */}\n \n {listOpen ? (\n

\n All stocks you can deposit ({open.length})\n

\n ) : (\n \n All stocks you can deposit ({open.length})\n \n )}\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 {desktop &&
{notes}
}\n \n {desktop && open.length > 0 && (\n
{stockList}
\n )}\n \n
\n {!desktop && }\n \n", + "

\n \n \n \n"] ] }; -// <<< v13 pieces +// <<< v14 pieces assert.deepEqual(Object.keys(editedFiles),[ - "Flows.tsx", - "wallet.tsx" -],'the v13 files'); + "Docs.tsx", + "Flows.tsx" +],'the v14 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. @@ -113,13 +148,13 @@ function difference(a,b){ } // Guarded calls: the exact, documented differences from HEAD, each added call with the place it must sit in // (everything equals HEAD, in order and place). The existing receipt read and display-only amount parser are -// already part of HEAD. v13 adds no guarded call, wallet request or chain read. +// already part of HEAD. v14 adds no guarded call, wallet request or chain read. const expected={ - "Flows.tsx": { + "Docs.tsx": { "removed": [], "added": [] }, - "wallet.tsx": { + "Flows.tsx": { "removed": [], "added": [] } @@ -128,11 +163,38 @@ const expected={ // equals HEAD in order, text for text. None of them builds or starts a transaction (no onSubmit, getSpec, build or // claim) and none contains a guarded call (both asserted below). The list between the marker lines was generated // from the difference to HEAD, as the pieces were; each why was written by hand, and an entry without one fails. -// >>> v13 attributes (generated from the difference to HEAD; each why written by hand) +// >>> v14 attributes (generated from the difference to HEAD; each why written by hand) const attributeChanges={ - + "Docs.tsx":{ + removed:[], + added:[ + ["div","role=\"tablist\"", + "Docs module (owner approved Docs-local tabs): the tab row is marked as a tablist for assistive technology. Presentation only; no action, read or request."], + ["button","role=\"tab\"", + "Docs module: each of the three local tab buttons (Docs, Tokenomics, BASK) is marked as a tab for assistive technology. Presentation only."], + ["button","onClick={() => setTab(index)}", + "Docs module: pressing a tab button sets the local tab state (which of the three Docs panels is shown). Display state only; no read, send, storage or network use."], + ["button","onKeyDown={move}", + "Docs module: arrow, Home and End keys move between the three tab buttons (focus and the local tab state). Display only; no read, send, storage or network use."], + ["div","role=\"tabpanel\"", + "Docs module: each of the three panels (the default Docs grid, Tokenomics, BASK) is marked as a tabpanel linked to its tab. Presentation only."], + ["div","role=\"tabpanel\"", + "Docs module: each of the three panels (the default Docs grid, Tokenomics, BASK) is marked as a tabpanel linked to its tab. Presentation only."], + ["div","role=\"tabpanel\"", + "Docs module: each of the three panels (the default Docs grid, Tokenomics, BASK) is marked as a tabpanel linked to its tab. Presentation only."]]}, + "Flows.tsx":{ + removed:[ + ["button","disabled={working || w.busy || rows.some((x) => x.a.token === a.token)}", + "v14: the stock list's buttons moved unchanged into a JSX constant rendered either inside the form (phones) or in the right-hand column (desktop); the disabled rule is HEAD's text, character for character."], + ["button","onClick={() => add(a.token)}", + "v14: the stock list's buttons moved unchanged into a JSX constant rendered either inside the form (phones) or in the right-hand column (desktop); the press is HEAD's add(), character for character, which only adds a row to the form."]], + added:[ + ["button","disabled={working || w.busy || rows.some((x) => x.a.token === a.token)}", + "v14: the stock list's buttons moved unchanged into a JSX constant rendered either inside the form (phones) or in the right-hand column (desktop); the disabled rule is HEAD's text, character for character."], + ["button","onClick={() => add(a.token)}", + "v14: the stock list's buttons moved unchanged into a JSX constant rendered either inside the form (phones) or in the right-hand column (desktop); the press is HEAD's add(), character for character, which only adds a row to the form."]]} }; -// <<< v13 attributes +// <<< v14 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. @@ -144,19 +206,22 @@ const attributeChanges={ // read is added; the existing receipt read and answer handling are independently byte-pinned. // The guarded startup eth_accounts answer is part of HEAD and must remain exact. const restoreAccountReply=(x)=>x.text; -// Reviewed v13 differences in calls and watched-name uses. Independent of the generated pieces. +// Reviewed v14 differences in calls and watched-name uses. Independent of the generated pieces. const callChanges={ - "Flows.tsx": { - "why": "v13 (owner decision 2026-10-13): while the Deposit form is empty (no wallet, or a wallet whose read balances hold no listed stock and no row) the stock list under the search is shown open across the panel, in a div with a plain heading line instead of the details fold, and it stays open for that account. One new local state (the account the list opened for), one display-only effect that sets it, and the display-only emptiness test; no call of HEAD is removed, no read, send, storage or network use is added.", + "Docs.tsx": { + "why": "Docs module (owner approved A and Docs-local tabs) and the five ITEM panels: a local tab state and its keyboard handler, the two display-only tab components, the live fee sentence and the removed not-live sentence, five new section panels of text. No read, send, storage or network use is added; the module files are pinned by reviewed digests above.", "pieces": [], "removed": [], "uses": [] }, - "wallet.tsx": { - "why": "v13 (owner decision 2026-10-13): the Claim all result detail no longer counts the stocks that are not owed; the unused zeros count (a filter over the already-read owed values) is removed. The send head, every state write and every request stay HEAD's.", + "Flows.tsx": { + "why": "v14 (owner decision 2026-10-13): on desktop (wider than 960px, read once through matchMedia and kept in one display-only state) the Deposit panel is two columns: the form, its preview and transaction buttons and the Before-you-deposit notes on the left, the stock list always open on the right; on phones the v13 layout and behaviour are unchanged. The sentence about grey stocks is removed and the warning note re-worded. No call of HEAD is removed beyond the documented moves; no read, send, storage or network use is added.", "pieces": [], "removed": [ - "values.filter((v) => v.ok && v.value === 0n)" + "listed.map((a) => (
  • 5 ? \"wide\" : undefined}>
  • ))", + "rows.some((x) => x.a.token === a.token)", + "add(a.token)", + "usd(s.globals.NAV_CAP)" ], "uses": [] } @@ -209,9 +274,9 @@ function localDeclarations(file,text,fn,list){ return out; } const sources=git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)); -assert.deepEqual(git(['ls-files','--cached','--others','--exclude-standard','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)).sort(),[...sources].sort(),'No source file added or removed'); -// Every source is either frozen (byte-identical to HEAD) or a v13 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 v13 file'); +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,...NEW_SOURCES].sort(),'No source file added or removed beyond the four module files'); +// Every source is either frozen (byte-identical to HEAD) or a v14 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 v14 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); @@ -235,20 +300,20 @@ if(process.env.BASKET_GATE_FACTS)fs.writeFileSync(process.env.BASKET_GATE_FACTS, // includes these pins. Changing a callback, receiver, argument, location, order or count changes the digest. // >>> reviewed calls (manual; never refreshed by put-pieces) const addedCallPins={ - "Flows.tsx": { - "sha256": "684589218caafbf91bde61f7f37ff05fb113369917cae3028fae03a1cdc5a4bd", - "callees": "useState some useEffect setListOpenFor" + "Docs.tsx": { + "sha256": "07495e37aa44c937913a52d41cb024b6f39ca041e9e3889f9bf99fb1bec5f184", + "callees": "useState useRef preventDefault setTab focus map setTab" }, - "wallet.tsx": { - "sha256": "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945", - "callees": "" + "Flows.tsx": { + "sha256": "9420ba314dc7417826b92076310403045ce72c76fd871e45203c121a6da4ff78", + "callees": "useState matchMedia useEffect matchMedia setDesktop addEventListener removeEventListener map some add usd" } }; // <<< reviewed calls -// v13: exact reviewed source of the two files, beyond either generated block. +// v14: exact reviewed source of the two edited files, beyond either generated block (the four module files are pinned above). const reviewedSourcePins={ - "Flows.tsx": "8ec2d9af627b26797c2971eb12154262001c0e1e5aab7b8b723e9640e41fed6a", - "wallet.tsx": "564ce2be2ec1c9e79f55bfd13b26c87e0b0525915e131d32d4d983413e8b8bcf" + "Docs.tsx": "6c9cc7e308109e50b7b9a6e5c861fc4d9feec927e7d568f4c238e9a807930e77", + "Flows.tsx": "b9452b067fabd93c4ce9718d19e4c70906263068bcb81589b2e151beb411c309" }; 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]); @@ -419,7 +484,7 @@ function loadsOf(text,file){ })(tree); return out; } -// v13 adds nothing a page loads or opens: every file equals HEAD here. +// v14 adds nothing a page loads or opens in the HEAD sources: every file equals HEAD here (the module files are scanned above). const loadDisplayAdded={}; const loadUses={}; for(const f of sources){ @@ -489,14 +554,11 @@ function sendParts(text){ } const headSend=sendParts(baseline('web/src/wallet.tsx')),nowSend=sendParts(read('web/src/wallet.tsx')); assert.equal(nowSend.head,headSend.head,'wallet.tsx send(): byte-identical head through receipt wait'); -// v13: the Claim all result detail loses the "Nothing owed for N other stocks." sentence and its unused count. -// This exact replacement is independent of both regeneration blocks and must occur exactly once; the mined-hash -// statements of v12 are part of HEAD now and are pinned by sendTailWrites below. +// v14 does not touch wallet.tsx: send() is byte-identical to HEAD, head and tail (no piece); the mined-hash +// statements are pinned by sendTailWrites below as well. const SEND_TAIL_PIECES=[ - [' detail =\n `Still owed to ${creditor}: ` +\n (fresh.complete\n ? remaining.join("; ") || "Nothing owed."\n : "Stock list unreadable — Retry vault.");\n', - ' const zeros = values.filter((v) => v.ok && v.value === 0n).length;\n detail =\n `Still owed to ${creditor}: ` +\n (fresh.complete\n ? (remaining.join("; ") || "Nothing owed.") +\n (zeros ? ` Nothing owed for ${zeros} other stocks.` : "")\n : "Stock list unreadable — Retry vault.");\n'], ]; -assert.equal(putBack(nowSend.tail,SEND_TAIL_PIECES,'wallet.tsx send() tail'),headSend.tail,'wallet.tsx send(): only the exact v13 detail replacement in the tail'); +assert.equal(putBack(nowSend.tail,SEND_TAIL_PIECES,'wallet.tsx send() tail'),headSend.tail,'wallet.tsx send(): tail byte-identical to HEAD'); pinnedFunctions['wallet.tsx send()']={headEqualToHead:true,headBytes:Buffer.byteLength(nowSend.head,'utf8'),headSha256:sha256(nowSend.head),tailPieces:SEND_TAIL_PIECES.map(([now,head])=>({now,head}))}; // An explicit ordered pin of the changed transaction-status statements, independent of the piece list. const sendTailWrites=[]; @@ -591,7 +653,7 @@ for(const name of ['short','approvals'])rawProtected('Deposit '+name,'Flows.tsx' // 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 v13 adds at the top of a file are listed here (none); a name v13 adds at the top of a component or hook +// HEAD. The names v14 adds at the top of a file are listed here (the Docs module imports); a name v14 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); @@ -653,18 +715,35 @@ for(const f of Object.keys(editedFiles)){ transactionCode[f]={pieces:n.count,namesDeclaredAroundThem:n.names}; } const namesAdded={ - "Flows.tsx": {}, - "wallet.tsx": {} + "Docs.tsx": { + "BaskGuide": "import BaskGuide from \"./BaskGuide\"", + "KeyboardEvent": "import type KeyboardEvent from \"react\"", + "Tokenomics": "import Tokenomics from \"./Tokenomics\"", + "useRef": "import useRef from \"react\"", + "useState": "import useState from \"react\"" + }, + "Flows.tsx": {} }; -// v13 changes no declaration at the top of either file. +// v14 changes no declaration at the top of either file. const moduleChanges={ - "Flows.tsx": [], - "wallet.tsx": [] + "Docs.tsx": [], + "Flows.tsx": [] }; -// v13 changes no declaration at the top of a component or hook; never generated by put-pieces. +// v14 changes one declaration at the top of DepositPage: the list is also open on desktop; never generated by put-pieces. const scopeChanges={ - "Flows.tsx": [], - "wallet.tsx": [] + "Docs.tsx": [], + "Flows.tsx": [ + { + "function": "DepositPage", + "name": "listOpen", + "head": [ + "listOpen = emptyForm || listOpenFor === accountKey" + ], + "now": [ + "listOpen = desktop || emptyForm || listOpenFor === accountKey" + ] + } + ] }; const newNames={}; for(const f of Object.keys(editedFiles)){ @@ -689,7 +768,7 @@ for(const f of Object.keys(editedFiles)){ } } // The whole-source digest is additional to the specific structural assertions above. -for(const [f,digest] of Object.entries(reviewedSourcePins))assert.equal(sha256(read('web/src/'+f)),digest,f+': reviewed v13 source differs; regeneration does not authorize source edits'); +for(const [f,digest] of Object.entries(reviewedSourcePins))assert.equal(sha256(read('web/src/'+f)),digest,f+': reviewed v14 source differs; regeneration does not authorize source edits'); // Differential encoding: HEAD and current governance/model/chain bundled side by side. For the same inputs the // calldata must be byte-identical whenever the current page accepts; v12 adds no refusal, never accepts what HEAD // refused, and refuses with HEAD's words (model.ts, chain.ts and governance.ts are byte-identical to HEAD in v12). diff --git a/web/scripts/check-tokenomics-live.mjs b/web/scripts/check-tokenomics-live.mjs new file mode 100644 index 0000000..ef6c095 --- /dev/null +++ b/web/scripts/check-tokenomics-live.mjs @@ -0,0 +1,21 @@ +import fs from 'node:fs';import path from 'node:path';import http from 'node:http';import assert from 'node:assert/strict';import {build} from 'esbuild'; +const {chromium}=await import(process.env.PLAYWRIGHT_MODULE); +const scratch=path.resolve('../test/scratch/tokenomics-live');fs.mkdirSync(scratch,{recursive:true}); +const source=path.resolve('src/Tokenomics.tsx').replaceAll('\\','/'); +const data=path.resolve('src/tokenomicsData.ts').replaceAll('\\','/'); +const entry=`import React from 'react';import {createRoot} from 'react-dom/client';import {LiveFigures} from ${JSON.stringify(source)};import {ADDRESSES} from ${JSON.stringify(data)}; +const mode=new URLSearchParams(location.search).get('mode'); +const addresses=Object.fromEntries(Object.keys(ADDRESSES).map((key,i)=>[key,mode===key?null:'0x'+String(i+1).padStart(40,'0')])); +window.calls=0;const reader=async specs=>{window.calls++;window.functions=specs.map(c=>c.functionName);if(mode==='retry'&&window.calls===1)return [{ok:false,error:'mock unavailable'}];return Array(10).fill({ok:true,value:1234567890123456789012345n});}; +createRoot(document.getElementById('root')).render();`; +await build({stdin:{contents:entry,loader:'tsx',resolveDir:path.resolve('.')},bundle:true,platform:'browser',format:'esm',outfile:path.join(scratch,'fixture.js'),logLevel:'error'}); +const server=http.createServer((req,res)=>{res.setHeader('Content-Type',req.url.startsWith('/fixture.js')?'text/javascript':'text/html');res.end(req.url.startsWith('/fixture.js')?fs.readFileSync(path.join(scratch,'fixture.js')):'
    ');});await new Promise(r=>server.listen(0,'127.0.0.1',r)); +const browser=await chromium.launch(),page=await browser.newPage();let external=0;await page.route('**/*',r=>{if(r.request().url().startsWith('http://127.0.0.1:'))return r.continue();external++;return r.abort();}); +try{ + const base='http://127.0.0.1:'+server.address().port; + for(const mode of ['ITEM','ITEM_STAKING','BASK_USDG_GAUGE','ITEM_ETH_GAUGE','DEV_VESTING']){await page.goto(base+'/?mode='+mode);await page.waitForFunction(()=>window.calls!==undefined);assert.equal(await page.locator('#root').innerHTML(),'');assert.equal(await page.evaluate(()=>window.calls),0);} + await page.goto(base+'/?mode=success');await page.getByText('ITEM supply',{exact:true}).waitFor();assert.equal(await page.locator('dd').count(),10);assert.equal(await page.locator('dd').first().textContent(),'1,234,567');assert.equal(await page.locator('dd').nth(3).textContent(),'1,234,567.890123');assert.equal(await page.locator('a').count(),5);assert.equal(await page.evaluate(()=>window.calls),1); + assert.deepEqual(await page.evaluate(()=>window.functions),['totalSupply','released','remaining','totalStaked','released','remaining','totalStaked','vested','released','totalStaked']); + await page.goto(base+'/?mode=retry');await page.getByText('Live figures unreadable.',{exact:true}).waitFor();assert.equal(await page.locator('dd').count(),0);assert.equal(await page.locator('a').count(),0);await page.getByRole('button',{name:'Retry',exact:true}).click();await page.getByText('ITEM supply',{exact:true}).waitFor();assert.equal(await page.evaluate(()=>window.calls),2);assert.equal(await page.getByText('Live figures unreadable.',{exact:true}).count(),0);assert.equal(await page.locator('dd').count(),10);assert.equal(external,0); + console.log('PASS tokenomics live component: five null mounts absent/zero reads, mocked success/explorer rows, failure-only notice, Retry succeeds, zero external requests'); +}finally{await browser.close();await new Promise(r=>server.close(r));fs.rmSync(scratch,{recursive:true,force:true});} diff --git a/web/scripts/check-tokenomics.tsx b/web/scripts/check-tokenomics.tsx new file mode 100644 index 0000000..d7b17b9 --- /dev/null +++ b/web/scripts/check-tokenomics.tsx @@ -0,0 +1,43 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { ADDRESSES, ALLOCATIONS, TOTAL_SUPPLY, T, CLIFF, VESTING_END, RELEASES, gaugeReleased, developmentVested, PARAGRAPHS } from '../src/tokenomicsData'; +import { Tokenomics, LiveFigures, readLiveFigures, formatLive } from '../src/Tokenomics'; +const brief = process.env.BASKET_TOKEN_BRIEF ? fs.readFileSync(process.env.BASKET_TOKEN_BRIEF,'utf8') : `ITEM supply after launch: + 10% swarm (IMD's share) automatic + 10% IMD launch pool, ITEM/ETH on Uniswap v4 automatic + 49% 490,000,000 ITEM BaskUsdgGauge (stake BASK/USDG v2 LP) approve + start() + 21% 210,000,000 ITEM ItemEthGauge (stake ITEM/ETH v2 LP) approve + start() + 4% 40,000,000 ITEM DevVesting (25% at month 12, linear to 48) approve + fund() + 1% 10,000,000 ITEM dev wallet, unlocked plain transfer + 5% 50,000,000 ITEM operations, including owner pool seeding and ITEM staking +GAUGES: stake a v2 LP, earn ITEM; one abstract base, two contracts. BaskUsdgGauge: tokenA BASK, tokenB USDG, ALLOCATION 490,000,000e18. ItemEthGauge: tokenA ITEM, tokenB WETH, ALLOCATION 210,000,000e18. +- released(t) = ALLOCATION * t * (2T - t) / T^2, T = 4 * 365 days, t = seconds since start with totalStaked > 0, capped at T; paid pro rata to staked LP. +DEVVESTING: ALLOCATION 40,000,000e18. fund(): once, pulls exactly ALLOCATION of ITEM from the caller; not revocable. elapsed = seconds since deployment; vested = 0 before 365 days, then ALLOCATION * min(elapsed, 1460 days) / 1460 days. release(), by anyone after fund(), sends vested - released to the beneficiary.`; +const header=brief.split('ITEM supply after launch:')[1].split('GAUGES:')[0]; +const table=[...header.matchAll(/^\s+(\d+)%\s+(?:(\d[\d,]*) ITEM\s+)?([^\n]+)/gm)].slice(0,7).map(m=>({share:Number(m[1]),amount:m[2]?Number(m[2].replaceAll(',','')):TOTAL_SUPPLY*Number(m[1])/100,who:m[3]})); +assert.equal(table.length,7,'parse all seven brief header allocations'); +for(const [index,label] of [[0,'BaskUsdgGauge'],[1,'ItemEthGauge'],[2,'IMD launch pool'],[3,'swarm'],[4,'operations'],[5,'DevVesting'],[6,'dev wallet']] as const){const row=table.find(r=>r.who.includes(label));assert(row);assert.deepEqual([ALLOCATIONS[index].share,ALLOCATIONS[index].amount],[row.share,row.amount]);} +const gaugeLine=brief.split('\n').find(l=>l.startsWith('GAUGES:'))!; +assert.deepEqual([...gaugeLine.matchAll(/ALLOCATION ([\d,]+)e18/g)].map(m=>Number(m[1].replaceAll(',',''))),ALLOCATIONS.slice(0,2).map(r=>r.amount)); +const devLine=brief.split('\n').find(l=>l.startsWith('DEVVESTING:'))!; +assert.equal(Number(devLine.match(/ALLOCATION ([\d,]+)e18/)![1].replaceAll(',','')),ALLOCATIONS[5].amount); +assert.match(brief,/T = 4 \* 365 days/);assert.match(devLine,/before 365 days/);assert.match(devLine,/1460 days/); +assert.equal(T,126144000);assert.equal(CLIFF,365*86400);assert.equal(VESTING_END,1460*86400); +assert.equal(ALLOCATIONS.reduce((n,r)=>n+r.amount,0),TOTAL_SUPPLY);assert.equal(TOTAL_SUPPLY,1000000000);assert.equal(ALLOCATIONS.reduce((n,r)=>n+r.share,0),100); +assert.deepEqual([1,2,3,4].map(y=>(gaugeReleased(y*T/4,100)-gaugeReleased((y-1)*T/4,100))),[43.75,31.25,18.75,6.25]); +assert.deepEqual(RELEASES.map(r=>[r.gauges,r.vested,r.outside,r.share]),[[306250000,10000000,576250000,57.6],[525000000,20000000,805000000,80.5],[656250000,30000000,946250000,94.6],[700000000,40000000,1000000000,100]]); +assert.equal(developmentVested(CLIFF-1),0);assert.equal(developmentVested(CLIFF),10000000);assert.equal(developmentVested(VESTING_END+1),40000000);assert.equal(gaugeReleased(T+1),700000000); +const configured=Object.fromEntries(Object.keys(ADDRESSES).map((k,i)=>[k,('0x'+String(i+1).padStart(40,'0'))])) as typeof ADDRESSES; +let calls=0;let contractNames:string[]=[]; +const reader=async (specs: {functionName:string;address:string}[])=>{calls++;contractNames=specs.map(c=>c.functionName);return Array(10).fill({ok:true,value:1234567890123456789n});}; +assert.deepEqual(Object.values(ADDRESSES),[null,null,null,null,null]); +for(const key of Object.keys(ADDRESSES)){const addresses={...configured,[key]:null};assert.equal(await readLiveFigures(addresses,reader),null);assert.equal(renderToStaticMarkup(),'');} +assert.equal(calls,0,'every single-null configuration has zero calls and no block'); +assert(!renderToStaticMarkup().includes('Live figures')); +assert.deepEqual(await readLiveFigures(configured,reader),Array(10).fill(1234567890123456789n));assert.equal(calls,1);assert.deepEqual(contractNames,['totalSupply','released','remaining','totalStaked','released','remaining','totalStaked','vested','released','totalStaked']); +await assert.rejects(readLiveFigures(configured,async()=>{throw Error('fixture unavailable');}),/fixture unavailable/); +await assert.rejects(readLiveFigures(configured,async()=>[{ok:false,error:'fixture unavailable'}]),/Live figures unreadable/); +assert.equal(formatLive(1234567890123456789012345n),'1,234,567');assert.equal(formatLive(1234567890123456789n,true),'1.234567');assert.equal(formatLive(1000000000000000001n,true),'1'); +assert.equal(PARAGRAPHS.length,6); +console.log('PASS tokenomics: brief parsing, supply, schedule, cliff, four annual rows, every-null guard, absent live block, configured multicall, rejected read, formatting'); diff --git a/web/scripts/check-visibility.mjs b/web/scripts/check-visibility.mjs index e1f329b..1af8e8e 100644 --- a/web/scripts/check-visibility.mjs +++ b/web/scripts/check-visibility.mjs @@ -177,7 +177,7 @@ function layoutSnapshot() { strip:{...box('header .marquee'),animations:m.getAnimations({subtree:true}).length,still:halves.length===2&&Math.abs(halves[0].left-w.left)<0.01&&Math.abs(halves[1].left-halves[0].right)<0.01&&halves[1].right>=w.right-0.01}, titleParagraphs:[...document.querySelectorAll('.page-title > p')].map(e=>e.textContent.replace(/\s+/g,' ').trim()), heading:document.querySelector('.page-title > h1')?.textContent, - docs:docs.map(box),docsParagraphs:paragraphs.map(e=>({text:e.textContent.replace(/\s+/g,' ').trim(),...box(e)})), + docs:docs.map(box),docsTitles:docs.map(p=>p.querySelector('h2')?.textContent??null),docsParagraphs:paragraphs.map(e=>({text:e.textContent.replace(/\s+/g,' ').trim(),...box(e)})), docsAddedHeight:paragraphs.length===5?box(paragraphs[4]).bottom-box(paragraphs[0]).bottom:0, redeemAside:document.querySelector('.flow-layout > aside')?.querySelector('h2')?.textContent??null, oneColumn:document.querySelector('.flow-layout')?.classList.contains('one-column')??false, @@ -196,7 +196,9 @@ function layoutSnapshot() { title:document.querySelector('.all-stocks .list-title, .all-stocks summary')?.textContent.replace(/\s+/g,' ').trim()??null, ...box('.all-stocks'), width:(()=>{const ul=document.querySelector('.all-stocks ul'),panel=ul?.closest('section.panel');if(!ul||!panel)return null;const cs=getComputedStyle(panel),inner=panel.clientWidth-parseFloat(cs.paddingLeft)-parseFloat(cs.paddingRight);return ul.getBoundingClientRect().width/inner;})(), - buttons:document.querySelectorAll('.all-stocks ul button').length}, + buttons:document.querySelectorAll('.all-stocks ul button').length, + sentences:[...document.querySelectorAll('.all-stocks p')].map(e=>e.textContent.replace(/\s+/g,' ').trim()), + grey:(()=>{const p=[...document.querySelectorAll('.all-stocks p')].find(e=>e.textContent.trim()==='Grey stocks are already in the form above.');if(!p)return null;const cs=getComputedStyle(p);return {height:p.getBoundingClientRect().height,marginTop:parseFloat(cs.marginTop),marginBottom:parseFloat(cs.marginBottom)};})()}, depositSummary:box('.deposit-summary'), led:ledGeometry(), sideways:document.documentElement.scrollWidth>innerWidth, @@ -301,7 +303,7 @@ try { if(oldLayout.flowSteps.bottom<=812)assert.ok(newLayout.flowSteps.bottom<=812,'Previously visible Deposit task instructions remain on the first screen'); // v13: both variants are empty forms (no wallet; the synthetic account holds nothing), so the list is open // across the panel with the fold's words as a plain heading line in the fold line's place; the baseline shows the closed fold. - assert.deepEqual([oldLayout.stockList.open,oldLayout.stockList.summary,newLayout.stockList.open,newLayout.stockList.summary],[false,true,true,false],'v13: the open list replaces the closed fold in the empty states'); + assert.deepEqual([oldLayout.stockList.open,oldLayout.stockList.summary,newLayout.stockList.open,newLayout.stockList.summary],[true,false,true,false],'v13: the open list replaces the closed fold in the empty states'); assert.equal(newLayout.stockList.title,oldLayout.stockList.title,'The list heading keeps the fold line\'s words'); assert.match(newLayout.stockList.title,/^All stocks you can deposit \(\d+\)$/); assert.ok(near(newLayout.stockList.top,oldLayout.stockList.top),'The list starts where the fold line started'); @@ -312,12 +314,24 @@ try { checks.push(statusCase==='normal'?'Owner follow-up: no normal status area/sign':'Owner follow-up: exact single '+statusCase+' sentence, compact Vault Closed notice, Deposit-only CLOSED sign'); if(name==='docs') { assert.equal(oldLayout.docsParagraphs.length,5); - assert.equal(newLayout.docsParagraphs.length,5); + assert.equal(newLayout.docsParagraphs.length,4); assert.deepEqual(oldLayout.docsParagraphs.slice(1).map(p=>p.text),docsAdded,'Baseline carries the four v12 paragraphs'); - assert.deepEqual(newLayout.docsParagraphs.map(p=>p.text),oldLayout.docsParagraphs.map(p=>p.text),'Docs paragraphs equal the baseline word for word'); - assert.equal(newLayout.docs.length,oldLayout.docs.length); - for(let i=0;ip.text),oldLayout.docsParagraphs.slice(0,4).map(p=>p.text.replace('is set to go to ITEM staking once ITEM is live','goes to ITEM staking')),'Only approved live-oriented Docs edits'); + // v14: five ITEM panels are inserted after "Risks" and before "Find Basket"; every retained panel is matched by its + // title, keeps its height, and moves down only by the tab row, the copy change and (Find Basket) the inserted panels. + const ITEM_PANELS=['ITEM','Earn ITEM by providing liquidity','Earn BASK by staking ITEM','Rewards FAQ','ITEM risks']; + assert.deepEqual(oldLayout.docsTitles.slice(-2),['Risks','Find Basket'],'Baseline ends with Risks and Find Basket'); + assert.deepEqual(newLayout.docsTitles,[...oldLayout.docsTitles.slice(0,-1),...ITEM_PANELS,'Find Basket'],'v14: the five ITEM panels sit between Risks and Find Basket'); + const tabShift=newLayout.docs[0].top-oldLayout.docs[0].top; + assert.ok(tabShift>0&&tabShift<100,'Only a small local tab row displaces Docs'); + const copyDelta=newLayout.docs[0].height-oldLayout.docs[0].height; + const at=t=>newLayout.docs[newLayout.docsTitles.indexOf(t)]; + for(let i=1;i0,'The five ITEM panels take room between Risks and Find Basket'); + assert.ok(near(at('Find Basket').top-oldLayout.docs.at(-1).top,tabShift+copyDelta+inserted)&&near(at('Find Basket').height,oldLayout.docs.at(-1).height),'Find Basket keeps its size and moves down by exactly the tab row, the copy change and the inserted panels'); + for(const t of ITEM_PANELS)assert.ok(at(t).height>0,'ITEM panel rendered: '+t); + checks.push('v14 Docs: the four v12 paragraphs with the live fee sentence; five ITEM panels between Risks and Find Basket; every retained panel keeps its size, displaced only by the tab row, the copy change and the inserted panels'); } if(name==='redeem') { assert.equal(oldLayout.redeemAside,null); @@ -332,9 +346,23 @@ try { } // v13: on Deposit everything after the fold line moves down by exactly the open list's added height. const listGrowth=name==='deposit'?newLayout.stockList.height-oldLayout.stockList.height:0; - if(name==='deposit')assert.ok(listGrowth>0,'The open list is taller than the fold line'); + if(name==='deposit'){ + // v14: the sentence about grey stocks is removed from the list; the list is shorter by exactly that line (its box plus the bottom margin that no longer separates it from the buttons). + assert.ok(oldLayout.stockList.grey&&!newLayout.stockList.grey,'v14: the grey-stocks sentence is gone from the list'); + assert.deepEqual(newLayout.stockList.sentences,oldLayout.stockList.sentences.filter(t=>t!=='Grey stocks are already in the form above.'),'v14: the list keeps its other words'); + assert.ok(near(listGrowth,-(oldLayout.stockList.grey.height+oldLayout.stockList.grey.marginBottom)),'v14: the list is shorter by exactly the removed sentence: '+listGrowth); + } for(const x of prior) { const y=counterpart(x); + // Owner selected Docs-local tabs and the two exact copy edits. Name only these consequences. + if(name==='docs'&&x.docsIndex>=0){ + if(words(x.text)===words(docsAdded[3])){assert(!now.some(z=>words(z.text)===words(x.text)));exceptions.push({reason:'Approved not-live sentence removal',element:x});continue;} + const target=y??now.find(z=>words(z.text)===words(x.text.replace('is set to go to ITEM staking once ITEM is live','goes to ITEM staking'))); + assert(target,'Every retained Docs first-screen word remains'); + const tabShift=newLayout.docs[0].top-oldLayout.docs[0].top; + assert.ok(near(target.y-x.y,tabShift+(x.docsIndex>0?newLayout.docs[0].height-oldLayout.docs[0].height:0)),'Exact local tab/copy displacement'); + exceptions.push({reason:'Approved Docs local tab row and exact copy layout',element:x,now:target});continue; + } if(name==='deposit'&&x.tag==='SUMMARY'&&/^All stocks you can deposit \(\d+\)$/.test(normalize(x.text))) { const line=now.find(z=>z.tag==='P'&&words(z.text)===words(x.text)); assert.ok(line&&near(line.y,x.y),'v13: the fold line is a plain heading line with the same words in the same place'); @@ -369,6 +397,6 @@ try { } assert.deepEqual(routingErrors,[],'Only local files and allowed public RPC reads; fixture account reads stay synthetic'); for(const r of results)if(r.regressions.length)throw Error(JSON.stringify({variant:r.variant,name:r.name,regressions:r.regressions})); - console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.prior.length,0),'previous first-screen elements preserved except the exact named v13 Deposit list heading and displacement; header height, slogan strip and character assertions retained'); + console.log('PASS 375x812 baseline visibility: six pages, no-wallet and connected;',results.reduce((n,r)=>n+r.prior.length,0),'previous first-screen elements preserved except the exact approved Docs tab row and copy edits; header height, slogan strip and character assertions retained'); fs.writeFileSync('../artifacts/phone-visibility.json',JSON.stringify(results,null,2)+'\n'); }finally{await b.close();await new Promise(r=>server.close(r));} diff --git a/web/scripts/run-browser.mjs b/web/scripts/run-browser.mjs index 78c2bf3..939caf1 100644 --- a/web/scripts/run-browser.mjs +++ b/web/scripts/run-browser.mjs @@ -1,3 +1,5 @@ +import { checkBaskGuide } from "./bask-guide-browser.mjs"; +import { checkTokenomics } from "./tokenomics-browser.mjs"; // One bounded foreground process owns its fork, preview server and browser. import fs from "node:fs"; import path from "node:path"; @@ -468,6 +470,7 @@ try { let holdConnect = false, releaseConnect, emptyAccounts = false; const v12Checks = []; const v13Checks = []; + const v14Checks = []; page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); @@ -505,6 +508,11 @@ try { t.to.toLowerCase() === VAULT ? vaultAbi : compiled.TestStock.abi; const decoded = decodeFunctionData({ abi, data: t.data }); assert.equal(await raw("eth_chainId"), "0x1237"); + if (['deposit','redeem'].includes(decoded.functionName)) { + const deadline=Number(decoded.args.at(-1)); + const clockSeconds=await page.evaluate(()=>Math.floor(Date.now()/1000)); + assert.ok(deadline<=clockSeconds+600&&deadline>=clockSeconds+590,'Deadline remains browser time plus 600 seconds (up to 10 seconds of simulation/request latency)'); + } sends.push({ to: t.to, ...decoded, gas: t.gas }); // v12: reprice one existing deposit in the mock, without adding a page send. if (speedNext) { @@ -832,13 +840,15 @@ try { `${state} ${name} overflow ${width}`, ); const file = `${state}-${name.toLowerCase()}-${width}.jpg`; - // Quality 26 in v12 (32 in v11, 36 in v9/v10, 40 before): the 24 - // readable full-page screenshots leave at least 250,000 bytes within - // the 8 MiB delivery budget, including the two share-picture copies. + // Quality 18 in v14 (26 in v12 and v13, 32 in v11, 36 in v9/v10, 40 + // before): the Docs page is five panels and a tab row longer, so the + // 24 readable full-page screenshots keep at least 250,000 bytes + // within the 8 MiB delivery budget, including the two share-picture + // copies. await page.screenshot({ path: path.join(artifacts, file), type: "jpeg", - quality: 26, + quality: 18, fullPage: true, }); shots.push(file); @@ -872,12 +882,39 @@ try { v12Checks.push("Vault: Always in stock, no sentence or empty paragraph; compact deposit status shows existing label and status only"); await nav("Docs"); await page.getByRole("heading", { name: "Docs", exact: true }).waitFor(); - assert.deepEqual(await page.locator(".docs-grid > section").first().locator("p").allTextContents().then(xs => xs.map(x => x.replace(/\s+/g, " ").trim())), ["One token, BASK, backed by a basket of Stock Tokens held in a vault on Robinhood Chain. Deposit one or more listed Stock Tokens at once and receive BASK. Redeeming BASK pays a share of every stock in the vault.","The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.","BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.","The 0.5% fee on each deposit and redemption is set to go to ITEM staking once ITEM is live, so more trading through the vault means more fees for ITEM.","ITEM is not live yet. Its address will be listed here when it is."]); - v12Checks.push("Docs: original paragraph and four approved paragraphs, word for word"); + assert.deepEqual(await page.locator(".docs-grid > section").first().locator("p").allTextContents().then(xs => xs.map(x => x.replace(/\s+/g, " ").trim())), ["One token, BASK, backed by a basket of Stock Tokens held in a vault on Robinhood Chain. Deposit one or more listed Stock Tokens at once and receive BASK. Redeeming BASK pays a share of every stock in the vault.","The basket has no set weights. Any listed stock can be deposited, in any amount, as long as the vault’s checks pass and the vault stays under its size limit. So the mix is whatever holders have put in, and it changes with every deposit and redemption.","BASK’s backing moves with every stock in the vault, so its market price can drift from the value behind it. Traders close that gap: they deposit stocks and sell BASK when it trades high, or buy BASK and redeem when it trades low. That keeps BASK near its backing.","The 0.5% fee on each deposit and redemption goes to ITEM staking, so more trading through the vault means more fees for ITEM."]); + v12Checks.push("Docs: original paragraph and approved live-oriented copy, word for word"); + // v14: five ITEM panels between Risks and Find Basket, in the default Docs tab. + assert.deepEqual(await page.locator("#docs-panel-0 .docs-grid > section > h2").allTextContents(), ["What Basket is", "Deposits", "Redemption", "FAQ", "NAV", "Numbers", "Safety", "Risks", "ITEM", "Earn ITEM by providing liquidity", "Earn BASK by staking ITEM", "Rewards FAQ", "ITEM risks", "Find Basket"]); + assert.equal(await page.locator("#docs-panel-0 .docs-grid > section").nth(8).locator("p").first().textContent().then((x) => x.replace(/\s+/g, " ").trim()), "ITEM is Basket's reward token. It has two jobs: it pays the people who provide liquidity for BASK and ITEM, and staking it earns the vault's fees. Its supply is fixed at 1,000,000,000 and its reward schedules are written into contracts that have no owner, no pause and no upgrade. Nobody can change the amounts, speed them up or stop them."); + assert.deepEqual(await page.locator("#docs-panel-0 .docs-grid > section").nth(9).locator("table tbody tr").evaluateAll((rows) => rows.map((r) => [...r.children].map((c) => c.textContent))), [["BASK/USDG", "490,000,000", "43.75%", "31.25%", "18.75%", "6.25%"], ["ITEM/ETH", "210,000,000", "43.75%", "31.25%", "18.75%", "6.25%"]]); + assert.deepEqual(await page.locator("#docs-panel-0 .docs-grid > section").nth(11).locator("h3").allTextContents(), ["Can I claim whenever I want?", "Is anything locked?", "What if nobody uses the vault?", "Do rewards expire?", "Can the team change the rewards?"]); + assert.equal(await page.locator("#docs-panel-0").getByText(/not live yet/).count(), 0, "no not-live wording in the Docs tab"); + v14Checks.push("Docs: five ITEM panels between Risks and Find Basket, headings in order, the ITEM paragraph, the gauge table and the five FAQ questions word for word; no not-live wording"); + const beforeTokenomics = {rpc:rpcCalls.length, wallet:asked.length}; + await page.evaluate(()=>{ + window.__docsStorageWrites=0; + window.__docsStorageOriginals=Object.fromEntries(['setItem','removeItem','clear'].map(k=>[k,Storage.prototype[k]])); + for(const key of ['setItem','removeItem','clear'])Storage.prototype[key]=function(...args){window.__docsStorageWrites++;return window.__docsStorageOriginals[key].apply(this,args);}; + }); + checks.push(await checkTokenomics(page)); + checks.push(await checkBaskGuide(page)); + const docsStorageWrites=await page.evaluate(()=>{const n=window.__docsStorageWrites;for(const [key,fn] of Object.entries(window.__docsStorageOriginals))Storage.prototype[key]=fn;delete window.__docsStorageWrites;delete window.__docsStorageOriginals;return n;}); + assert.equal(docsStorageWrites,0,'All local Docs tab visits add no storage write'); + checks.push('BASK: all local Docs tabs add zero storage writes'); + const afterTokenomics = {rpc:rpcCalls.length, wallet:asked.length}; + assert.deepEqual(afterTokenomics,beforeTokenomics,'Opening Tokenomics adds no RPC or wallet request'); + checks.push('Tokenomics: opening the tab adds zero RPC and zero wallet requests'); await nav("Deposit"); await page.getByRole("heading", { name: "Deposit Stock Tokens", exact: true }).waitFor(); - const beforeDeposit = page.locator("aside.panel"); + // v14: on desktop the notes sit under the form inside the panel; no aside. + const beforeDeposit = page.locator(".deposit-main > .deposit-notes"); + assert.equal(await beforeDeposit.count(), 1); + assert.equal(await page.locator("aside.panel").count(), 0, "v14: no Before-you-deposit aside on desktop"); + assert.equal(await beforeDeposit.locator("h2").textContent(), "Before you deposit"); assert.equal(await beforeDeposit.locator("details").count(), 0); + assert.equal(await beforeDeposit.getByText("Issuer restrictions and pool checks can temporarily halt deposits.", { exact: true }).count(), 1, "v14: the approved note"); + assert.equal(await page.getByText(/Feeds lag the market\. Issuer/).count(), 0, "the old note is gone"); assert.equal(await beforeDeposit.getByText(/^Size limit: /).count(), 1, "Deposit aside retains Size limit"); for (const text of ["You approve each stock for exactly the amount you deposit. Each approval is its own wallet prompt; the deposit prompt comes last.", "A prepared deposit must be sent within ten minutes. The BASK goes to your connected wallet."]) assert.equal(await beforeDeposit.getByText(text, {exact:true}).count(), 1); @@ -892,7 +929,7 @@ try { assert.equal(await fullDepositStatus.locator(":scope > strong").count(), 0, "Deposit notice has only its Closed sign and sentence"); assert.ok(await fullDepositStatus.evaluate(element => element.previousElementSibling?.classList.contains("hours-tag") && element.parentElement.firstElementChild === element.previousElementSibling), "full deposit status follows the unchanged first-child 24/7 pill"); assert.deepEqual(await fullDepositStatus.locator(":scope > p").allInnerTexts(), ["Deposits will be back open when “genesis not finished” is resolved."], "initial setup has exactly one approved issue sentence"); - v12Checks.push("Deposit: Before you deposit shows both approved paragraphs without a fold; full deposit status follows the 24/7 pill"); + v12Checks.push("Deposit: Before you deposit under the form on desktop shows both approved paragraphs and the approved note without a fold; full deposit status follows the 24/7 pill"); await nav("Redeem"); await page.getByRole("heading", { name: "Redeem BASK", exact: true }).waitFor(); assert.equal(await page.locator(".flow-layout.one-column").count(), 1); @@ -1181,27 +1218,34 @@ try { checks.push( "Deposit status: no normal notice; Closed sign and one approved closure sentence", ); - // v11: the list under the search, closed until pressed, holds every stock - // the vault takes now. A stock that has a row is switched off; S10 to S16 - // 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"), + // v14: on desktop (the walk's 1440px) the list is always open in the + // right-hand column, never a fold; it 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(".deposit-right div.all-stocks.open-list"), 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 page.locator("details.all-stocks").count(), 0); assert.equal( - await allStocks.locator("summary").textContent(), + await allStocks.locator(".list-title").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.equal(await allStocks.getByText("Grey stocks are already in the form above.", { exact: true }).count(), 0, "v14: the grey-stocks sentence is gone"); + // The two columns: the form, its buttons and the notes left, the list right, side by side from the same top. + const columns = await page.evaluate(() => { + const main = document.querySelector(".deposit-main").getBoundingClientRect(), right = document.querySelector(".deposit-right").getBoundingClientRect(), notes = document.querySelector(".deposit-notes"), form = document.querySelector(".deposit-main form").getBoundingClientRect(); + return { sideBySide: right.left >= main.right - 1 && Math.abs(right.top - main.top) < 2, notesUnderForm: !!notes && notes.getBoundingClientRect().top >= form.bottom && !!main && document.querySelector(".deposit-main").contains(notes), aside: document.querySelectorAll("aside.panel").length, columnsTemplate: getComputedStyle(document.querySelector(".deposit-columns")).gridTemplateColumns.split(" ").length, overflow: document.documentElement.scrollWidth > innerWidth }; + }); + assert.deepEqual(columns, { sideBySide: true, notesUnderForm: true, aside: 0, columnsTemplate: 2, overflow: false }, JSON.stringify(columns)); assert.deepEqual( await tickers(), listed.map((s) => [s, !listed.slice(12, 19).includes(s)]), @@ -1224,7 +1268,7 @@ try { ); 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", + "All stocks you can deposit (25): open in the right-hand column on desktop with the notes under the form, 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(); @@ -1605,14 +1649,14 @@ try { // shown open across the panel, with its heading as a plain line and no // details element, and it stays open for that account after a press. The // list reads from the vault only: no wallet request and no send. - const openList = page.locator("div.all-stocks.open-list"), + const openList = page.locator(".deposit-right div.all-stocks.open-list"), listFacts = () => page.evaluate(() => { const ul = document.querySelector(".all-stocks ul"), title = document.querySelector(".all-stocks .list-title"), - panel = ul.closest("section.panel"), + // v14: the list fills its right-hand column (the panel's content box on phones). + panel = ul.closest(".deposit-right") ?? ul.closest("section.panel"), cs = getComputedStyle(panel), - // The panel's content box: client width without its frame and padding. inner = panel.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight), u = ul.getBoundingClientRect(); return { @@ -1641,7 +1685,7 @@ try { ["All stocks you can deposit (25)", "P", 0, 25, false], JSON.stringify(facts), ); - assert.ok(facts.ratio > 0.98 && facts.columns >= 8, JSON.stringify(facts)); + assert.ok(facts.ratio > 0.98 && facts.columns >= 5, JSON.stringify(facts)); await openList .getByText("Press a stock to add it to your deposit.", { exact: true }) .waitFor(); @@ -1652,7 +1696,8 @@ try { ); await address.waitFor(); await page.getByLabel("FIG amount", { exact: true }).waitFor(); - assert.equal(await page.locator("details.all-stocks").count(), 1); + // v14: on desktop the owner's wallet sees the open list too (never a fold). + assert.deepEqual([await page.locator("details.all-stocks").count(), await openList.count()], [0, 1]); await page.evaluate((a) => window.__accounts([a]), nextOwner); await page .getByText("No open listed stock in this wallet.", { exact: true }) @@ -1664,7 +1709,7 @@ try { ); facts = await listFacts(); assert.deepEqual([facts.title, facts.summaries, facts.buttons], ["All stocks you can deposit (25)", 0, 25]); - assert.ok(facts.ratio > 0.98 && facts.columns >= 8, JSON.stringify(facts)); + assert.ok(facts.ratio > 0.98 && facts.columns >= 5, JSON.stringify(facts)); await openList.getByRole("button", { name: "FIG", exact: true }).click(); await page.getByLabel("FIG amount", { exact: true }).waitFor(); assert.equal(await page.locator(":focus").getAttribute("aria-label"), "FIG amount"); @@ -1672,17 +1717,15 @@ try { assert.equal(await openList.count(), 1); assert.equal(await openList.getByRole("button", { name: "FIG", exact: true }).isDisabled(), true); assert.equal(await page.locator("details.all-stocks").count(), 0); - // Back to the owner's wallet: its rows, and the fold closed as before. + // Back to the owner's wallet: its rows, and (desktop) the list still open on the right. await page.evaluate((a) => window.__accounts([a]), owner); await page.waitForFunction(() => document.querySelectorAll(".deposit-row").length === 25); - assert.equal(await openList.count(), 0); - assert.equal(await page.locator("details.all-stocks").count(), 1); - assert.equal(await page.locator("details.all-stocks").evaluate((d) => d.open), false); + assert.deepEqual([await openList.count(), await page.locator("details.all-stocks").count()], [1, 0]); assert.equal(sends.length, listSends); assert.deepEqual([...new Set(asked.slice(listRequests))].sort(), ["eth_chainId", "eth_requestAccounts"]); assert.deepEqual([issues, consoleErrors.slice(before[2])], [[], []]); v13Checks.push( - "Deposit stock list: open across the panel with a plain heading and no fold while the form is empty (no wallet; a wallet without a listed stock), a press adds the row and the list stays open for that account, the owner's wallet keeps the closed fold; no wallet request or send", + "Deposit stock list on desktop: always open in the right-hand column with a plain heading and no fold (no wallet; a wallet without a listed stock; the owner's wallet), a press adds the row and the list stays open; no wallet request or send", ); // A Saturday: deposits refused (Hours) with the reopen time; redeem and claim still work. await warpTo(await saturday()); @@ -2159,8 +2202,28 @@ try { assert.ok(facts.natural>=240&&facts.lines>1&&facts.inside&&!facts.overflow,JSON.stringify(facts));assert.deepEqual(facts.bad,[]); } v12Checks.push("24/7 label: widened text wraps at whole words inside pill and panel at 661px on Deposit and Redeem"); - checks.push(...v12Checks, ...v13Checks); + checks.push(...v12Checks, ...v13Checks, ...v14Checks); assert.deepEqual([...new Set(asked)].sort(),["eth_accounts","eth_chainId","eth_requestAccounts","eth_sendTransaction"]); + if (process.env.BASKET_PREVIOUS_WALK) { + const previous = JSON.parse(fs.readFileSync(process.env.BASKET_PREVIOUS_WALK,'utf8')); + const serialized = JSON.parse(JSON.stringify(sends,(_,v)=>typeof v==='bigint'?String(v):v)); + assert.equal(serialized.length,70,'All 70 prior wallet sends retained'); + // The existing fork clock advances with real elapsed time. Only its derived deadlines vary by run. + // The gate separately compares exact calldata/deadlines at six fixed clocks; send capture checks live clock above. + const normalized=records=>records.map(record=>['deposit','redeem'].includes(record.functionName)?{...record,args:[...record.args.slice(0,-1),'browser-clock-plus-600']}:record); + assert.deepEqual(normalized(serialized),normalized(previous.sends),'All 70 sends equal v13 apart from wall-clock-derived deadlines'); + const currentChecks=checks.filter(c=>!c.startsWith('Tokenomics:')&&!c.startsWith('BASK:')&&!v14Checks.includes(c)); + // v14's authorized label replacements (owner decisions: desktop columns, the notes under the form, the new note). + const renamed={ + 'Docs: original paragraph and four approved paragraphs, word for word':'Docs: original paragraph and approved live-oriented copy, word for word', + "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":"All stocks you can deposit (25): open in the right-hand column on desktop with the notes under the form, 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", + 'Deposit: Before you deposit shows both approved paragraphs without a fold; full deposit status follows the 24/7 pill':'Deposit: Before you deposit under the form on desktop shows both approved paragraphs and the approved note without a fold; full deposit status follows the 24/7 pill', + "Deposit stock list: open across the panel with a plain heading and no fold while the form is empty (no wallet; a wallet without a listed stock), a press adds the row and the list stays open for that account, the owner's wallet keeps the closed fold; no wallet request or send":"Deposit stock list on desktop: always open in the right-hand column with a plain heading and no fold (no wallet; a wallet without a listed stock; the owner's wallet), a press adds the row and the list stays open; no wallet request or send", + }; + const previousChecks=previous.checks.map(c=>renamed[c]??c); + assert.deepEqual(currentChecks,previousChecks,'Every prior page check retained in order'); + assert.deepEqual(shots,previous.shots,'Every prior screenshot retained in order'); + } assert.equal(revoked.length,0); assert.deepEqual(issues,[]); fs.writeFileSync( @@ -2174,6 +2237,8 @@ try { sends, shots, rpcCalls: rpcCalls.length, + tokenomicsRequests: {before:beforeTokenomics,after:afterTokenomics}, + docsStorageWrites, issues, limitations: [ "Local fork and disposable token/feed/pool fixtures; no live transaction", diff --git a/web/scripts/tokenomics-browser.mjs b/web/scripts/tokenomics-browser.mjs new file mode 100644 index 0000000..654d819 --- /dev/null +++ b/web/scripts/tokenomics-browser.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { ALLOCATION_HEADERS, ALLOCATION_ROWS, RELEASE_HEADERS, RELEASE_ROWS, PARAGRAPHS, SUPPLY_LABEL, RELEASE_LABEL } from '../src/tokenomicsData.ts'; +export async function checkTokenomics(page) { + const tabs=page.getByRole('tab'); + assert.deepEqual(await tabs.allTextContents(),['Docs','Tokenomics','BASK']); + assert.equal(await tabs.nth(0).getAttribute('aria-selected'),'true'); + assert.equal(await page.locator('#tokenomics').count(),0); + const navigation=await page.locator('nav a').allTextContents(); + assert.deepEqual(navigation.map(x=>x.trim()),['Vault','Deposit','Redeem','Docs']); + await tabs.nth(0).focus();await page.keyboard.press('ArrowRight'); + assert.equal(await tabs.nth(1).getAttribute('aria-selected'),'true'); + assert.equal(await tabs.nth(1).evaluate(e=>e===document.activeElement),true); + const module=page.locator('#tokenomics');await module.waitFor(); + assert.equal(await page.locator('#docs-panel-0').isVisible(),false); + assert.equal(await page.locator('#docs-panel-1').getAttribute('aria-labelledby'),'docs-tab-1'); + assert.equal(await tabs.nth(1).getAttribute('aria-controls'),'docs-panel-1'); + assert.equal(await module.locator(':scope > h2').textContent(),'Tokenomics'); + assert.deepEqual(await module.locator(':scope > p').allTextContents(),PARAGRAPHS); + for(const [selector,headers,rows] of [['.tm-allocations',ALLOCATION_HEADERS,ALLOCATION_ROWS],['.tm-releases',RELEASE_HEADERS,RELEASE_ROWS]]){ + assert.deepEqual(await module.locator(selector+' thead th').allTextContents(),headers); + assert.deepEqual(await module.locator(selector+' tbody tr').evaluateAll(rs=>rs.map(r=>[...r.children].map(c=>c.textContent))),rows); + } + assert.equal(await module.locator('.tm-live').count(),0);assert(!/not live|planned numbers|pre.launch/i.test(await module.textContent())); + assert.equal(await module.locator('.tm-supply svg').getAttribute('role'),'img');assert.equal(await module.locator('.tm-supply svg').getAttribute('aria-label'),SUPPLY_LABEL); + for(const svg of await module.locator('.tm-release svg').all()){assert.equal(await svg.getAttribute('role'),'img');assert.equal(await svg.getAttribute('aria-label'),RELEASE_LABEL);} + const initial=page.viewportSize(); + for(const width of [320,375,1440,1920]){ + await page.setViewportSize({width,height:900}); + const geometry=await module.evaluate(panel=>{ + const p=panel.getBoundingClientRect(),visible=e=>{const b=e.getBoundingClientRect();return b.width>0&&b.height>0;}; + const charts=[...panel.querySelectorAll('svg')].filter(visible); + const overlap=(a,b)=>a.leftb.left+.1&&a.topb.top+.1; + return {overflow:document.documentElement.scrollWidth>innerWidth,escaped:[...panel.querySelectorAll('table,figure,svg')].filter(visible).filter(e=>{const b=e.getBoundingClientRect();return b.leftp.right+.5;}).length,overlaps:charts.flatMap(svg=>{const labels=[...svg.querySelectorAll('text')].map(e=>e.getBoundingClientRect());return labels.flatMap((a,i)=>labels.slice(i+1).filter(b=>overlap(a,b)));}).length}; + });assert.deepEqual(geometry,{overflow:false,escaped:0,overlaps:0},'tables/charts and text at '+width); + } + await page.setViewportSize(initial); + await tabs.nth(1).focus();await page.keyboard.press('Home');assert.equal(await tabs.nth(0).getAttribute('aria-selected'),'true'); + await page.keyboard.press('End');assert.equal(await tabs.nth(2).getAttribute('aria-selected'),'true'); + await page.keyboard.press('ArrowLeft');assert.equal(await tabs.nth(1).getAttribute('aria-selected'),'true'); + await page.keyboard.press('Home'); + return 'Tokenomics: Docs-local keyboard tabs; exact approved prose/tables/labels; all-null live block absent; both SVG charts and contained layout at 320/375/1440/1920'; +} diff --git a/web/src/BaskGuide.tsx b/web/src/BaskGuide.tsx new file mode 100644 index 0000000..245e9e2 --- /dev/null +++ b/web/src/BaskGuide.tsx @@ -0,0 +1,10 @@ +import { INTRO, SECTIONS, type Block, type Example } from "./baskGuideData"; +function Blocks({ blocks }: { blocks: Block[] }) { + return <>{blocks.map((block, index) => block.kind === "table" ? {block.headers.map(h => )}{block.rows.map((row, r) => {row.map((cell, c) => c === 0 ? : )})}
    {h}
    {cell}{cell}
    : block.kind === "formula" ?
    {block.text}
    :

    {block.text}

    )}; +} +function WorkedExample({ example }: { example: Example }) { + return

    {example.title}

    ; +} +export function BaskGuide() { + return

    BASK

    {INTRO}

    {SECTIONS.map(section =>

    {section.title}

    {section.examples.length > 0 &&
    {section.examples.map(example => )}
    }
    )}
    ; +} diff --git a/web/src/Docs.tsx b/web/src/Docs.tsx index 279b07c..59a0961 100644 --- a/web/src/Docs.tsx +++ b/web/src/Docs.tsx @@ -1,11 +1,29 @@ +import { useRef, useState, type KeyboardEvent } from "react"; +import { Tokenomics } from "./Tokenomics"; +import { BaskGuide } from "./BaskGuide"; import { PageTitle, AddressLink } from "./components"; import { VAULT } from "./deployment"; export function DocsPage() { + const [tab, setTab] = useState(0); + const buttons = useRef<(HTMLButtonElement | null)[]>([]); + function move(event: KeyboardEvent) { + const next = event.key === "Home" ? 0 : event.key === "End" ? 2 : event.key === "ArrowRight" ? (tab + 1) % 3 : event.key === "ArrowLeft" ? (tab + 2) % 3 : undefined; + if (next === undefined) return; + event.preventDefault(); + setTab(next); + buttons.current[next]?.focus(); + } return ( <> How Basket works, what it costs and what to watch for. +
    + {["Docs", "Tokenomics", "BASK"].map((label, index) => ( + + ))} +
    + + + ); } diff --git a/web/src/Flows.tsx b/web/src/Flows.tsx index 2fc1038..92c48a8 100644 --- a/web/src/Flows.tsx +++ b/web/src/Flows.tsx @@ -82,6 +82,17 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { // v13: the account ("" for none) for which the stock list under the search // was shown open because the form was empty; it stays open for it. const [listOpenFor, setListOpenFor] = useState(); + // v14: wider than 960px the panel has two columns (the form left, the stock + // list always open on the right); phones and tablets keep the one column. + const [desktop, setDesktop] = useState( + () => window.matchMedia("(min-width: 961px)").matches, + ); + useEffect(() => { + const query = window.matchMedia("(min-width: 961px)"); + const update = () => setDesktop(query.matches); + query.addEventListener("change", update); + return () => query.removeEventListener("change", update); + }, []); // The stock an Add button just added, until its row is on screen. const focusRow = useRef
    (undefined); const sequence = useRef(0), @@ -351,7 +362,7 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { useEffect(() => { if (emptyForm) setListOpenFor(accountKey); }, [emptyForm, accountKey]); - const listOpen = emptyForm || listOpenFor === accountKey; + const listOpen = desktop || emptyForm || listOpenFor === accountKey; const StockList = listOpen ? "div" : "details"; // `focus` is false for Enter in the search box, which keeps the focus. function add(t: Address, focus = true) { @@ -400,6 +411,59 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { counted++; } } + // Every stock the vault takes now. On phones: inside the form, closed until + // pressed while the form has rows, open across the panel while the form is + // empty (no wallet, or a wallet without a listed stock) and then for the + // rest of the visit for that account (v13). On desktop: always open, in the + // right-hand column (v14). A press is the same Add as in the search; a stock + // that has a row stays in the list, switched off. + const stockList = ( + + {listOpen ? ( +

    All stocks you can deposit ({open.length})

    + ) : ( + All stocks you can deposit ({open.length}) + )} +

    Press a stock to add it to your deposit.

    +
      + {listed.map((a) => ( +
    • 5 ? "wide" : undefined}> + +
    • + ))} +
    +
    + ); + // "Before you deposit": its own panel under the form on phones, under the + // form inside the panel on desktop (v14). + const notes = ( + <> +

    Before you deposit

    +

    Size limit: {usd(s.globals.NAV_CAP)}.

    +

    + First deposit: 0.001 BASK is locked forever. The preview already + subtracts this lock and any fee. +

    +

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

    +

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

    + + Issuer restrictions and pool checks can temporarily halt deposits. + + + ); return ( <>

    Make a deposit

    + {/* v14: two columns on desktop (the form, its preview and buttons and + the notes left; the stock list right), one column otherwise. */} +
    +
    {!open.length ? ( )}
    - {/* Every stock the vault takes now. Closed until pressed while - the form has rows; shown open across the panel while the - form is empty (no wallet, or a wallet without a listed - stock), and then for the rest of the visit for that account - (v13). A press is the same Add as in the search; a stock - that has a row stays in the list, switched off. */} - - {listOpen ? ( -

    - All stocks you can deposit ({open.length}) -

    - ) : ( - - All stocks you can deposit ({open.length}) - - )} -

    Press a stock to add it to your deposit.

    -

    Grey stocks are already in the form above.

    -
      - {listed.map((a) => ( -
    • 5 ? "wide" : undefined} - > - -
    • - ))} -
    -
    + {/* The stock list (above): in the form on phones, in the + right-hand column on desktop. */} + {!desktop && stockList} {counted + badAmount.length + noPrice.length > 0 && (
    {counted > 0 && ( @@ -866,25 +895,14 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) {

    {error}

    + {desktop &&
    {notes}
    } +
    + {desktop && open.length > 0 && ( +
    {stockList}
    + )} +
    - + {!desktop && } ); diff --git a/web/src/Tokenomics.tsx b/web/src/Tokenomics.tsx new file mode 100644 index 0000000..af8e644 --- /dev/null +++ b/web/src/Tokenomics.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; +import { parseAbi, formatUnits, type Address } from "viem"; +import { many } from "./chain"; +import { ADDRESSES, ALLOCATIONS, ALLOCATION_HEADERS, ALLOCATION_ROWS, RELEASE_HEADERS, RELEASE_ROWS, PARAGRAPHS, T, gaugeReleased, developmentVested, outsideContracts, formatItem, SUPPLY_LABEL, RELEASE_LABEL, type Addresses } from "./tokenomicsData"; + +const colors = ["var(--royal)", "var(--sky)", "var(--sun)", "var(--red)", "var(--ink)", "#93aaca", "#dbe6f4"]; +function Table({ headers, rows, kind }: { headers: readonly string[]; rows: readonly (readonly string[])[]; kind: string }) { + return {headers.map(h => )}{rows.map((r, i) => {r.map((cell, j) => j === 0 ? : )})}
    {h}
    {cell}{cell}
    ; +} +function SupplyChart() { + return
    {ALLOCATIONS.map((r, i) => n + a.share, 0) * 10} y="0" width={r.share * 10} height="48" fill={colors[i]} stroke="var(--paper)" strokeWidth="2" />)}
    {ALLOCATIONS.map((r, i) =>
    {r.who}{r.share}%
    )}
    ; +} +function Curve({ width, height }: { width: number; height: number }) { + const l = 44, r = 12, t = 12, b = 28, w = width - l - r, h = height - t - b; + const x = (m: number) => l + m / 48 * w, y = (n: number) => t + h - n / 1e9 * h; + // Both sides of the cliff are sampled: there is no gradual vesting in month 11. + const samples = [...Array.from({ length: 12 }, (_, i) => i), 11.999999, ...Array.from({ length: 37 }, (_, i) => i + 12)]; + const line = (fn: (seconds: number) => number) => samples.map((m, i) => `${i ? "L" : "M"}${x(m).toFixed(3)},${y(fn(m / 48 * T)).toFixed(3)}`).join(" "); + return {[0, 250, 500, 750, 1000].map(n => {formatItem(n)})}{[0, 12, 24, 36, 48].map(m => {m})}{[12, 24, 36, 48].map(m => )}; +} +function ReleaseChart() { + return
    Released over four years
    Million ITEM
    Months after launch
    Outside the contracts, at mostLiquidity rewards releasedDevelopment vested
    ; +} +const liveAbi = parseAbi(["function totalSupply() view returns (uint256)", "function released() view returns (uint256)", "function remaining() view returns (uint256)", "function totalStaked() view returns (uint256)", "function vested() view returns (uint256)"]); +export const hasAddresses = (addresses: Addresses): addresses is Record => Object.values(addresses).every(value => value !== null); +export async function readLiveFigures(addresses: Addresses, reader: typeof many = many) { + if (!hasAddresses(addresses)) return null; + const views = [[addresses.ITEM, "totalSupply"], [addresses.BASK_USDG_GAUGE, "released"], [addresses.BASK_USDG_GAUGE, "remaining"], [addresses.BASK_USDG_GAUGE, "totalStaked"], [addresses.ITEM_ETH_GAUGE, "released"], [addresses.ITEM_ETH_GAUGE, "remaining"], [addresses.ITEM_ETH_GAUGE, "totalStaked"], [addresses.DEV_VESTING, "vested"], [addresses.DEV_VESTING, "released"], [addresses.ITEM_STAKING, "totalStaked"]] as const; + const results = await reader(views.map(([address, functionName]) => ({ address, abi: liveAbi, functionName }))); + return results.map(result => { + if (!result.ok || typeof result.value !== "bigint") throw new Error("Live figures unreadable."); + return result.value; + }); +} +const liveLabels = ["ITEM supply", "BASK/USDG gauge: released", "remaining", "LP staked", "ITEM/ETH gauge: released", "remaining", "LP staked", "Development vesting: vested", "released", "ITEM staking: ITEM staked"]; +const addressLabels = ["ITEM", "ITEM staking", "BASK/USDG gauge", "ITEM/ETH gauge", "Development vesting"]; +export function formatLive(value: bigint, lp = false) { + if (!lp) return (value / 10n ** 18n).toLocaleString("en-US"); + const [whole, fraction = ""] = formatUnits(value, 18).split("."); + const decimals = fraction.slice(0, 6).replace(/0+$/, ""); + return BigInt(whole).toLocaleString("en-US") + (decimals ? "." + decimals : ""); +} +export function LiveFigures({ addresses = ADDRESSES, reader = many }: { addresses?: Addresses; reader?: typeof many }) { + const [values, setValues] = useState(null); + const [failed, setFailed] = useState(false); + const [attempt, setAttempt] = useState(0); + useEffect(() => { + if (!hasAddresses(addresses)) return; + let active = true; + setValues(null); setFailed(false); + readLiveFigures(addresses, reader).then(result => { if (active) setValues(result); }).catch(() => { if (active) setFailed(true); }); + return () => { active = false; }; + }, [addresses, reader, attempt]); + if (!hasAddresses(addresses)) return null; + return

    Live figures

    {failed ? <>

    Live figures unreadable.

    : values && <>
    {values.map((value, i) =>
    {liveLabels[i]}
    {formatLive(value, i === 3 || i === 6)}
    )}

    Addresses

    {Object.values(addresses).map((address, i) =>
    {addressLabels[i]} {address}
    )}
    }
    ; +} +function Prose({ index }: { index: number }) { + const text = PARAGRAPHS[index]; + const split = text.indexOf(".") + 1; + return

    {text.slice(0, split)}{text.slice(split)}

    ; +} +export function Tokenomics() { + return

    Tokenomics

    {PARAGRAPHS[0]}

    {PARAGRAPHS[1]}

    {PARAGRAPHS[3]}

    ; +} diff --git a/web/src/baskGuideData.ts b/web/src/baskGuideData.ts new file mode 100644 index 0000000..bf2a14a --- /dev/null +++ b/web/src/baskGuideData.ts @@ -0,0 +1,223 @@ +// Static worked examples, derived in integer base units from the vault's arithmetic. +export const UNIT = 10n ** 18n; +export const LOCK = 10n ** 15n; +export const FEE_DENOMINATOR = 200n; +export function fee(amount: bigint, enabled = true) { + return enabled ? amount / FEE_DENOMINATOR + (amount % FEE_DENOMINATOR === 0n ? 0n : 1n) : 0n; +} +export function value(amount: bigint, answer: bigint, tokenDecimals: number, feedDecimals: number) { + const exponent = tokenDecimals + feedDecimals; + return exponent >= 18 ? amount * answer / 10n ** BigInt(exponent - 18) : amount * answer * 10n ** BigInt(18 - exponent); +} +export function deposit(depositValue: bigint, supply: bigint, nav: bigint, enabled = true) { + if (supply !== 0n && nav === 0n) throw new Error("Zero NAV"); + const gross = supply === 0n ? depositValue : depositValue * supply / nav; + const charged = fee(gross, enabled), locked = supply === 0n ? LOCK : 0n; + if (gross - charged <= locked) throw new Error("Zero shares"); + return { gross, fee: charged, locked, received: gross - charged - locked, supplyAfter: supply + gross, navAfter: nav + depositValue }; +} +export function redeem(submitted: bigint, supply: bigint, enabled = true) { + if (supply === 0n || submitted > supply) throw new Error("Invalid supply"); + const charged = fee(submitted, enabled), burned = submitted - charged; + return { fee: charged, burned, supplyAfter: supply - burned }; +} +export function allocation(managed: bigint, balance: bigint | null, owed: bigint, burned: bigint, supply: bigint) { + const available = balance === null ? managed : balance > owed ? balance - owed : 0n; + const redeemable = managed < available ? managed : available; + return redeemable * burned / supply; +} +export function format(value: bigint, decimals = 18) { + const unit = 10n ** BigInt(decimals), whole = value / unit; + const fraction = (value % unit).toString().padStart(decimals, "0").replace(/0+$/, ""); + return whole.toLocaleString("en-US") + (fraction ? "." + fraction : ""); +} +const u = (n: bigint) => n * UNIT; +export const FIRST = { quantity: u(10n), price: u(100n) }; +export const FIRST_VALUE = value(FIRST.quantity, FIRST.price, 18, 18); +export const INITIAL = deposit(FIRST_VALUE, 0n, 0n); +export const EXISTING = { quantity: u(5n), price: u(200n), nav: u(20_000n), supply: u(10_000n) }; +export const EXISTING_VALUE = value(EXISTING.quantity, EXISTING.price, 18, 18); +export const ADDED = deposit(EXISTING_VALUE, EXISTING.supply, EXISTING.nav); +export const REDEMPTION = { submitted: u(1_000n), supply: u(10_000n), stocks: [{ name: "Stock A", quantity: u(100n), price: u(80n) }, { name: "Stock B", quantity: u(60n), price: u(200n) }] }; +export const REDEEMED = redeem(REDEMPTION.submitted, REDEMPTION.supply); +export const OUTPUTS = REDEMPTION.stocks.map(s => allocation(s.quantity, s.quantity, 0n, REDEEMED.burned, REDEMPTION.supply)); +export const FRACTION = format(REDEEMED.burned * 100n * UNIT / REDEMPTION.supply) + "%"; +export const REDEMPTION_NAV = REDEMPTION.stocks.reduce((n, s) => n + value(s.quantity, s.price, 18, 18), 0n); +export const OUTPUT_VALUE = REDEMPTION.stocks.reduce((n, s, i) => n + value(OUTPUTS[i], s.price, 18, 18), 0n); +export const FEE_PERCENT = format(100n * UNIT / FEE_DENOMINATOR) + "%"; +// The arbitrage flywheel example (owner, 2026-10-13): ten holdings of 100 stock at $100 ($10,000 each, $100,000 +// in all) against 100,000 BASK; two holdings rise 10% to $110; 1,000 BASK bought for $1,000 and redeemed. +export const FLYWHEEL = { holdings: 10n, risen: 2n, quantity: u(100n), price: u(100n), risenPrice: u(110n), supply: u(100_000n), bought: u(1_000n) }; +export const FLYWHEEL_NAV_BEFORE = value(FLYWHEEL.quantity, FLYWHEEL.price, 18, 18) * FLYWHEEL.holdings; +export const FLYWHEEL_NAV_AFTER = value(FLYWHEEL.quantity, FLYWHEEL.price, 18, 18) * (FLYWHEEL.holdings - FLYWHEEL.risen) + value(FLYWHEEL.quantity, FLYWHEEL.risenPrice, 18, 18) * FLYWHEEL.risen; +export const FLYWHEEL_REDEEMED = redeem(FLYWHEEL.bought, FLYWHEEL.supply); +export const FLYWHEEL_OUTPUT = allocation(FLYWHEEL.quantity, FLYWHEEL.quantity, 0n, FLYWHEEL_REDEEMED.burned, FLYWHEEL.supply); +export const FLYWHEEL_UNCHANGED_VALUE = value(FLYWHEEL_OUTPUT, FLYWHEEL.price, 18, 18); +export const FLYWHEEL_RISEN_VALUE = value(FLYWHEEL_OUTPUT, FLYWHEEL.risenPrice, 18, 18); +export const FLYWHEEL_RECEIVED = FLYWHEEL_UNCHANGED_VALUE * (FLYWHEEL.holdings - FLYWHEEL.risen) + FLYWHEEL_RISEN_VALUE * FLYWHEEL.risen; +export const FLYWHEEL_GAIN = FLYWHEEL_RECEIVED - FLYWHEEL.bought; +export type Block = { kind: "p" | "formula"; text: string } | { kind: "table"; headers: string[]; rows: string[][] }; +export type Example = { title: string; blocks: Block[] }; +export type Section = Example & { examples: Example[] }; + +export const INTRO = "BASK represents a share of the Stock Tokens accounted for by the vault. Deposits create new BASK. Redemptions burn BASK and release a proportional amount of each stock. Its supply grows and shrinks with those actions; there is no fixed supply."; +export const SECTIONS: Section[] = [{"title": `What one BASK represents`, +"blocks": [{"kind": "p", +"text": `The vault's net asset value, or NAV, is the sum of each stock's managed quantity multiplied by its feed price. Dividing that value by the total BASK supply gives the feed-based backing per BASK. The total supply includes fee BASK and the permanently locked first-deposit amount. BASK's trading price can be different.`}, +{"kind": "formula", +"text": `NAV = sum of managed stock quantities × their feed prices`}, +{"kind": "formula", +"text": `Backing per BASK = NAV ÷ BASK supply`}, +{"kind": "p", +"text": `Managed quantities are the vault's accounted holdings. Stock already owed to earlier redeemers is kept separate. Direct donations are not counted until a Resync updates the accounting.`}], +"examples": []}, +{"title": `Minting BASK`, +"blocks": [{"kind": "p", +"text": `You can deposit one or more eligible Stock Tokens. The vault values each amount using its feed price, then adds those values together. Your deposit receives the same backing per BASK as the existing supply, before the fee.`}, +{"kind": "formula", +"text": `Deposit value = sum of deposited quantities × their feed prices`}, +{"kind": "formula", +"text": `Gross BASK = deposit value × BASK supply ÷ NAV before the deposit`}, +{"kind": "formula", +"text": `BASK you receive = gross BASK − deposit fee`}, +{"kind": "p", +"text": `The deposit fee is ${FEE_PERCENT} of gross BASK. Fee BASK is minted to the fee recipient alongside the BASK minted to you. The Stock Tokens stay in the vault.`}], +"examples": [{"title": `Example 1: the first deposit`, +"blocks": [{"kind": "p", +"text": `When the supply is zero, the first deposit creates 1 gross BASK per $1 of deposit value. It also locks ${format(INITIAL.locked)} BASK forever. That lock is taken only on the first deposit. Here, you deposit ${format(FIRST.quantity) + " Stock A"} at a feed price of ${"$" + format(FIRST.price)} each.`}, +{"kind": "table", +"headers": [`Step`, +`Amount`], +"rows": [[`Deposit value: ${format(FIRST.quantity) + " Stock A"} × ${"$" + format(FIRST.price)}`, +`$${format(FIRST_VALUE)}`], +[`Gross BASK created`, +`${format(INITIAL.gross)} BASK`], +[`Deposit fee: ${format(INITIAL.gross)} × ${FEE_PERCENT}`, +`${format(INITIAL.fee) + " BASK"}`], +[`Permanently locked`, +`${format(INITIAL.locked)} BASK`], +[`You receive`, +`${format(INITIAL.received)} BASK`]]}, +{"kind": "p", +"text": `The total supply is ${format(INITIAL.supplyAfter)} BASK: your ${format(INITIAL.received)}, ${"the " + format(INITIAL.fee) + " paid"} as the fee and the ${format(INITIAL.locked)} permanently locked.`}]}, +{"title": `Example 2: adding to an existing basket`, +"blocks": [{"kind": "p", +"text": `Suppose the vault has $${format(EXISTING.nav)} of NAV and a total supply of ${format(EXISTING.supply)} BASK. Each BASK has ${"$" + format(EXISTING.nav * UNIT / EXISTING.supply)} of feed-based backing. You deposit ${format(EXISTING.quantity) + " eligible"} Stock C at a feed price of ${"$" + format(EXISTING.price)} each, worth $${format(EXISTING_VALUE)} in total.`}, +{"kind": "table", +"headers": [`Step`, +`Amount`], +"rows": [[`Gross BASK: $${format(EXISTING_VALUE)} × ${format(EXISTING.supply)} ÷ $${format(EXISTING.nav)}`, +`${format(ADDED.gross) + " BASK"}`], +[`Deposit fee: ${format(ADDED.gross) + " ×"} ${FEE_PERCENT}`, +`${format(ADDED.fee)} BASK`], +[`You receive`, +`${format(ADDED.received)} BASK`], +[`NAV after the deposit`, +`$${format(ADDED.navAfter)}`], +[`BASK supply after the deposit`, +`${format(ADDED.supplyAfter)} BASK`]]}, +{"kind": "p", +"text": `At unchanged feed prices, $${format(ADDED.navAfter)} ÷ ${format(ADDED.supplyAfter)} is still ${"$" + format(EXISTING.nav * UNIT / EXISTING.supply)} of backing per BASK. The fee changes how the new BASK is divided between you and the fee recipient.`}]}]}, +{"title": `Redeeming BASK`, +"blocks": [{"kind": "p", +"text": `You submit BASK and receive a share of each stock in the vault. The ${FEE_PERCENT} redemption fee is transferred in BASK to the fee recipient. The remaining BASK is burned.`}, +{"kind": "formula", +"text": `BASK burned = BASK submitted − redemption fee`}, +{"kind": "formula", +"text": `Your fraction = BASK burned ÷ BASK supply before the redemption`}, +{"kind": "formula", +"text": `Each stock amount = that stock's redeemable quantity × your fraction`}, +{"kind": "p", +"text": `For a readable balance, the redeemable quantity is the smaller of its managed quantity and the balance available after earlier claims are reserved. If the balance cannot be read, the calculation uses the managed quantity and an unsuccessful payment is queued. Redemption calculates quantities without using price feeds.`}], +"examples": [{"title": `Example 3: receiving both stocks`, +"blocks": [{"kind": "p", +"text": `Consider a separate basket with a total supply of ${format(REDEMPTION.supply)} BASK and the two stocks below. You redeem ${format(REDEMPTION.submitted)} BASK: ${format(REDEEMED.fee) + " BASK"} is the fee, ${format(REDEEMED.burned)} BASK is burned, and your fraction is ${format(REDEEMED.burned)} ÷ ${format(REDEMPTION.supply)} = ${FRACTION}.`}, +{"kind": "table", +"headers": [`Stock`, +`Redeemable quantity`, +`Your fraction`, +`You receive`], +"rows": [[`Stock A`, +`${format(REDEMPTION.stocks[0].quantity)}`, +`${FRACTION}`, +`${format(OUTPUTS[0]) + " Stock A"}`], +[`Stock B`, +`${format(REDEMPTION.stocks[1].quantity)}`, +`${FRACTION}`, +`${format(OUTPUTS[1]) + " Stock B"}`]]}, +{"kind": "p", +"text": `For illustration, if Stock A's feed price is ${"$" + format(REDEMPTION.stocks[0].price)} and Stock B's is ${"$" + format(REDEMPTION.stocks[1].price)}, the basket's NAV is $${format(REDEMPTION_NAV)} and these stock amounts have a combined feed value of $${format(OUTPUT_VALUE)}. Those prices explain the value; they are not inputs to the redemption calculation. The BASK supply becomes ${format(REDEEMED.supplyAfter)} because only the ${format(REDEEMED.burned)} BASK is burned; the ${format(REDEEMED.fee) + " fee"} BASK remains in circulation.`}]}, +{"title": `Example 4: one stock is queued`, +"blocks": [{"kind": "p", +"text": `Use the same redemption, but suppose Stock B cannot be transferred and the basket is within the direct-payout limit. Stock A can still be paid. The receiver's claim to Stock B is recorded separately.`}, +{"kind": "table", +"headers": [`Stock`, +`Paid now`, +`Owed to the receiver`], +"rows": [[`Stock A`, +`${format(OUTPUTS[0]) + " Stock A"}`, +`0`], +[`Stock B`, +`0`, +`${format(OUTPUTS[1]) + " Stock B"}`]]}, +{"kind": "p", +"text": `Only the receiver wallet can claim the queued stock. Choose a receiver you control. Claiming does not burn more BASK or charge another redemption fee; gas still applies. Payment depends on the token becoming transferable and the vault having an available balance. If the basket exceeds the direct-payout limit, all stock amounts are queued for claiming.`}]}]}, +{"title": `The arbitrage flywheel`, +"blocks": [{"kind": "p", +"text": `As the prices of the stocks inside BASK move, BASK’s market price can drift away from the value of the stocks it represents. That gap creates an opportunity for arbitrageurs:`}, +{"kind": "p", +"text": `• BASK trades above its backing value: deposit stocks to mint BASK, then sell that BASK on the market.`}, +{"kind": "p", +"text": `• BASK trades below its backing value: buy BASK on the market, then redeem it for its share of the underlying stocks.`}, +{"kind": "p", +"text": `When the difference covers fees and trading costs, arbitrageurs can profit from closing the gap. Their trades help bring BASK’s market price closer to its backing value. Each mint or redemption pays a 0.5% fee in BASK to ITEM staking.`}, +{"kind": "formula", +"text": `Stock prices move → price gaps open → arbitrageurs mint or redeem → BASK fees flow to ITEM stakers.`}], +"examples": []}, +{"title": `How TVL and liquidity support the flywheel`, +"blocks": [{"kind": "p", +"text": `TVL is the total value of the stocks held by the vault. As TVL and basket diversity grow, so does the potential for arbitrage: a larger basket provides more backing to trade against, while a wider mix of stocks creates more individual price movements that can open up opportunities.`}, +{"kind": "p", +"text": `Liquidity in the BASK/USDG pool helps turn those opportunities into trades. A deeper pool lets arbitrageurs buy and sell larger amounts of BASK with less price impact, making more price gaps practical to capture.`}, +{"kind": "p", +"text": `Together, a larger and more diverse basket and deeper BASK/USDG liquidity can create more arbitrage opportunities and support larger trades. Each opportunity captured through minting or redemption generates BASK fees for ITEM stakers.`}, +{"kind": "formula", +"text": `More backing + more stock diversity + deeper liquidity → more room for arbitrage → more minting and redemption activity → more BASK fees for ITEM stakers.`}], +"examples": [{"title": `A simple example: ten stocks in the basket`, +"blocks": [{"kind": "p", +"text": `The basket holds 10 stocks worth $10,000 each, for $100,000 total. With 100,000 BASK outstanding, each BASK represents $1 of backing.`}, +{"kind": "p", +"text": `Two stocks rise 10%. Those holdings are now worth $11,000 each, while the other eight remain worth $10,000 each.`}, +{"kind": "p", +"text": `The basket is now worth $102,000, or $1.02 per BASK. But BASK is still available on the market for $1.`}, +{"kind": "p", +"text": `An arbitrageur:`}, +{"kind": "p", +"text": `1. Buys 1,000 BASK for $1,000.`}, +{"kind": "p", +"text": `2. Redeems it. The 0.5% fee sends 5 BASK to ITEM staking, and the remaining 995 BASK is burned.`}, +{"kind": "p", +"text": `3. Receives 0.995% of each stock holding:`}, +{"kind": "table", +"headers": [`Holding`, +`Received`], +"rows": [[`Eight unchanged stocks: $99.50 each`, +`$796`], +[`Two stocks that rose: $109.45 each`, +`$218.90`], +[`Total stock value received`, +`$1,014.90`]]}, +{"kind": "p", +"text": `4. Sells those stocks at the illustrated prices, capturing $14.90 before other trading costs and gas.`}, +{"kind": "p", +"text": `The arbitrageur captures the price difference, buying pressure helps move BASK’s market price toward its backing value, and ITEM stakers receive 5 BASK in fees.`}]}]}, +{"title": `Reading a real preview`, +"blocks": [{"kind": "p", +"text": `These are independent worked examples, with the ${FEE_PERCENT} fee enabled and gas excluded. They are not live quotes. The contract charges the fee when a fee recipient is configured; otherwise it is zero.`}, +{"kind": "p", +"text": `BASK has 18 decimals. The contract rounds the BASK fee up to the smallest BASK unit, gross minting down to the smallest BASK unit, and each redemption amount down to that stock's smallest unit. The examples use amounts that divide exactly.`}, +{"kind": "p", +"text": `Deposits must pass the vault's price, balance, availability and size-limit checks. A new deposit is rejected if the vault's NAV falls to zero. Your transaction also carries an expiry and minimum amounts: a deposit reverts if it would mint less BASK than your minimum, and a redemption reverts if a stock allocation falls below its supplied minimum. A queued allocation can still satisfy a redemption minimum; the minimum does not guarantee immediate delivery.`}, +{"kind": "p", +"text": `Use the Deposit or Redeem preview for the amounts calculated from the current vault state.`}], +"examples": []}]; diff --git a/web/src/styles.css b/web/src/styles.css index 1004cac..10eaf34 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2808,3 +2808,177 @@ button.quiet.small-toggle { @media (max-width: 660px) { .page-title.character-vault > h1 { margin-bottom: 0; } } + +/* Approved ITEM charts and local Docs tabs. */ +.docs-tabs { display:flex; gap:6px; margin:0 0 20px; border-bottom:2px solid var(--ink); } +.docs-tabs [role="tab"] { border:0; border-radius:0; background:transparent; color:var(--ink); padding:10px 20px; font-size:15px; font-weight:700; box-shadow:none; } +.docs-tabs [role="tab"][aria-selected="true"] { background:var(--sun); } +.docs-tabs [role="tab"]:focus-visible { outline:3px solid var(--royal); outline-offset:3px; } +[role="tabpanel"][hidden] { display:none; } + +.tm-module { grid-column:1 / -1; } +.tm-module > h2 { border-bottom:5px solid var(--sun); padding-bottom:12px; } +.tm-module > p { max-width:100%; margin-bottom:20px; } +.tm-module > p:first-of-type { max-width:76ch; font-size:18px; } +.tm-table { width:100%; border-collapse:collapse; margin:22px 0 32px; font-size:15px; table-layout:fixed; } +.tm-table th,.tm-table td { padding:13px 12px; border-bottom:1px solid #c3ccdb; vertical-align:top; text-align:left; overflow-wrap:break-word; } +.tm-table thead { background:var(--subtle); } +.tm-table thead th { border-top:2px solid var(--ink); border-bottom:2px solid var(--ink); font-size:13px; } +.tm-table tbody th { font-weight:700; color:var(--royal); } +.tm-allocations th:nth-child(1) { width:8%; } +.tm-allocations th:nth-child(2) { width:15%; } +.tm-allocations th:nth-child(3) { width:29%; } +.tm-allocations th:nth-child(4) { width:48%; } +.tm-releases th:nth-child(1) { width:12%; } +.tm-releases th:nth-child(2) { width:26%; } +.tm-releases th:nth-child(3) { width:20%; } +.tm-releases th:nth-child(4) { width:42%; } +.tm-chart { margin:24px 0; } +.tm-supply > svg { width:100%; height:42px; } +.tm-supply-legend { display:grid; grid-template-columns:1fr 1fr; gap:10px 32px; margin-top:18px; font-size:14px; } +.tm-supply-legend > div { display:grid; grid-template-columns:12px 1fr auto; gap:10px; align-items:baseline; } +.tm-key { height:12px; width:12px; border:1px solid var(--line); } +.tm-supply-legend strong { font-size:16px; } +.tm-release { padding:22px; background:var(--subtle); border:1px solid #c3ccdb; } +.tm-release > figcaption { font-family:Flyer,Impact,sans-serif; text-transform:uppercase; font-size:25px; margin-bottom:16px; } +.tm-unit { font-size:13px; font-weight:700; margin-bottom:4px; } +.tm-release svg { width:100%; overflow:visible; } +.tm-release svg text { font:12px Arial,Helvetica,sans-serif; fill:var(--ink); } +.tm-x-label { text-align:center; font-size:13px; margin-top:5px; } +.tm-series { display:flex; flex-wrap:wrap; gap:8px 26px; font-size:13px; margin-top:20px; } +.tm-series > span { display:flex; align-items:center; gap:8px; } +.tm-line { display:inline-block; width:24px; border-top:3px solid var(--royal); } +.tm-line.liquidity { border-color:var(--ink); border-top-style:dashed; } +.tm-line.vested { border-color:var(--red); } +.tm-plot-phone { display:none; } +@media(max-width:660px) { + .tm-module { padding:16px 14px; } + .tm-module > p:first-of-type { font-size:16px; } + .tm-table { font-size:14px; margin-bottom:26px; } + .tm-table thead { position:absolute; width:1px; height:1px; padding:0; overflow:hidden; clip-path:inset(50%); } + .tm-table,.tm-table tbody,.tm-table tr,.tm-table td,.tm-table tbody th { display:block; width:100%; } + .tm-table tr { border-top:2px solid var(--ink); padding:12px 0; } + .tm-table td,.tm-table tbody th { border:0; padding:4px 0; } + .tm-table td::before { content:attr(data-label); display:block; font-size:12px; font-weight:700; color:#485974; } + .tm-table tbody th:nth-child(1) { width:100%; font-size:20px; } + .tm-allocations tr { display:grid; grid-template-columns:1fr 1fr; gap:0 10px; } + .tm-allocations td:nth-child(2) { text-align:right; } + .tm-allocations td:nth-child(n+3) { grid-column:1 / -1; } + .tm-allocations td:nth-child(3) { font-weight:700; font-size:16px; } + .tm-supply-legend { grid-template-columns:1fr; font-size:13px; gap:10px; } + .tm-release { padding:12px 8px; } + .tm-release > figcaption { font-size:23px; } + .tm-plot-wide { display:none; } + .tm-plot-phone { display:block; } + .tm-series { flex-direction:column; font-size:12px; gap:10px; } + .tm-series > span { align-items:baseline; } + .tm-line { flex-shrink:0; } +} + +.tm-live dl { display:grid; grid-template-columns:1fr auto; gap:8px 20px; } +.tm-live dd { margin:0; } +.tm-addresses { display:grid; gap:12px; overflow-wrap:anywhere; } + +@media (min-width:661px) and (max-width:900px) { + .tm-table th,.tm-table td { padding-left:6px; padding-right:6px; } + .tm-allocations th:nth-child(1) { width:9%; } + .tm-allocations th:nth-child(2) { width:20%; } + .tm-allocations th:nth-child(3) { width:27%; } + .tm-allocations th:nth-child(4) { width:44%; } +} + +/* BASK worked examples: local Docs panel. */ +.bg-module { padding:28px; } +.bg-module > h2 { border-bottom:6px solid var(--sun); padding-bottom:12px; margin-bottom:22px; } +.bg-intro { font-size:18px; } +.bg-section { margin-top:34px; } +.bg-section > h3 { font-family:Flyer,Impact,sans-serif; text-transform:uppercase; font-size:29px; margin:0 0 16px; } +.bg-formula { padding:12px 16px; border-left:4px solid var(--royal); background:var(--subtle); font-weight:700; margin:8px 0; overflow-wrap:break-word; } +.bg-formula + p { margin-top:18px; } +.bg-examples { display:grid; gap:22px; margin-top:24px; } +.bg-deposits { grid-template-columns:repeat(2,minmax(0,1fr)); } +.bg-example { padding:20px; border:1px solid #c3ccdb; min-width:0; } +.bg-example h4 { font-size:20px; line-height:1.25; color:var(--royal); margin:0 0 16px; } +.bg-example > p:last-child { margin-bottom:0; } +.bg-table { width:100%; border-collapse:collapse; margin:22px 0; font-size:14px; } +.bg-table th,.bg-table td { padding:12px 10px; border-bottom:1px solid #c3ccdb; vertical-align:top; text-align:left; } +.bg-table thead th { background:var(--subtle); border-top:2px solid var(--ink); border-bottom:2px solid var(--ink); } +.bg-table tbody th { font-weight:500; } +.bg-table td:last-child { font-weight:700; color:var(--royal); } +.bg-deposits .bg-table td { min-width:105px; } +@media(max-width:900px) { .bg-deposits { grid-template-columns:1fr; } } +@media(max-width:660px) { + .bg-module { padding:16px 14px; } + .bg-intro { font-size:16px; } + .bg-section > h3 { font-size:26px; } + .bg-example { padding:14px 12px; } + .bg-formula { padding:10px 12px; } + .bg-table { font-size:14px; } + .bg-table th,.bg-table td { padding:10px 4px; } + .bg-deposits .bg-table td { min-width:0; } + .bg-table thead { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); } + .bg-table,.bg-table tbody,.bg-table tr,.bg-table td,.bg-table tbody th { display:block; width:100%; } + .bg-table tr { padding:10px 0; border-top:2px solid var(--ink); } + .bg-table td,.bg-table tbody th { border:0; padding:4px 0; } + .bg-table tbody th { font-weight:700; } + .bg-table td::before { content:attr(data-label); display:block; font-size:12px; color:#485974; font-weight:500; } + .docs-tabs button { padding-inline:14px; } +} +/* v14: wider than 960px the Deposit panel has two columns: the form, its + preview and buttons and the "Before you deposit" notes on the left, the + stock list always open on the right. Phones and tablets keep the one-column + layout above, with the list inside the form. */ +@media (min-width: 961px) { + .deposit-columns { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: 40px; + align-items: start; + } + .deposit-right .all-stocks ul { + grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); + } + .deposit-right .all-stocks.open-list { + margin-bottom: 0; + } + .deposit-notes { + margin-top: 28px; + padding-top: 20px; + border-top: 1px solid var(--line); + } + .deposit-notes h2 { + margin-top: 0; + } +} +/* v14: the Deposit panel body sits inside .deposit-main, so its direct + buttons (Retry balances) keep the spacing the panel gave them. */ +.deposit-main > button, +.deposit-main > form + button { + margin-top: 12px; +} +/* v14: the gauge table of the ITEM panel on the Docs page: plain cells that + never wrap; on a narrow panel the table scrolls inside its own box. */ +.item-table { + overflow-x: auto; + margin: 16px 0; +} +.item-gauges { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.item-gauges th, +.item-gauges td { + padding: 8px 8px; + text-align: left; + white-space: nowrap; + border-bottom: 1px solid #c3ccdb; +} +.item-gauges thead th { + border-bottom: 2px solid var(--ink); + font-size: 13px; +} +.item-gauges tbody th { + font-weight: 700; + color: var(--royal); +} diff --git a/web/src/tokenomicsData.ts b/web/src/tokenomicsData.ts new file mode 100644 index 0000000..cffef40 --- /dev/null +++ b/web/src/tokenomicsData.ts @@ -0,0 +1,84 @@ +import type { Address } from "viem"; + +export const TOTAL_SUPPLY = 1_000_000_000; +export const DAY = 86_400; +export const T = 4 * 365 * DAY; +export const CLIFF = 365 * DAY; +export const VESTING_END = 1460 * DAY; +export const ALLOCATIONS = [ + { + "share": 49, + "amount": 490000000, + "who": "BASK/USDG liquidity providers", + "terms": "Released by the gauge over four years, front loaded, only while someone is staked" + }, + { + "share": 21, + "amount": 210000000, + "who": "ITEM/ETH liquidity providers", + "terms": "Same schedule, same rules" + }, + { + "share": 10, + "amount": 100000000, + "who": "ITEM/ETH launch pool", + "terms": "Paired with ETH at launch; where ITEM first trades" + }, + { + "share": 10, + "amount": 100000000, + "who": "The IMD swarm that built and launched the contracts", + "terms": "Paid to IMD's members under IMD's own rules" + }, + { + "share": 5, + "amount": 50000000, + "who": "Operations", + "terms": "Seeding the two pools and the first stakes; held by the owner wallet" + }, + { + "share": 4, + "amount": 40000000, + "who": "Development, vested", + "terms": "Nothing for 12 months, 25% at month 12, the rest evenly to month 48; cannot be revoked or sped up" + }, + { + "share": 1, + "amount": 10000000, + "who": "Development, unlocked", + "terms": "Sent once at launch" + } +] as const; +export const PARAGRAPHS = [ + "ITEM's supply is 1,000,000,000, created once at launch. Nothing can mint more. It is a plain token: anyone can hold, send or trade it.", + "How the supply is divided:", + "What is available when. At launch, 26% of the supply exists outside the reward and vesting contracts: the launch pool, the swarm's share, operations and the unlocked development amount. The other 74% is held by contracts and comes out only on schedule: 70% through the two gauges, to the people staked in them, and 4% through vesting. If both gauges run without a pause, the most that can be outside those contracts is:", + "A gauge's clock only runs while someone is staked, so these are the earliest dates, not fixed ones.", + "Where value flows. ITEM pays the people who make BASK and ITEM tradable: liquidity providers earn ITEM from the gauges. ITEM holders who stake earn the vault's fees, 0.5% of every deposit and 0.5% of every redemption, paid in BASK. Trading fees in the two Uniswap v2 pools stay with the liquidity providers of those pools. Trades in the launch pool carry a 1.25% fee set by IMD: 1% to the protocol's operations wallet and 0.25% to IMD.", + "What cannot change. The split above, the four-year gauge schedule, the 7-day fee stream and the vesting dates are constants in contracts with no owner. Nobody holds a key that can alter them." +] as const; +export const ALLOCATION_HEADERS = ["Share","ITEM","Who","Terms"] as const; +export const RELEASE_HEADERS = ["End of","Released by the gauges","Vested","Outside the contracts, at most"] as const; +export const ADDRESSES: Record<"ITEM" | "ITEM_STAKING" | "BASK_USDG_GAUGE" | "ITEM_ETH_GAUGE" | "DEV_VESTING", Address | null> = { + ITEM: null, ITEM_STAKING: null, BASK_USDG_GAUGE: null, ITEM_ETH_GAUGE: null, DEV_VESTING: null, +}; +export type Addresses = typeof ADDRESSES; +export const GAUGE_ALLOCATION = ALLOCATIONS[0].amount + ALLOCATIONS[1].amount; +export const VESTED_ALLOCATION = ALLOCATIONS[5].amount; +export const OUTSIDE_AT_LAUNCH = TOTAL_SUPPLY - GAUGE_ALLOCATION - VESTED_ALLOCATION; +export const formatItem = (n: number) => Math.round(n).toLocaleString("en-US"); +export const gaugeReleased = (seconds: number, allocation = GAUGE_ALLOCATION) => { + const t = Math.max(0, Math.min(seconds, T)); + const fraction = t / T; + return allocation * fraction * (2 - fraction); +}; +export const developmentVested = (seconds: number) => seconds < CLIFF ? 0 : VESTED_ALLOCATION * Math.min(seconds, VESTING_END) / VESTING_END; +export const outsideContracts = (seconds: number) => OUTSIDE_AT_LAUNCH + gaugeReleased(seconds) + developmentVested(seconds); +export const RELEASES = [1, 2, 3, 4].map(year => { + const seconds = year * 365 * DAY; + return {year, gauges:gaugeReleased(seconds), vested:developmentVested(seconds), outside:outsideContracts(seconds), share:Math.round(outsideContracts(seconds) / TOTAL_SUPPLY * 1000) / 10}; +}); +export const ALLOCATION_ROWS = ALLOCATIONS.map(r => [r.share + "%", formatItem(r.amount), r.who, r.terms]); +export const RELEASE_ROWS = RELEASES.map(r => ["Year " + r.year, formatItem(r.gauges), formatItem(r.vested), formatItem(r.outside) + " (" + r.share + "%)"]); +export const SUPPLY_LABEL = ALLOCATIONS.map(r => r.who + " " + r.share + "%").join(", "); +export const RELEASE_LABEL = "Outside the contracts, at most: " + formatItem(OUTSIDE_AT_LAUNCH) + " at launch, " + RELEASES.map(r => formatItem(r.outside) + " at year " + r.year).join(", ") + ". Liquidity rewards released: " + formatItem(GAUGE_ALLOCATION) + " over four years. Development vested: 0 before month 12, " + formatItem(developmentVested(CLIFF)) + " at month 12, " + formatItem(VESTED_ALLOCATION) + " at month 48."; diff --git a/web/validation/README.md b/web/validation/README.md index 947b151..1c08a26 100644 --- a/web/validation/README.md +++ b/web/validation/README.md @@ -36,3 +36,17 @@ The v12 fork keeps all 74 earlier check positions and 70 page sends. Two owner-a The expanded fix suite drives actual Claims through first-read blank state, quiet empty/no-wallet lines, positive and unreadable balances, incomplete lists, retry, stable refreshes, running claims, errors and retained confirmations. React Activity preserves the account visit while hidden and pauses effects; account changes reset it. The art suite adds a portable widened-text case of at least 240px, with whole-word wrapping and pill/panel/character clearance, including a rejection of the former nowrap rule. Every existing assertion remains except its narrowly documented owner-approved replacement. The latest status decision replaces the obsolete art-bundle pin for “Deposits are open.” with the exact short closure sentence on Deposit, the plain Closed notice on Vault, and absence of normal status. The Closed-only Deposit LED sign’s named border-chase and letter-glow animations are an explicit owner-approved addition; marquee and shelf motion checks remain, and reduced motion stops both sign animations while retaining its readable state. The v13 baseline is the v12 commit 586998cf5eb2a6ccce05615ddf7f44874813042c. check-calldata permits only Flows and wallet, restored exactly through documented v13 pieces; Vault, components, Docs, main and every other source equal HEAD. check-bundle's source scope is Flows, wallet and styles. check-visibility compares the v12 six-route phone view at 375×812 and names one exception, the Deposit stock list shown open while the form is empty. The v13 fork keeps all 84 v12 check positions and 70 page sends, adds two checks (the open stock list in both empty states, with a press that keeps it open and the owner's wallet keeping the closed fold; the Claim all result line without the other-stocks count) and takes the same 24 screenshots. + +## Tokenomics validation + +The transaction gate now freezes every existing transaction source byte-for-byte against the v13 base. Docs is restored through approved pieces and independently SHA-pinned, as are both new source files; regeneration cannot bless edited copy, allocations, a removed null guard or an injected sink. The complete previous differential encoding/refusal corpus and six-clock send-builder assertions remain. CSS is append-only. + +`check-tokenomics.tsx` parses the brief's seven allocation rows and gauge/vesting declarations, proves totals, yearly release percentages and outside-supply rows, and exercises each single-null configuration plus a mocked configured reader. `check-tokenomics-live.mjs` mounts the real React component with mocked reads to verify hidden null states, successful figures, failure and Retry with no external request. The fork walk invokes `tokenomics-browser.mjs` for exact approved text, table data, keyboard tabs, chart labels, no overlap/overflow at 320/375/1440/1920, and zero added RPC/wallet calls. Visibility uses the v13 baseline and permits only the selected local tab row and two exact Docs copy edits; every other page remains unchanged. + +## Combined documentation checks + +BASK adds `check-bask-guide.tsx` for independently audited arithmetic expectations and exact canonical prose, formulas and table cells, plus `bask-guide-browser.mjs` in the existing fork walkthrough. The latter checks three-tab linkage, focus and keyboard wrap, hidden panels, four examples and containment at 320/375/661/1000/1440/1920. The walkthrough retains its historical send/ordered-check comparison and adds a zero-storage-write assertion around visits to all local Docs tabs. Existing ITEM numeric, null and mounted configured-live checks remain. + +The expected values follow `web/pinned/src/BaskVault.sol`: `_value`, `_depositShares`, `_fee`, `redeem`, `_available` and `previewRedeem`. Fee ceil, mint floor, first lock, reserved debt, balance deficit and unreadable fallback cases are independently tested. Static example helpers are not a full contract preview emulator, including the zero-supply/zero-redemption boundary. No real wallet or ITEM chain read is required. + +The v14 baseline is the v13 commit 2b7d55bc32d09610fed936d963d9a8c492f4eecf. check-calldata permits Docs.tsx and Flows.tsx through documented v14 pieces and the four module sources through reviewed digests; wallet.tsx and every other source equal HEAD. check-bundle's source scope adds Flows.tsx to the module's. check-visibility compares the v13 six-route phone view at 375×812, matching Docs panels by title around the five inserted ITEM panels; the Deposit phone layout is unchanged. The v14 fork keeps all 86 v13 check positions and 70 page sends, replaces the words of three Deposit checks for the desktop two-column layout, adds the Tokenomics and BASK checks and one Docs check for the five ITEM panels, and takes the same 24 screenshots at JPEG quality 18 (26 before) so that the longer Docs page keeps the delivery under 8,388,608 bytes. check-bask-guide proves the arbitrage flywheel example through the audited helpers and counts five examples, five tables and ten formula lines; check-tokenomics and check-tokenomics-live are unchanged.