diff --git a/README.md b/README.md index 4429f15..8d02a71 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,12 @@ What the checks allow in this update: - check-art keeps every earlier assertion (whole bubble words, the bubble off her face, hands and the crate and off every opaque picture pixel, the strip clear of the text, no taller Vault title) at every width from 320 to 2560px in Chromium and WebKit, and adds: the Vault phone layout at every phone width; the live v14 title beside it (nothing in a title moves above 660px; on phones the Deposit and Redeem titles equal v14 and the Vault title differs only sideways as decided), with the v15 check rejecting the v14 look; and the focus marker at 660, 430, 393, 375, 360, 359 and 320px (the line on the marker's bottom band up to its outer edge, the v14 line put back is rejected, the line returns after blur, no shelf line at 661px). - The fork walk is unchanged in what it checks: all 91 v14 checks under the same names in the same order, none added, the same 70 page sends, the same 24 screenshot names at JPEG quality 18. check-motion, check-fixes, inspect-export, check-guards, check-preservation, check-interface, check-tokenomics, check-tokenomics-live and check-bask-guide are unchanged. +## Site update v22 + +The current patch is applied to v20, commit f86e5d80e53174c37f5cd4e14cf6df453ffa33c6. Three fixes from an independent audit of the live site, in three sources. wallet.tsx: every wallet send except genesis listing first settles the transaction saved in this tab (a receipt, or its nonce passed by the node's record of the hash, or the count saved before the prompt passed while nothing is queued); while it is unresolved the press is refused with the existing "may still be pending" words and its link, and once it is found mined or replaced the record goes, the vault is read again and that press is refused once with "The earlier transaction is finished (confirmed or replaced). Refresh the vault before retrying."; the listing flow is unchanged. Flows.tsx: the Redeem press compares the fresh estimate with the preview on screen (the fee, the leg count, every leg within 0.1%) and builds the minimums from the previewed amounts, refusing with "Redemption changed. Preview again." otherwise; the Deposit press re-reads previewDeposit and refuses with "Deposit changed. Preview again." when the fee changed. Owner.tsx: the roles panel shows the current fee recipient (None while there is none). No other word, picture or layout changes; the built CSS is byte-identical to v20. + +What the checks allow in this update: check-calldata documents the three files by generated pieces, reviewed digests, the written-out guarded calls, uses, storage use and module names, the send() head and tail pieces, the Flows.tsx attribute and raw pieces, and pins the new behaviour (the exact sendPending() text and its call before verifyNetwork, the pending branch, the two exact getSpec attribute texts, the roles row, the two constants); every other source is frozen; check-bundle's source scope is the three files; run-browser.mjs gains four checks (the lock after a reload and after the nonce passed; the Redeem refusal on a shortfall; the Deposit refusal on a changed fee; the roles row before and after the FeeRecipient execute) and corrects its own Docs panel list, stale since v19; check-fixes.mjs meets a non-listing send in each of its six saved-record modes. + ## Site update v20 The current patch is applied to v19, commit 8f88184b6dbcfd2c11f7a762c4c25cce55d8d230. It changes no source, word, picture or layout: the built site is byte-identical to v19. It repairs two browser checks. check-art and check-motion waited for a route to be shown by looking for its current-page navigation link (`a[aria-current="page"][href="#"]`); since v19 nothing links to the Losses page with that mark (the footer link went; the short-stock notice and the Docs panel link to it), so both checks timed out on #losses. They now count the Losses route as shown once its own heading "Losses" is the page title's; every other route still waits for its current navigation link, and nothing they assert changed. diff --git a/web/scripts/check-bundle.mjs b/web/scripts/check-bundle.mjs index 51bd876..0a8ebed 100644 --- a/web/scripts/check-bundle.mjs +++ b/web/scripts/check-bundle.mjs @@ -8,7 +8,7 @@ const git=args=>execFileSync('git',args,{cwd:root,encoding:'utf8'}); const changed=git(['diff','--name-only','HEAD']).trim().split('\n').filter(Boolean); // v20 (two browser checks re-scoped for the Losses route, 2026-10-16): no source may change. // check-calldata independently pins each reviewed source and freezes every other. -const v20Source=[]; +const v22Source=["web/src/wallet.tsx","web/src/Flows.tsx","web/src/Owner.tsx"]; const SHELL_SHA256='82f66899a0c19f61299254c0005ee5117af0a912b97e8e57d249288e9dbc962d'; const SHARE_SHA256='dc14dd6f4068ec9325426583104b1c6ae8748fbb17d8326863c9612280eacd85'; // The new favicon: red panel, yellow frame, the header's basket in yellow. @@ -23,7 +23,7 @@ for(const p of changed){ // v12 section 5: the exact page shell is pinned below; build configuration and validation fixtures stay unchanged. assert.ok(!['web/vite.config.ts','web/tsconfig.json'].includes(p)&&(!p.startsWith('web/validation/')||p==='web/validation/README.md'),'Page shell, build configuration or validation fixture changed: '+p); assert.ok(!/(^|\/)(lib|node_modules|\.github|\.git)(\/|$)|(^|\/)\.env(?:\.|$)|(^|\/)(foundry\.toml|foundry\.lock|remappings\.txt|\.gitmodules|package\.json|[^/]*lock[^/]*)$/.test(p),'Protected path changed: '+p); - if(p.startsWith('web/src/'))assert.ok(v20Source.includes(p),'v20 changes no source, but this changed: '+p); + if(p.startsWith('web/src/'))assert.ok(v22Source.includes(p),'Only the three v22 sources may change: '+p); assert.ok(!p.startsWith('src/'),'Root contract source changed'); } // v20: no new source and no new public asset. diff --git a/web/scripts/check-calldata.mjs b/web/scripts/check-calldata.mjs index afba253..e0424bb 100644 --- a/web/scripts/check-calldata.mjs +++ b/web/scripts/check-calldata.mjs @@ -11,22 +11,23 @@ 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'); -// v20 on HEAD=v19: no source changes. The update re-scopes two browser checks (check-art, check-motion) whose wait for -// a route needed a current-page navigation link, which the Losses page no longer has since v19, and returns this -// gate, check-bundle and check-visibility to "equal to HEAD": v19's documented changes in main.tsx and Docs.tsx are -// HEAD's own now. Every web/src file is byte-identical to HEAD, styles.css included. Transactions remain HEAD's: -// send(), connect(), disconnect() and switchChain() keep their pins. +// v22 on HEAD=v20 (f86e5d8): three sources change, the live-site audit fixes (character/v22/SPEC.md). wallet.tsx: every +// send except genesis listing first settles the saved transaction of this tab (sendPending: a receipt, or its nonce +// passed; refused with its hash while unresolved; refused once with the finished words after); its call is the one +// documented piece in send()'s head and the PendingTransaction branch the one in its tail. Flows.tsx: the Redeem press +// compares the fresh previewRedeem (fee, leg count, every leg within 0.1%) with the preview on screen and builds the +// floors from quote.amounts; the Deposit press re-reads previewDeposit and refuses when the fee changed. Owner.tsx: +// a fourth roles row shows the current feeRecipient. Every other web/src file is byte-identical to HEAD, styles.css +// included. connect(), disconnect(), switchChain(), listingPending() and waitPending() keep their pins; send()'s head +// and tail equal HEAD once their documented pieces are put back. const protectedFiles=[ "BaskGuide.tsx", - "Flows.tsx", "Losses.tsx", - "Owner.tsx", "Scenery.tsx", "Vault.tsx", "components.tsx", "Tokenomics.tsx", "tokenomicsData.ts", - "wallet.tsx", "main.tsx", "Docs.tsx", "baskGuideData.ts", @@ -66,14 +67,41 @@ assert.equal(sha256(fs.readFileSync(path.join(root,'web/src/styles.css')).subarr // v16: no style rule after HEAD's text (the v15 rules are HEAD's own now). const cssTail=fs.readFileSync(path.join(root,'web/src/styles.css')).subarray(Buffer.byteLength(baseline('web/src/styles.css'))).toString('utf8'); assert.equal(cssTail,'','v16: no style rule after the text of HEAD'); -// The v20 files (none: v20 changes no source): byte-identical to HEAD once their documented pieces [v20, HEAD] are put back (each exactly once), so +// The v22 files (wallet.tsx, Flows.tsx, Owner.tsx): byte-identical to HEAD once their documented pieces [v22, 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. -// >>> v20 pieces (generated from the difference to HEAD, not edited by hand) -const editedFiles={}; -// <<< v20 pieces -assert.deepEqual(Object.keys(editedFiles),[],'v20 edits no source'); +// >>> v22 pieces (generated from the difference to HEAD, not edited by hand) +const editedFiles={ + "wallet.tsx":[ + [" \"The earlier transaction is finished (confirmed or replaced). Press Resume listing.\";\n// v22 (2026-10-17, live-site review): every send except genesis listing first\n// settles the saved transaction of this tab. While it has no receipt and its\n// nonce has not passed, the press is refused with its hash and these words.\nexport const PENDING_WAIT =\n \"Receipt timed out or unreadable. This transaction may still be pending. Wait for its receipt before retrying.\";\n// Once it is found mined or replaced, the record goes, the vault is read again\n// and that press is refused once, so the result is seen before any retry.\nexport const PENDING_FINISHED =\n \"The earlier transaction is finished (confirmed or replaced). Refresh the vault before retrying.\";\nclass PendingTransaction extends Error {\n constructor(\n message: string,\n public hash?: Hex,\n ) {\n super(message);\n }\n}\ntype PendingTx = {\n", + " \"The earlier transaction is finished (confirmed or replaced). Press Resume listing.\";\ntype PendingTx = {\n"], + [" }\n // v22: settles the saved transaction of this tab before a send, whichever\n // account sent it (listGenesis keeps listingPending and its Resume buttons).\n // Mined or replaced: a receipt, the nonce the node reports for the hash\n // passed, or the count saved before the prompt passed while nothing is\n // queued (listingPending's second guard). Fail closed: only the node's \"not\n // found\" answer to a hash lookup (viem's TransactionReceiptNotFoundError or\n // TransactionNotFoundError) counts as absent; any other error of the two\n // lookups refuses the press with the pending words and the hash, and an\n // error of either count read propagates to send()'s catch; the record stays.\n async function sendPending(key: string) {\n const pending = pendingRef.current;\n if (!pending) return;\n const absent = (name: string) => (e: unknown) => {\n if ((e as { name?: string } | null)?.name === name) return undefined;\n throw new PendingTransaction(PENDING_WAIT, pending.hash);\n };\n let finished = !!(await client\n .getTransactionReceipt({ hash: pending.hash })\n .catch(absent(\"TransactionReceiptNotFoundError\")));\n if (!finished) {\n const [transaction, latest, queued] = await Promise.all([\n client\n .getTransaction({ hash: pending.hash })\n .catch(absent(\"TransactionNotFoundError\")),\n client.getTransactionCount({\n address: pending.account,\n blockTag: \"latest\",\n }),\n client.getTransactionCount({\n address: pending.account,\n blockTag: \"pending\",\n }),\n ]);\n finished =\n transaction !== undefined\n ? latest > transaction.nonce\n : pending.nonce !== undefined &&\n latest > pending.nonce &&\n latest === queued;\n }\n if (!finished) throw new PendingTransaction(PENDING_WAIT, pending.hash);\n pendingRef.current = undefined;\n try {\n sessionStorage.removeItem(pendingKey);\n } catch {\n /* storage unavailable */\n }\n void refresh().catch(() => {});\n throw new PendingTransaction(PENDING_FINISHED, pending.hash);\n }\n const [account, setAccount] = useState
();\n", + " }\n const [account, setAccount] = useState
();\n"], + [" if (s.functionName === \"listGenesis\") await listingPending();\n else await sendPending(key);\n await verifyNetwork();\n", + " if (s.functionName === \"listGenesis\") await listingPending();\n await verifyNetwork();\n"], + [" }\n if (e instanceof PendingTransaction) {\n report(key, { message: e.message, hash: e.hash });\n throw new InputError(e.message);\n }\n const message = await explainAction(e, s);\n", + " }\n const message = await explainAction(e, s);\n"] + ], + "Flows.tsx":[ + [" }\n // v22: the fee shown must be the fee charged; a fee that\n // changed since the preview needs a new preview.\n const [, fee] = await read(\n vault(\"previewDeposit\", [quote.tokens, quote.amounts]),\n );\n if (fee !== quote.fee)\n throw new InputError(\"Deposit changed. Preview again.\");\n if (revision !== sequence.current)\n", + " }\n if (revision !== sequence.current)\n"], + [" throw new InputError(\"Your BASK balance is too low.\");\n const [amounts, fee] = await read(\n vault(\"previewRedeem\", [quote.shares]),\n", + " throw new InputError(\"Your BASK balance is too low.\");\n const [amounts] = await read(\n vault(\"previewRedeem\", [quote.shares]),\n"], + [" throw new InputError(\"Redemption changed. Preview again.\");\n // v22: the minimums are 0.1% under the preview on screen; a\n // fresh estimate under them, or another fee, needs a new one.\n if (\n fee !== quote.fee ||\n amounts.length !== quote.amounts.length ||\n amounts.some(\n (a: bigint, i: number) =>\n a < (quote.amounts[i] * 999n) / 1000n,\n )\n )\n throw new InputError(\"Redemption changed. Preview again.\");\n return vault(\n \"redeem\",\n redeemArgs(quote.shares, receiver, quote.amounts),\n );\n", + " throw new InputError(\"Redemption changed. Preview again.\");\n return vault(\n \"redeem\",\n redeemArgs(quote.shares, receiver, amounts),\n );\n"] + ], + "Owner.tsx":[ + [" \n
\n Fee recipient\n {s.loading ? (\n \"Reading...\"\n ) : s.globals.feeRecipient === undefined ? (\n unreadable\n ) : same(s.globals.feeRecipient, zeroAddress) ? (\n None\n ) : (\n \n )}\n
\n \n", + " \n \n"] + ] +}; +// <<< v22 pieces +assert.deepEqual(Object.keys(editedFiles),[ + "wallet.tsx", + "Flows.tsx", + "Owner.tsx" +],'the v22 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. @@ -127,17 +155,67 @@ 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 (v18's addToWallet() calls included). v20 adds no guarded call, wallet request or chain read. -const expected={}; +// already part of HEAD (v18's addToWallet() calls included). v22's added guarded calls (the settle's four client reads, the Deposit press's previewDeposit read and its encoder, the Redeem floors built from quote.amounts) are written out below, each in its place. +const expected={ + "wallet.tsx": { + "removed": [], + "added": [ + [ + "client .getTransactionReceipt({ hash: pending.hash })", + "useWallet > sendPending" + ], + [ + "client .getTransaction({ hash: pending.hash })", + "useWallet > sendPending" + ], + [ + "client.getTransactionCount({ address: pending.account, blockTag: \"latest\", })", + "useWallet > sendPending" + ], + [ + "client.getTransactionCount({ address: pending.account, blockTag: \"pending\", })", + "useWallet > sendPending" + ] + ] + }, + "Flows.tsx": { + "removed": [ + "vault(\"redeem\", redeemArgs(quote.shares, receiver, amounts))", + "redeemArgs(quote.shares, receiver, amounts)" + ], + "added": [ + [ + "read(vault(\"previewDeposit\", [quote.tokens, quote.amounts]))", + "DepositPage, in getSpec" + ], + [ + "vault(\"previewDeposit\", [quote.tokens, quote.amounts])", + "DepositPage, in getSpec" + ], + [ + "vault(\"redeem\", redeemArgs(quote.shares, receiver, quote.amounts))", + "RedeemPage, in getSpec" + ], + [ + "redeemArgs(quote.shares, receiver, quote.amounts)", + "RedeemPage, in getSpec" + ] + ] + } +}; // Action attributes: the exact differences from HEAD, in page order, each [element, attribute, why]. Everything else // equals HEAD in order, text for text. None of them builds or starts a transaction (no onSubmit, getSpec, build or // claim) and none contains a guarded call (both asserted below). The list between the marker lines was generated // from the difference to HEAD, as the pieces were; each why was written by hand, and an entry without one fails. -// >>> v20 attributes (generated from the difference to HEAD; each why written by hand) +// >>> v22 attributes (generated from the difference to HEAD; each why written by hand) const attributeChanges={ - + "Owner.tsx":{ + removed:[], + added:[ + ["Addr","value={s.globals.feeRecipient}", + "v22, the owner (2026-10-17, live-site review, Codex F6): the Owner page's roles panel gains a fourth row, Fee recipient, showing the vault's current feeRecipient() from the snapshot's globals (already read since v12), None while it is zero, unreadable when the read failed; a display-only Addr, no read, check or send."]]} }; -// <<< v20 attributes +// <<< v22 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. @@ -150,8 +228,66 @@ const attributeChanges={ // The guarded startup eth_accounts answer is part of HEAD and must remain exact. const restoreAccountReply=(x)=>x.text; // Reviewed v16 differences in calls and watched-name uses. Independent of the generated pieces. -// v20: no call of HEAD is removed and no watched name gains a use. -const callChanges={}; +// v22: the Redeem floors call changes its argument (a documented removed call and its replacement); the new uses of watched names (client in sendPending, read and vault in the Deposit press, quote.amounts in the Redeem floors) are written out below. +const callChanges={ + "wallet.tsx": { + "why": "v22: sendPending reads the saved record and the public client (receipt, transaction, two nonce counts) to settle it; nothing else of HEAD moves.", + "pieces": [], + "removed": [], + "uses": [ + [ + "pendingRef.current", + "useWallet > sendPending" + ], + [ + "client .getTransactionReceipt({ hash: pending.hash }) .catch(absent(\"TransactionReceiptNotFoundError\"))", + "useWallet > sendPending" + ], + [ + "client .getTransaction({ hash: pending.hash }) .catch(absent(\"TransactionNotFoundError\"))", + "useWallet > sendPending" + ], + [ + "client.getTransactionCount({ address: pending.account, blockTag: \"latest\", })", + "useWallet > sendPending" + ], + [ + "client.getTransactionCount({ address: pending.account, blockTag: \"pending\", })", + "useWallet > sendPending" + ], + [ + "pendingRef.current", + "useWallet > sendPending" + ] + ] + }, + "Flows.tsx": { + "why": "v22: the Redeem floors are built from the preview on screen (quote.amounts) after the fresh estimate was compared with it; the Deposit press re-reads previewDeposit for its fee.", + "pieces": [], + "removed": [ + "vault(\"redeem\", redeemArgs(quote.shares, receiver, amounts))", + "redeemArgs(quote.shares, receiver, amounts)" + ], + "uses": [ + [ + "read(vault(\"previewDeposit\", [quote.tokens, quote.amounts]))", + "DepositPage, in getSpec" + ], + [ + "vault(\"previewDeposit\", [quote.tokens, quote.amounts])", + "DepositPage, in getSpec" + ], + [ + "vault(\"redeem\", redeemArgs(quote.shares, receiver, quote.amounts))", + "RedeemPage, in getSpec" + ], + [ + "redeemArgs(quote.shares, receiver, quote.amounts)", + "RedeemPage, in getSpec" + ] + ] + } +}; const WATCHED=[...names].filter(x=>!x.includes('.')).concat('client'); // The state setters and refs a file declares: const [x, setX] = useState(...) gives setX, const r = useRef(...) gives r. function stateNames(text,file){ @@ -202,7 +338,16 @@ function localDeclarations(file,text,fn,list){ const sources=git(['ls-tree','-r','--name-only','HEAD','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)); assert.deepEqual(git(['ls-files','--cached','--others','--exclude-standard','web/src']).trim().split('\n').filter(p=>/\.tsx?$/.test(p)).map(p=>p.slice(8)).sort(),[...sources,...NEW_SOURCES].sort(),'No source file added or removed'); // Every source is either frozen (byte-identical to HEAD) or a v16 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 (v20 edits none)'); +assert.deepEqual([...sources].sort(),[...protectedFiles,...Object.keys(editedFiles)].filter(f=>/\.tsx?$/.test(f)).sort(),'every web/src source is frozen or a documented v22 file'); +// v22: the attributes that build a transaction (the Deposit and Redeem getSpec) carry the documented Flows.tsx pieces; +// they are compared with HEAD once those pieces, printed as the scan prints them, are put back. Each printed piece must +// be used exactly once over all attributes of the file. No other attribute carries a piece. +const attributePieces={"Flows.tsx":[ + ['} const [, fee] = await read(vault("previewDeposit", [quote.tokens, quote.amounts])); if (fee !== quote.fee) throw new InputError("Deposit changed. Preview again."); if (revision !== sequence.current)','} if (revision !== sequence.current)'], + ['const [amounts, fee] = await read(vault("previewRedeem", [quote.shares])); if (revision !== seq.current) throw new InputError("Redemption changed. Preview again."); if (fee !== quote.fee || amounts.length !== quote.amounts.length || amounts.some((a: bigint, i: number) => a < (quote.amounts[i] * 999n) / 1000n)) throw new InputError("Redemption changed. Preview again."); return vault("redeem", redeemArgs(quote.shares, receiver, quote.amounts));','const [amounts] = await read(vault("previewRedeem", [quote.shares])); if (revision !== seq.current) throw new InputError("Redemption changed. Preview again."); return vault("redeem", redeemArgs(quote.shares, receiver, amounts));'], +]}; +const attributePieceUses={}; +const restoreAttribute=(f,a)=>{if(!TX_ATTRIBUTES.has(a.name))return a.text;let t=a.text;(attributePieces[f]??[]).forEach(([n,h],i)=>{const parts=t.split(n);assert.ok(parts.length<=2,f+': an attribute piece occurs twice in one attribute: '+n);if(parts.length===2){(attributePieceUses[f]??=[])[i]=(attributePieceUses[f]?.[i]??0)+1;t=parts.join(h);}});return t;}; 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); @@ -211,7 +356,7 @@ for(const f of sources){ const restored=now.all.map(x=>restore(x.text)); const watched=new Set([...WATCHED,...stateNames(baseline('web/src/'+f),f)]),usesOld=usesOf(baseline('web/src/'+f),f,watched),usesNow=usesOf(read('web/src/'+f),f,watched),uses=difference(usesOld.map(x=>x.text+' @ '+x.place),usesNow.map(x=>restore(x.text)+' @ '+x.place)); // Two calls are the same when their text and their place are; two attributes when their element and their text are. - const attributes=difference(old.attributes.map(a=>a.tag+' '+a.text),now.attributes.map(a=>a.tag+' '+a.text)),guarded=difference(old.found.map(x=>x.text+' @ '+x.place),now.found.map(x=>restoreAccountReply(x,f)+' @ '+x.place)),all=difference(old.all.map(x=>x.text+' @ '+x.place),restored.map((x,i)=>x+' @ '+now.all[i].place)); + const attributes=difference(old.attributes.map(a=>a.tag+' '+a.text),now.attributes.map(a=>a.tag+' '+restoreAttribute(f,a))),guarded=difference(old.found.map(x=>x.text+' @ '+x.place),now.found.map(x=>restoreAccountReply(x,f)+' @ '+x.place)),all=difference(old.all.map(x=>x.text+' @ '+x.place),restored.map((x,i)=>x+' @ '+now.all[i].place)); scans[f]={old,now,c,used,attributes,guarded,all,usesOld,usesNow,uses}; if(attributes.removed.length+attributes.added.length)facts.attributes[f]={removed:attributes.removed.map(i=>[old.attributes[i].tag,old.attributes[i].text]),added:attributes.added.map(i=>[now.attributes[i].tag,now.attributes[i].text])}; if(guarded.removed.length+guarded.added.length)facts.guarded[f]={removed:guarded.removed.map(i=>old.found[i].text),added:guarded.added.map(i=>[now.found[i].text,now.found[i].place])}; @@ -221,18 +366,33 @@ for(const f of sources){ // names a file, the differences computed from this tree are written to it before they are checked. Nothing is // skipped or loosened by it. if(process.env.BASKET_GATE_FACTS)fs.writeFileSync(process.env.BASKET_GATE_FACTS,JSON.stringify(facts,null,1)+'\n'); +for(const [f,pieces] of Object.entries(attributePieces))pieces.forEach((p,i)=>assert.equal(attributePieceUses[f]?.[i]??0,1,f+': the attribute piece is used exactly once over its attributes: '+p[0].slice(0,80))); // Hand-reviewed complete ordered additions. SHA-256 binds JSON.stringify([[full printed call, place], ...]); the // ordered callee names make the list readable without repeating large nested JSX bodies. No regeneration block // includes these pins. Changing a callback, receiver, argument, location, order or count changes the digest. // >>> reviewed calls (manual; never refreshed by put-pieces) -const addedCallPins={}; +const addedCallPins={ + "wallet.tsx": { + "callees": "(expression) catch getTransactionReceipt absent all catch getTransaction absent getTransactionCount getTransactionCount removeItem catch refresh sendPending report", + "sha256": "88c506b1a61cee524cb639339aa5544bddcd6d2b1ade989b4a576c7030403479" + }, + "Flows.tsx": { + "callees": "read vault some vault redeemArgs", + "sha256": "cfcc36196b942db1408478afc506239e72adc2bfd2b2d0272dbb48787550ac66" + }, + "Owner.tsx": { + "callees": "same", + "sha256": "003598b1cc1bd05f81547de19ff078e021d708fd8b5a8e19706c79fe8e82f436" + } +}; // <<< reviewed calls // The exact reviewed source of the two v14 files (frozen now) and of the two v16 files, beyond either generated block. const reviewedSourcePins={ "Docs.tsx": "4a017ccbabc92c33df85c32d34b9f4c2ad94ae8988b50c82eb1115141e413343", "main.tsx": "acbfbe99686e824d26720805002343722cc16c8bcfff75a095c1774bcac94e80", - "wallet.tsx": "cb08bb0214a1b581a85da98c9974b5edb0b340e5c66e01bcdd4239149c7d6a91", - "Flows.tsx": "b9452b067fabd93c4ce9718d19e4c70906263068bcb81589b2e151beb411c309", + "wallet.tsx": "550eb23f8c890ee228c218a6e15ffa6e8c1f2f0a3da2aef7c17200103fffcd67", + "Flows.tsx": "e8f727507de361e7c3f074b33d855867273083aa900f6c0132709c4c8a4246db", + "Owner.tsx": "9f7b51477f4b3ea2410cad43073ac75c4c2ee3b17085fcf97797dade3942fd65", "Tokenomics.tsx": "6d57c2f9d8629f7207368b6d00618da9351e72aba0de52fcff225490be47c85a", "tokenomicsData.ts": "8e2cfc45c8055f2dbfdc7a7610e57856809845d52dd103712dd8398f3b11a99a" }; @@ -325,9 +485,9 @@ for(const f of sources){ assert.deepEqual(list[i].guarded,[],f+' '+list[i].where+': a guarded call inside an added or removed attribute: '+list[i].text); } // The attributes that build or start a transaction, spelled out: the same texts as at HEAD, in order. - assert.deepEqual(now.attributes.filter(x=>TX_ATTRIBUTES.has(x.name)).map(x=>x.text),old.attributes.filter(x=>TX_ATTRIBUTES.has(x.name)).map(x=>x.text),f+': an onSubmit, getSpec, build or claim attribute differs from HEAD'); + assert.deepEqual(now.attributes.filter(x=>TX_ATTRIBUTES.has(x.name)).map(x=>restoreAttribute(f,x)),old.attributes.filter(x=>TX_ATTRIBUTES.has(x.name)).map(x=>x.text),f+': an onSubmit, getSpec, build or claim attribute differs from HEAD beyond the documented v22 pieces'); // No guarded call moved into or out of an attribute: inside the attributes they are HEAD's, in order. - assert.deepEqual(now.attributes.flatMap(x=>x.guarded),old.attributes.flatMap(x=>x.guarded),f+': the guarded calls inside action attributes differ from HEAD'); + assert.deepEqual(now.attributes.flatMap(x=>x.guarded).filter(t=>!e.added.some(a=>a[0]===t)),old.attributes.flatMap(x=>x.guarded).filter(t=>!e.removed.includes(t)),f+': the guarded calls inside action attributes differ from HEAD beyond the documented ones'); // Every call expression (see callChanges). assert.deepEqual(all.removed.map(i=>old.all[i].text),c.removed,f+': a call of HEAD is gone or moved beyond the documented ones'); c.pieces.forEach(([n],i)=>assert.ok(used[i]>0,f+': the call piece is in no call: '+n)); @@ -368,7 +528,7 @@ function sendsOf(text,file){ })(tree); return out; } -// v20 adds no provider use: every file's list equals HEAD's. +// v22 adds no provider use: every file's list equals HEAD's (sendPending reads the chain through the public client only). const sendAdded={}; const NO_SEND=['Vault.tsx','Scenery.tsx','main.tsx','Docs.tsx'],sendUses={}; for(const f of sources){ @@ -410,8 +570,7 @@ function loadsOf(text,file){ })(tree); return out; } -// v20 adds, removes and moves nothing a page loads or opens: every file's list equals HEAD's (v19's Losses link move -// and Docs link included), in order and in place. +// v22 adds, removes and moves nothing a page loads or opens: every file's list equals HEAD's, in order and in place. const loadDisplayAdded={}; const loadDisplayRemoved={}; const loadUses={}; @@ -429,7 +588,7 @@ for(const f of sources){ } // v12: the wallet and RPC methods the pages name. Every `method` given as a property (or assigned) in web/src, in order // with its place: exactly HEAD's list. Local-only Disconnect names no new wallet or RPC method. -// v20 names no new wallet or RPC method in any file. +// v22 names no new wallet or RPC method in any file. const methodsAdded={}; function methodsOf(text,file){ const tree=parse(text,file),print=printerOf(tree),out=[],key=n=>ts.isIdentifier(n)||ts.isStringLiteralLike(n)?n.text:ts.isComputedPropertyName(n)?lit(n.expression):undefined; @@ -461,7 +620,7 @@ function storageOf(text,file){ (function v(n){if(ts.isIdentifier(n)&&STORE.has(n.text))out.push({text:print(top(n)),place:place(n,tree)});ts.forEachChild(n,v);})(tree); return out; } -const storageAdded={}; +const storageAdded={"wallet.tsx":[["sessionStorage.removeItem(pendingKey)","useWallet > sendPending"]]}; for(const f of sources){ const h=storageOf(baseline('web/src/'+f),f),n=storageOf(read('web/src/'+f),f),d=difference(h.map(x=>x.text+' @ '+x.place),n.map(x=>x.text+' @ '+x.place)); assert.deepEqual({removed:d.removed.map(i=>h[i].text),added:d.added.map(i=>[n[i].text,n[i].place])},{removed:[],added:storageAdded[f]??[]},f+': browser storage is used differently from HEAD beyond the documented flag'); @@ -493,13 +652,19 @@ function sendParts(text){ return {head:text.slice(f.node.getStart(f.tree),wait[0].end),tail:text.slice(wait[0].end,f.node.end)}; } const headSend=sendParts(baseline('web/src/wallet.tsx')),nowSend=sendParts(read('web/src/wallet.tsx')); -assert.equal(nowSend.head,headSend.head,'wallet.tsx send(): byte-identical head through receipt wait'); -// v16 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. +// v22: one documented piece in the head (the settle of the saved transaction before verifyNetwork, for every send +// except genesis listing); the head equals HEAD byte for byte once it is put back. +const SEND_HEAD_PIECES=[ + [' if (s.functionName === "listGenesis") await listingPending();\n else await sendPending(key);\n',' if (s.functionName === "listGenesis") await listingPending();\n'], +]; +assert.equal(putBack(nowSend.head,SEND_HEAD_PIECES,'wallet.tsx send() head'),headSend.head,'wallet.tsx send(): head byte-identical to HEAD once the documented piece is put back'); +// v22: one documented piece in the tail (the PendingTransaction branch: the message with its hash, then the refusal); +// the mined-hash statements are pinned by sendTailWrites below as well. const SEND_TAIL_PIECES=[ + [' }\n if (e instanceof PendingTransaction) {\n report(key, { message: e.message, hash: e.hash });\n throw new InputError(e.message);\n }\n const message = await explainAction(e, s);',' }\n const message = await explainAction(e, s);'], ]; 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}))}; +pinnedFunctions['wallet.tsx send()']={headPieces:SEND_HEAD_PIECES.map(([now,head])=>({now,head})),headBytes:Buffer.byteLength(nowSend.head,'utf8'),headSha256:sha256(nowSend.head),tailPieces:SEND_TAIL_PIECES.map(([now,head])=>({now,head}))}; // An explicit ordered pin of the changed transaction-status statements, independent of the piece list. const sendTailWrites=[]; {const tree=parse(read('web/src/wallet.tsx'),'wallet.tsx'),print=printerOf(tree); @@ -546,7 +711,7 @@ function hookReturn(text){ assert.equal(returns.length,1,'wallet.tsx useWallet: one return'); return print(returns[0]); } -// v20: the hook's return list is HEAD's (with addToWallet after switchChain since v18), pinned here printed without +// v22: the hook's return list is HEAD's (sendPending is called inside send() only and is not returned), pinned here printed without // comments; nothing in the list may change. const HOOK_RETURN_HEAD="return { account, connecting, progress, report, fail, notice, listingPending, waitPending, chainId, message, hash, busy, confirmed, connect, disconnect, switchChain, addToWallet, send, setMessage, };"; assert.equal(hookReturn(baseline('web/src/wallet.tsx')),HOOK_RETURN_HEAD,'wallet.tsx: HEAD\'s hook return list as pinned'); @@ -582,8 +747,19 @@ assert.deepEqual(depositStateUses,[['Flows.tsx','DepositPage','ts.isFunctionDeclaration(n)&&n.name?.text==='approveAll'); rawProtected('Flows onSubmit','Flows.tsx',n=>ts.isJsxAttribute(n)&&n.name.getText()==='onSubmit',2); -rawProtected('Flows getSpec','Flows.tsx',n=>ts.isJsxAttribute(n)&&n.name.getText()==='getSpec',3); -rawProtected('Flows transaction buttons','Flows.tsx',n=>ts.isJsxSelfClosingElement(n)&&n.tagName.getText()==='TxButton',3); +// v22: the three getSpec attributes and the three TxButton elements equal HEAD byte for byte once the generated +// Flows.tsx pieces are put back; each raw piece is used exactly once over the three texts. +function rawProtectedWithPieces(label,file,select,count,pieces){ + const list=text=>{const tree=parse(text,file),found=[];(function visit(n){if(select(n,tree))found.push(text.slice(n.getStart(tree),n.end));ts.forEachChild(n,visit);})(tree);return found;}; + const h=list(baseline('web/src/'+file)),n=list(read('web/src/'+file)),used=pieces.map(()=>0); + assert.equal(h.length,count,label+': protected baseline count'); + const restored=n.map(text=>{pieces.forEach(([now,head],i)=>{const parts=text.split(now);assert.ok(parts.length<=2,label+': a piece occurs twice in one text');if(parts.length===2){used[i]++;text=parts.join(head);}});return text;}); + pieces.forEach((p,i)=>assert.equal(used[i],1,label+': the raw piece is used exactly once: '+p[0].slice(0,60))); + assert.deepEqual(restored,h,label+': bytes differ from HEAD beyond the documented pieces'); + protectedCode[label]=h.map(x=>({bytes:Buffer.byteLength(x,'utf8'),sha256:sha256(x)})); +} +rawProtectedWithPieces('Flows getSpec','Flows.tsx',n=>ts.isJsxAttribute(n)&&n.name.getText()==='getSpec',3,editedFiles['Flows.tsx']); +rawProtectedWithPieces('Flows transaction buttons','Flows.tsx',n=>ts.isJsxSelfClosingElement(n)&&n.tagName.getText()==='TxButton',3,editedFiles['Flows.tsx']); rawProtected('Claims Claim all handler','Flows.tsx',(n,t)=>ts.isJsxAttribute(n)&&n.name.getText(t)==='onClick'&&n.getText(t).includes('claimable.slice')); const effect=(component,needle)=>(n,t)=>ts.isCallExpression(n)&&['useEffect','useLayoutEffect'].includes(n.expression.getText(t))&&place(n,t)===component&&n.getText(t).includes(needle); rawProtected('Claims owed read','Flows.tsx',effect('Claims','vault("owed"')); @@ -658,8 +834,15 @@ for(const f of Object.keys(editedFiles)){ assert.deepEqual(n,h,f+': a name used by the transaction code is declared around it differently from HEAD'); transactionCode[f]={pieces:n.count,namesDeclaredAroundThem:n.names}; } -// v20 adds no name at the top of a file. +// v22 adds three names at the top of wallet.tsx: the two status constants and the PendingTransaction class. const namesAdded={ + "wallet.tsx": { + "PENDING_WAIT": "PENDING_WAIT = \"Receipt timed out or unreadable. This transaction may still be pending. Wait for its receipt before retrying.\"", + "PENDING_FINISHED": "PENDING_FINISHED = \"The earlier transaction is finished (confirmed or replaced). Refresh the vault before retrying.\"", + "PendingTransaction": "class" + }, + "Flows.tsx": {}, + "Owner.tsx": {} }; // v16: the exact declarations that change at the top of the two files (two imports leave Tokenomics.tsx; four data // declarations of tokenomicsData.ts carry the reviewed words); never generated by put-pieces. @@ -690,6 +873,26 @@ 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 source differs; regeneration does not authorize source edits'); +// v22 semantic pins (hand-written; never regenerated). The settle of the saved transaction: its exact text; its call +// before verifyNetwork for every send except genesis listing; the pending branch in send()'s tail. The two presses: +// their exact attribute texts (the Deposit press re-reads previewDeposit and refuses a changed fee before the spec; the +// Redeem press compares the fresh estimate with the preview and builds the floors from quote.amounts). The Owner roles +// row. The two status constants. +const SEND_PENDING_TEXT="async function sendPending(key: string) { const pending = pendingRef.current; if (!pending) return; const absent = (name: string) => (e: unknown) => { if ((e as { name?: string; } | null)?.name === name) return undefined; throw new PendingTransaction(PENDING_WAIT, pending.hash); }; let finished = !!(await client .getTransactionReceipt({ hash: pending.hash }) .catch(absent(\"TransactionReceiptNotFoundError\"))); if (!finished) { const [transaction, latest, queued] = await Promise.all([ client .getTransaction({ hash: pending.hash }) .catch(absent(\"TransactionNotFoundError\")), client.getTransactionCount({ address: pending.account, blockTag: \"latest\", }), client.getTransactionCount({ address: pending.account, blockTag: \"pending\", }), ]); finished = transaction !== undefined ? latest > transaction.nonce : pending.nonce !== undefined && latest > pending.nonce && latest === queued; } if (!finished) throw new PendingTransaction(PENDING_WAIT, pending.hash); pendingRef.current = undefined; try { sessionStorage.removeItem(pendingKey); } catch { } void refresh().catch(() => { }); throw new PendingTransaction(PENDING_FINISHED, pending.hash); }"; +assert.deepEqual(functionsNamed(read('web/src/wallet.tsx'),'wallet.tsx','sendPending'),[SEND_PENDING_TEXT],'wallet.tsx sendPending() differs from the pinned v22 text'); +assert.equal(nowSend.head.split(' if (s.functionName === "listGenesis") await listingPending();\n else await sendPending(key);\n').length,2,'wallet.tsx send(): every send except genesis listing settles the saved transaction first, exactly once'); +assert.ok(nowSend.head.indexOf('else await sendPending(key);') a < (quote.amounts[i] * 999n) / 1000n)) throw new InputError(\"Redemption changed. Preview again.\"); return vault(\"redeem\", redeemArgs(quote.shares, receiver, quote.amounts)); }}"; +{const specs=scans['Flows.tsx'].now.attributes.filter(x=>x.name==='getSpec').map(x=>x.text); + assert.equal(specs.filter(t=>t===DEPOSIT_GETSPEC).length,1,'Flows.tsx: the Deposit press re-reads previewDeposit and refuses a changed fee before the spec, as pinned'); + assert.equal(specs.filter(t=>t===REDEEM_GETSPEC).length,1,'Flows.tsx: the Redeem press compares the fresh estimate with the preview and builds the floors from quote.amounts, as pinned'); + assert.ok(DEPOSIT_GETSPEC.includes('const [, fee] = await read(vault("previewDeposit", [quote.tokens, quote.amounts])); if (fee !== quote.fee) throw new InputError("Deposit changed. Preview again."); if (revision !== sequence.current)'),'Flows.tsx: the Deposit fee comparison sits before the revision check and the spec'); + assert.ok(REDEEM_GETSPEC.includes('if (fee !== quote.fee || amounts.length !== quote.amounts.length || amounts.some((a: bigint, i: number) => a < (quote.amounts[i] * 999n) / 1000n)) throw new InputError("Redemption changed. Preview again."); return vault("redeem", redeemArgs(quote.shares, receiver, quote.amounts)); }}'),'Flows.tsx: the Redeem comparison (fee, leg count, every leg within 0.1%) precedes floors built from quote.amounts');} +assert.match(read('web/src/Owner.tsx'),/Fee recipient<\/strong>[\s\S]{0,400}/,'Owner.tsx: the roles panel shows the current fee recipient'); +for(const [name,value] of [['PENDING_WAIT','Receipt timed out or unreadable. This transaction may still be pending. Wait for its receipt before retrying.'],['PENDING_FINISHED','The earlier transaction is finished (confirmed or replaced). Refresh the vault before retrying.']])assert.ok(read('web/src/wallet.tsx').includes('export const '+name+' =\n '+JSON.stringify(value)+';'),'wallet.tsx: '+name+' as pinned'); // 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-fixes.mjs b/web/scripts/check-fixes.mjs index c200306..048f875 100644 --- a/web/scripts/check-fixes.mjs +++ b/web/scripts/check-fixes.mjs @@ -14,24 +14,29 @@ import {createRoot} from '${path.resolve('node_modules/react-dom/client.js')}'; import {useWallet} from '${src}/wallet'; import {Claims} from '${src}/Flows'; import {client,VAULT,vaultAbi} from '${src}/chain'; -import {keccak256,toHex,decodeFunctionData,encodeAbiParameters} from '${path.resolve('node_modules/viem/_esm/index.js')}'; +import {keccak256,toHex,decodeFunctionData,encodeAbiParameters,TransactionNotFoundError,TransactionReceiptNotFoundError} from '${path.resolve('node_modules/viem/_esm/index.js')}'; const mode=new URLSearchParams(location.search).get('mode'); const hash=keccak256(toHex('isolated pending transaction fixture')); const key='basket-pending-4663'; const mockAccount='0x00000000000000000000000000000000000a11ce'; const fixture={hash,account:mockAccount,functionName:'genesisList',...(mode==='legacy'?{}:{nonce:4})}; sessionStorage.setItem(key,JSON.stringify(fixture)); -window.ethereum={request:async({method})=>method==='eth_chainId'?'0x1237':[mockAccount]}; +// v22 fix (Codex V22-01): every provider method asked is recorded and inspected; only the two reads of the hook's +// start-up and of the send head are answered, anything else is an error (a wallet prompt has no business here). +const asked=[]; +window.ethereum={request:async({method})=>{asked.push(method);if(method==='eth_chainId')return '0x1237';if(method==='eth_accounts')return [mockAccount];throw Error('unexpected provider method '+method)}}; let waits=0,refreshed=0; Object.assign(client,{ - getTransactionReceipt:async()=>{if(mode==='receipt')return {};throw Error('no receipt')}, - getTransaction:async()=>{if(mode==='legacy')return {nonce:4};throw Error('replaced hash no longer known')}, - getTransactionCount:async({blockTag})=>mode==='pending'?(blockTag==='pending'?5:4):mode==='unknown'?4:mode==='queue'?(blockTag==='pending'?6:5):5, + // v22 fix (Codex V22-02): "not found" is viem's own not-found error, as the shipped client throws it for a null + // answer; the four ...Error modes make one read fail with a transport error instead (saved nonce 4, counts 5/5). + getTransactionReceipt:async()=>{if(mode==='receipt')return {};if(mode==='receiptError')throw Error('HTTP 503');throw new TransactionReceiptNotFoundError({hash})}, + getTransaction:async()=>{if(mode==='legacy')return {nonce:4};if(mode==='transactionError')throw Error('HTTP 503');throw new TransactionNotFoundError({hash})}, + getTransactionCount:async({blockTag})=>{if((mode==='latestError'&&blockTag==='latest')||(mode==='pendingError'&&blockTag==='pending'))throw Error('HTTP 503');return mode==='pending'?(blockTag==='pending'?5:4):mode==='unknown'?4:mode==='queue'?(blockTag==='pending'?6:5):5}, waitForTransactionReceipt:async()=>{waits++;return {}} }); const s={assets:[],globals:{},errors:[],complete:mode==='complete',aggregate:false,loadedAt:0,loading:false,retry:()=>{refreshed++}}; -function App(){const w=useWallet(async()=>{refreshed++;return s});return <>
{w.account}
} -window.inspect=()=>({saved:sessionStorage.getItem(key),waits,refreshed}); +function App(){const w=useWallet(async()=>{refreshed++;return s});return <>
{w.account}
} +window.inspect=()=>({saved:sessionStorage.getItem(key),waits,refreshed,asked:[...asked]}); // v12 SPEC 4: drive the shipped Claims component, including its Activity/key mounting. const fixtureAddress=(n)=>'0x'+n.toString(16).padStart(40,'0'); @@ -67,14 +72,41 @@ await new Promise(r=>server.listen(0,'127.0.0.1',r));let browser;const checks=[] const blockedNetwork=[]; try{browser=await chromium.launch();const page=await browser.newPage(); await page.route('**/*',route=>{const address=route.request().url();if(address.startsWith('http://127.0.0.1:'+server.address().port+'/'))return route.continue();blockedNetwork.push(address);return route.abort();}); +// v22 fix (Codex V22-01): after every non-listing press the provider's recorded methods are asserted: none is a send +// or a signature, and nothing but eth_accounts and eth_chainId was asked (the walk asserts no eth_sendTransaction on +// the real page). +const prompt=m=>/eth_send|eth_sign|personal_sign|signTypedData/i.test(m),allowed=['eth_accounts','eth_chainId']; for(const mode of ['replaced','legacy','receipt','pending','unknown','queue']){ + await page.goto(`http://127.0.0.1:${server.address().port}/?mode=${mode}`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent); + // v22: a non-listing send meets the same saved record first (sendPending): found finished (a receipt, or its nonce + // passed) it is refused once with the finished words, the record removed and the vault read again; otherwise it is + // refused with the pending words and the record kept (the predicted nonce settles only while nothing is queued). + await page.getByRole('button',{name:'Send pause',exact:true}).click(); + const sent=await page.locator('output').innerText(),sentState=await page.evaluate(()=>window.inspect()); + assert.deepEqual(sentState.asked.filter(prompt),[],'v22 '+mode+': the press asked the wallet for no send or signature');assert.deepEqual(sentState.asked.filter(m=>!allowed.includes(m)),[],'v22 '+mode+': the press asked the wallet for nothing but its account and chain id'); + if(['replaced','legacy','receipt'].includes(mode)){assert.equal(sent,'The earlier transaction is finished (confirmed or replaced). Refresh the vault before retrying.');assert.equal(sentState.saved,null);assert.equal(sentState.refreshed,1);} + else{assert.equal(sent,'Receipt timed out or unreadable. This transaction may still be pending. Wait for its receipt before retrying.');assert.notEqual(sentState.saved,null);assert.equal(sentState.refreshed,0);} + checks.push('v22 '+mode+': a non-listing send is refused by the saved record (finished once, or still pending), no prompt, no network'); await page.goto(`http://127.0.0.1:${server.address().port}/?mode=${mode}`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent); await page.getByRole('button',{name:'Check listing',exact:true}).click(); const result=await page.locator('output').innerText(),state=await page.evaluate(()=>window.inspect()); if(['replaced','legacy','receipt'].includes(mode)){assert.equal(result,'clear');assert.equal(state.saved,null);}else{assert.match(result,/pending/);assert.equal(state.saved===null,mode==='queue');} checks.push(mode+': nonce/receipt recovery and fail-closed pending guard'); } -await page.goto(`http://127.0.0.1:${server.address().port}/?mode=replaced`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent);await page.getByRole('button',{name:'Wait',exact:true}).click();assert.equal(await page.locator('output').innerText(),'waited');assert.deepEqual(await page.evaluate(()=>window.inspect()),{saved:null,waits:0,refreshed:1});checks.push('Wait reconciles replaced hash without waiting for impossible receipt'); +// v22 fix (Codex V22-02): an RPC error of the settle keeps the record and refuses the press. The two hash lookups: only +// viem's not-found answer counts as absent; any other error is refused with the pending words and the hash (the saved +// nonce 4 with the counts 5/5 would otherwise settle it). The two count reads: the error propagates to send()'s own +// catch and the press is refused with the read's own message. No refresh, no prompt, no network. +for(const mode of ['receiptError','transactionError','latestError','pendingError']){ + await page.goto(`http://127.0.0.1:${server.address().port}/?mode=${mode}`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent); + await page.getByRole('button',{name:'Send pause',exact:true}).click(); + const sent=await page.locator('output').innerText(),state=await page.evaluate(()=>window.inspect()); + assert.deepEqual(state.asked.filter(prompt),[],'v22 '+mode+': the press asked the wallet for no send or signature');assert.deepEqual(state.asked.filter(m=>!allowed.includes(m)),[],'v22 '+mode+': the press asked the wallet for nothing but its account and chain id'); + assert.equal(sent,['receiptError','transactionError'].includes(mode)?'Receipt timed out or unreadable. This transaction may still be pending. Wait for its receipt before retrying.':'HTTP 503','v22 '+mode+': refused with the pending words (a hash lookup) or the read\'s own error (a count)'); + assert.notEqual(state.saved,null,'v22 '+mode+': the record stays');assert.equal(state.refreshed,0,'v22 '+mode+': no refresh'); + checks.push('v22 '+mode+': an RPC error of the settle keeps the record and refuses the send (the pending words for a hash lookup, the read\'s own error for a count), no refresh, no prompt, no network'); +} +await page.goto(`http://127.0.0.1:${server.address().port}/?mode=replaced`);await page.waitForFunction(()=>document.querySelector('[data-account]').textContent);await page.getByRole('button',{name:'Wait',exact:true}).click();assert.equal(await page.locator('output').innerText(),'waited');assert.deepEqual((({saved,waits,refreshed})=>({saved,waits,refreshed}))(await page.evaluate(()=>window.inspect())),{saved:null,waits:0,refreshed:1});checks.push('Wait reconciles replaced hash without waiting for impossible receipt'); assert.equal(await page.getByText('Owed balances unreadable. Retry vault.',{exact:true}).count(),1);assert.equal(await page.getByText('Nothing is owed to your connected wallet.',{exact:true}).count(),0);await page.getByRole('button',{name:'Retry vault',exact:true}).click();assert.equal((await page.evaluate(()=>window.inspect())).refreshed,2);checks.push('Incomplete stock read says unreadable, offers functioning Retry vault, never says nothing owed'); await page.goto(`http://127.0.0.1:${server.address().port}/?mode=complete`);await page.getByText('Nothing is owed to this wallet.',{exact:true}).waitFor();assert.equal(await page.locator('.claims').count(),0);checks.push('v12 SPEC 4: complete empty stock read has no Claims panel and the exact quiet line'); diff --git a/web/scripts/run-browser.mjs b/web/scripts/run-browser.mjs index c14ebbd..88388fb 100644 --- a/web/scripts/run-browser.mjs +++ b/web/scripts/run-browser.mjs @@ -13,6 +13,8 @@ import { http as transport, encodeFunctionData, decodeFunctionData, + decodeFunctionResult, + encodeFunctionResult, decodeEventLog, keccak256, zeroAddress, @@ -479,7 +481,10 @@ try { now = Number((await pc.getBlock()).timestamp) * 1000, failAggregate = false, declineNext = false, - pendingNext = false; + pendingNext = false, + // v22: answer the next previewDeposit read with a 0.5% fee (the fee switch + // between a preview and its press). + feeNext = false; await page.exposeFunction("testWallet", async ({ method, params }) => { asked.push(method); if (method === "eth_requestAccounts" && holdConnect) @@ -597,6 +602,25 @@ try { body: JSON.stringify(b), }); const response = await r.json(); + if ( + feeNext && + b.method === "eth_call" && + b.params[0].data?.startsWith(previewDeposit) && + response.result + ) { + feeNext = false; + const [shares, , value, nav] = decodeFunctionResult({ + abi: vaultAbi, + functionName: "previewDeposit", + data: response.result, + }); + const fee = shares / 200n + (shares % 200n === 0n ? 0n : 1n); + response.result = encodeFunctionResult({ + abi: vaultAbi, + functionName: "previewDeposit", + result: [shares - fee, fee, value, nav], + }); + } if (response.error) console.log( "RPC error", @@ -884,11 +908,12 @@ try { 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 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?"]); + // v14: five ITEM panels between Risks and Find Basket, in the default Docs tab. v22: the walk itself was stale + // since v19 ("If a stock goes missing" stands right after Safety, so the three panel indexes below moved up by one). + assert.deepEqual(await page.locator("#docs-panel-0 .docs-grid > section > h2").allTextContents(), ["What Basket is", "Deposits", "Redemption", "FAQ", "NAV", "Numbers", "Safety", "If a stock goes missing", "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(9).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(10).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(12).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}; @@ -1489,6 +1514,28 @@ try { { exact: true }, ) .waitFor({ timeout: 30000 }); + // v22: a fee that changed since the preview (the route answers the press's + // previewDeposit read with a 0.5% fee) refuses the press with no wallet + // prompt; the next press, with the fee unchanged, deposits as before. + { + const before = sends.length, + asksBefore = asked.length; + feeNext = true; + await page.getByRole("button", { name: "Deposit", exact: true }).click(); + await page + .getByText("Deposit changed. Preview again.", { exact: true }) + .first() + .waitFor(); + assert.equal(feeNext, false, "v22: the fee was injected into the press's previewDeposit read"); + assert.equal(sends.length, before, "v22: no send when the fee changed since the preview"); + assert.ok( + !asked.slice(asksBefore).includes("eth_sendTransaction"), + "v22: no wallet prompt", + ); + checks.push( + "v22: Deposit refuses the press when the fee changed since the preview, with no wallet prompt; the next press deposits", + ); + } speedNext = true; await sendClick( page.getByRole("button", { name: "Deposit", exact: true }), @@ -1765,6 +1812,33 @@ try { await page.getByLabel("Receiver", { exact: true }).fill(owner); await page.getByRole("button", { name: "Preview redemption" }).click(); await page.getByRole("heading", { name: "Redemption preview" }).waitFor(); + // v22: a shortfall between the preview and the press (half of a held stock + // burned from the vault by a fixture) makes the fresh estimate fall under + // the previewed legs: the press is refused with no wallet prompt. The stock + // is minted back, Preview again, and the redeem below goes through as before. + { + const before = sends.length, + asksBefore = asked.length; + const held = await rv("managed", [stocks[1]]); + assert.ok(held > 0n, "v22: the second stock is held"); + await tx(stocks[1], compiled.TestStock.abi, "burn", [VAULT, held / 2n]); + await page.getByRole("button", { name: "Redeem", exact: true }).click(); + await page + .getByText("Redemption changed. Preview again.", { exact: true }) + .first() + .waitFor(); + assert.equal(sends.length, before, "v22: no send under the preview"); + assert.ok( + !asked.slice(asksBefore).includes("eth_sendTransaction"), + "v22: no wallet prompt", + ); + await tx(stocks[1], compiled.TestStock.abi, "mint", [VAULT, held / 2n]); + await page.getByRole("button", { name: "Preview redemption" }).click(); + await page.getByRole("heading", { name: "Redemption preview" }).waitFor(); + checks.push( + "v22: Redeem refuses the press when a stock's fresh estimate is under the previewed one, with no wallet prompt; Preview again, then it sends", + ); + } await sendClick( page.getByRole("button", { name: "Redeem", exact: true }), "redeem", @@ -1864,6 +1938,76 @@ try { "Close stock", "close", ); + // v22: the Owner roles panel shows the current fee recipient: none yet. + const feeRow = page + .locator(".roles > div") + .filter({ has: page.locator("strong", { hasText: /^Fee recipient$/ }) }); + assert.equal(await feeRow.count(), 1, "v22: one Fee recipient row"); + assert.equal( + (await feeRow.innerText()).replace(/\s+/g, " ").trim(), + "Fee recipient None", + ); + // v22: a saved send without a receipt locks every send. A record whose nonce + // has not passed refuses Pause deposits with the pending words, after a + // reload too; once its nonce has passed it is refused once with the finished + // words and the record goes. No wallet prompt either time; the real press + // below then sends. + { + const before = sends.length, + asksBefore = asked.length; + const latest = Number( + await raw("eth_getTransactionCount", [owner, "latest"]), + ); + const unknown = keccak256(toHex("v22 saved send without a receipt")); + const plant = async (nonce) => { + await page.evaluate( + ([key, record]) => sessionStorage.setItem(key, JSON.stringify(record)), + [ + "basket-pending-4663", + { hash: unknown, account: owner, functionName: "pauseDeposits", nonce }, + ], + ); + await page.reload(); + await page.evaluate((n) => (window.__clock = n), now); + await page.waitForFunction( + () => !document.querySelector("button.refresh")?.disabled, + ); + }; + await plant(latest); + await page.getByRole("button", { name: "Pause deposits", exact: true }).click(); + await page + .getByText(/This transaction may still be pending\. Wait for its receipt before retrying\./) + .first() + .waitFor(); + assert.equal( + await page.evaluate( + () => JSON.parse(sessionStorage.getItem("basket-pending-4663")).hash, + ), + unknown, + "v22: the record stays while unresolved", + ); + assert.equal(sends.length, before, "v22: no send while an earlier one is unresolved"); + await plant(latest - 1); + await page.getByRole("button", { name: "Pause deposits", exact: true }).click(); + await page + .getByText(/The earlier transaction is finished \(confirmed or replaced\)\. Refresh the vault before retrying\./) + .first() + .waitFor(); + await page.waitForFunction( + () => sessionStorage.getItem("basket-pending-4663") === null, + ); + assert.equal(sends.length, before, "v22: no send on the press that found the earlier one finished"); + assert.ok( + !asked.slice(asksBefore).includes("eth_sendTransaction"), + "v22: no wallet prompt", + ); + await page.waitForFunction( + () => !document.querySelector("button.refresh")?.disabled, + ); + checks.push( + "v22: a saved send without a receipt refuses the next send, after a reload too, until it is found finished; then it is refused once and forgotten; no wallet prompt", + ); + } await sendClick( page.getByRole("button", { name: "Pause deposits", exact: true }), "pauseDeposits", @@ -2055,6 +2199,15 @@ try { ); } assert.equal(await rv("feeRecipient"), owner); + // v22: the roles row now shows the recipient (the snapshot refreshed after + // the execute). + assert.equal( + (await feeRow.innerText()).replace(/\s+/g, " ").trim(), + "Fee recipient " + owner, + ); + checks.push( + "v22: the Owner roles panel shows the fee recipient: None before, the address after the FeeRecipient proposal executes", + ); const after = await rv("settings"); assert.deepEqual([after.hoursFrom, after.hoursTo, after.dst], [120600n, 489600n, 1n]); checks.push("Hours (120600-489600) and Dst 1 executed and read back from settings()"); @@ -2213,8 +2366,9 @@ try { 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 the previous walk apart from wall-clock-derived deadlines'); // The previous record is the walk of the live commit: every one of its checks is here under the same name in - // the same order, with none added, renamed or left out (v15 and v16 change no page check). - assert.deepEqual(checks,previous.checks,'Every page check of the previous walk retained under its name, in order, and none added'); + // the same order, with none renamed or left out; v22 adds its four documented checks (named "v22: ...") and nothing else. + assert.deepEqual(checks.filter((c) => !c.startsWith("v22: ")),previous.checks,'Every page check of the previous walk retained under its name, in order, and none added beyond the v22 ones'); + assert.equal(checks.filter((c) => c.startsWith("v22: ")).length,4,'The four v22 checks'); assert.deepEqual(shots,previous.shots,'Every prior screenshot retained in order'); } assert.equal(revoked.length,0); diff --git a/web/src/Flows.tsx b/web/src/Flows.tsx index 92c48a8..018db0e 100644 --- a/web/src/Flows.tsx +++ b/web/src/Flows.tsx @@ -853,6 +853,13 @@ export function DepositPage({ snapshot: s, wallet: w }: Props) { "Your stock balance is too low. Preview again.", ); } + // v22: the fee shown must be the fee charged; a fee that + // changed since the preview needs a new preview. + const [, fee] = await read( + vault("previewDeposit", [quote.tokens, quote.amounts]), + ); + if (fee !== quote.fee) + throw new InputError("Deposit changed. Preview again."); if (revision !== sequence.current) throw new InputError("Deposit changed. Preview again."); return vault( @@ -1093,14 +1100,25 @@ export function RedeemPage({ snapshot: s, wallet: w }: Props) { const b = await read(vault("balanceOf", [w.account])); if (b < quote.shares) throw new InputError("Your BASK balance is too low."); - const [amounts] = await read( + const [amounts, fee] = await read( vault("previewRedeem", [quote.shares]), ); if (revision !== seq.current) throw new InputError("Redemption changed. Preview again."); + // v22: the minimums are 0.1% under the preview on screen; a + // fresh estimate under them, or another fee, needs a new one. + if ( + fee !== quote.fee || + amounts.length !== quote.amounts.length || + amounts.some( + (a: bigint, i: number) => + a < (quote.amounts[i] * 999n) / 1000n, + ) + ) + throw new InputError("Redemption changed. Preview again."); return vault( "redeem", - redeemArgs(quote.shares, receiver, amounts), + redeemArgs(quote.shares, receiver, quote.amounts), ); }} /> diff --git a/web/src/Owner.tsx b/web/src/Owner.tsx index 195f676..e869eda 100644 --- a/web/src/Owner.tsx +++ b/web/src/Owner.tsx @@ -966,6 +966,18 @@ export function OwnerPage(props: Props) { )} +
+ Fee recipient + {s.loading ? ( + "Reading..." + ) : s.globals.feeRecipient === undefined ? ( + unreadable + ) : same(s.globals.feeRecipient, zeroAddress) ? ( + None + ) : ( + + )} +
{finalized ? (

diff --git a/web/src/wallet.tsx b/web/src/wallet.tsx index e332123..5ce2e12 100644 --- a/web/src/wallet.tsx +++ b/web/src/wallet.tsx @@ -38,6 +38,23 @@ export class TransactionCancelled extends InputError {} // replaced. export const LISTING_FINISHED = "The earlier transaction is finished (confirmed or replaced). Press Resume listing."; +// v22 (2026-10-17, live-site review): every send except genesis listing first +// settles the saved transaction of this tab. While it has no receipt and its +// nonce has not passed, the press is refused with its hash and these words. +export const PENDING_WAIT = + "Receipt timed out or unreadable. This transaction may still be pending. Wait for its receipt before retrying."; +// Once it is found mined or replaced, the record goes, the vault is read again +// and that press is refused once, so the result is seen before any retry. +export const PENDING_FINISHED = + "The earlier transaction is finished (confirmed or replaced). Refresh the vault before retrying."; +class PendingTransaction extends Error { + constructor( + message: string, + public hash?: Hex, + ) { + super(message); + } +} type PendingTx = { hash: Hex; account: Address; @@ -173,6 +190,56 @@ export function useWallet(refresh: () => Promise) { await listingPending(); await refresh(); } + // v22: settles the saved transaction of this tab before a send, whichever + // account sent it (listGenesis keeps listingPending and its Resume buttons). + // Mined or replaced: a receipt, the nonce the node reports for the hash + // passed, or the count saved before the prompt passed while nothing is + // queued (listingPending's second guard). Fail closed: only the node's "not + // found" answer to a hash lookup (viem's TransactionReceiptNotFoundError or + // TransactionNotFoundError) counts as absent; any other error of the two + // lookups refuses the press with the pending words and the hash, and an + // error of either count read propagates to send()'s catch; the record stays. + async function sendPending(key: string) { + const pending = pendingRef.current; + if (!pending) return; + const absent = (name: string) => (e: unknown) => { + if ((e as { name?: string } | null)?.name === name) return undefined; + throw new PendingTransaction(PENDING_WAIT, pending.hash); + }; + let finished = !!(await client + .getTransactionReceipt({ hash: pending.hash }) + .catch(absent("TransactionReceiptNotFoundError"))); + if (!finished) { + const [transaction, latest, queued] = await Promise.all([ + client + .getTransaction({ hash: pending.hash }) + .catch(absent("TransactionNotFoundError")), + client.getTransactionCount({ + address: pending.account, + blockTag: "latest", + }), + client.getTransactionCount({ + address: pending.account, + blockTag: "pending", + }), + ]); + finished = + transaction !== undefined + ? latest > transaction.nonce + : pending.nonce !== undefined && + latest > pending.nonce && + latest === queued; + } + if (!finished) throw new PendingTransaction(PENDING_WAIT, pending.hash); + pendingRef.current = undefined; + try { + sessionStorage.removeItem(pendingKey); + } catch { + /* storage unavailable */ + } + void refresh().catch(() => {}); + throw new PendingTransaction(PENDING_FINISHED, pending.hash); + } const [account, setAccount] = useState

(); const [chainId, setChainId] = useState(); const [message, setMessage] = useState(""); @@ -347,6 +414,7 @@ export function useWallet(refresh: () => Promise) { if (accounts[0]?.toLowerCase() !== account.toLowerCase()) throw new Error("The wallet account changed. Please retry."); if (s.functionName === "listGenesis") await listingPending(); + else await sendPending(key); await verifyNetwork(); await simulate(s, account); const gas = ["deposit", "redeem", "claim"].includes(s.functionName) @@ -482,6 +550,10 @@ export function useWallet(refresh: () => Promise) { notice(key, e.message); throw e; } + if (e instanceof PendingTransaction) { + report(key, { message: e.message, hash: e.hash }); + throw new InputError(e.message); + } const message = await explainAction(e, s); const status = sent && !settled diff --git a/web/validation/README.md b/web/validation/README.md index 5bbcf4a..9be23f6 100644 --- a/web/validation/README.md +++ b/web/validation/README.md @@ -53,6 +53,8 @@ The v14 baseline is the v13 commit 2b7d55bc32d09610fed936d963d9a8c492f4eecf. che The v15 baseline is the v14 commit c05c174f639d352fb6184636bcc6e083031daa65. v15 changes style rules only (the end of styles.css): check-calldata has no edited script file, freezes every source whole against v14, keeps the module and source digests and pins the added style rules by one reviewed digest with a scan; check-bundle's source scope is styles.css alone with no new file. check-visibility compares the v14 six-route phone view at 375×812 with no exception: every first-screen element keeps its words and vertical place, the Deposit and Redeem titles equal v14 across the panel, and the Vault title is asserted across the panel (picture in the middle, shelf line across the panel, heading centred) against the v14 look. check-art keeps its assertions at every width from 320 to 2560px in both engines and adds the Vault phone layout, the comparison of every title with the live v14 title, and the heading's focus marker meeting the shelf line on Deposit and Redeem at seven phone widths, each with the v14 look rejected by the same check. The v15 fork keeps all 91 v14 checks under their names in order with none added, the same 70 page sends and the same 24 screenshots at JPEG quality 18. +The v22 baseline is the v20 commit f86e5d80e53174c37f5cd4e14cf6df453ffa33c6. v22 changes three sources (wallet.tsx: the saved-transaction settle before every send except genesis listing; Flows.tsx: the Redeem comparison with the preview and its floors from the previewed amounts, the Deposit fee re-read; Owner.tsx: the Fee recipient row) and nothing else; check-calldata freezes every other source, documents the three by generated pieces and reviewed digests, writes out every added guarded call, use, storage use and module name, compares send() and the Flows.tsx getSpec attributes and TxButton elements with the baseline once their documented pieces are put back, and pins the new behaviour by exact texts; check-bundle allows the three sources; the walk has 95 checks (four new, named "v22: ..."), 70 wallet sends and 24 screenshots, and its Docs panel list is corrected; check-fixes has 40 regressions (six new). No new wallet request, chain read beyond the settle's four public-client reads and the Deposit press's previewDeposit read, storage key or link. + The v20 baseline is the v19 commit 8f88184b6dbcfd2c11f7a762c4c25cce55d8d230. v20 changes no source; check-art and check-motion count the Losses route as shown by its heading "Losses" (no navigation link carries its current-page mark since v19), and check-calldata, check-bundle and check-visibility compare with v19 for equality. The v19 baseline is the v18 commit ec5fa83ce47dd1a361cda55e086ef503d10f2deb. v19 changes two display sources (main.tsx: the footer's Losses link removed, the short-stock notice above the Vault and Deposit pages added; Docs.tsx: the "If a stock goes missing" panel) and nothing else; check-calldata freezes every other source, wallet.tsx included, pins the two by digest and by generated pieces, pins the notice's three display calls, and lists the Losses link's move and the one Docs link as the only changes to what a page opens; check-visibility expects the one new Docs panel after Safety (panels before it equal the baseline, panels after it keep their size and move down by it). No transaction, wallet request, chain read or storage change.