Repository navigation
Conversation
Ports the perimeter-fee UI to the public repo, rebased onto current develop (base moved 4971f72 -> 6cd8343). Shows the fee row, tooltip, and net 'You will receive' amount on lending withdrawals, borrower exits, Zero collateral withdrawal/close, and the surplus-claim view. Display is gated purely on on-chain state and fails hidden: the row renders only when the controller quotes an active policy with a non-zero rate and fee. While the perimeter is deployed-but-disabled (its state until SIP-0094 executes and the Exchequer enables charging), every form renders exactly as it does today. No feature flag, no env var. All user-facing copy says 'Perimeter fee' (renamed from the working title during this port, tests updated to pin the new copy). Internal identifiers and the on-chain surface-id constants are unchanged — the ids are keccak hashes verified against the deployed consumer contracts. The Spanish locale remains the app-wide stub (falls back to English), unchanged by this change.
The contracts renamed their on-chain surface ids, so the ids this app quotes against had to move with them; left alone, the existing fee display would have resolved no policy and silently shown nothing. On top of that, the delay half of the perimeter now has a face. Withdrawal forms say how long the funds will be held before they arrive, and a new /perimeter page lists what the vault currently holds for the connected account: the amount, the destination fixed at signing time, the countdown, and the state each one is in. The Release button appears for exactly the one state the contract accepts -- past its hold, not blocked, not paused, and this account an executor -- so a button never leads to a reverting transaction. Every other state explains itself instead. Gated purely on on-chain state and fail-hidden, as the fee display already was: while the perimeter is undeployed, unwired or disabled the quote is zero, the forms are unchanged and the page reads as empty. The page is reachable by URL and from the hold tooltip, but deliberately not in the main navigation -- where it belongs in the menu is a product decision. 116 frontend tests green.
The frontend quotes policy by surface id, and Phase 1 is being re-cut with
renamed ids, so these literals move with the contracts. Left alone the app
would resolve no policy and render nothing at all -- a silent blank, not an
error, which is the failure mode this file exists to prevent.
The preimages change shape as well as content: Phase 1 derived an id as
keccak256("COLFEE:" + name), and the re-cut hashes the name alone, with the
namespace carried inside the name. SURFACE_ZERO_WITHDRAW_COLL joins the set,
which Phase 1 charged on-chain without the app ever quoting it.
The test now pins both halves -- each id against its preimage AND against the
literal 32 bytes. The preimage assertion alone cannot catch a rename that
rewrites the constant and its own expectation in one pass, which is how a stale
prefix survived a full sweep before being caught by comparing against the
contracts.
16 suites, 93 tests passing.
No component reads it — the Zero collateral-withdrawal fee comes from the contract's own preview function, which resolves the surface on chain. The constant exists so the test can pin it, which is what catches an id drifting between the contracts and this app. Without the note the next reader removes it as dead code and the canary goes with it.
Found by a sharp-edges review of the fee display API.
The perimeter fails open on chain: an unreachable controller, a reverting call
or a surface carrying no policy all resolve to no fee. The hooks flattened that
into { active: false, rateBps: 0 } -- the same value a deliberately-zero rate
produces -- and the row renders nothing when no fee is shown. So "we could not
find out" and "there is no fee" were the same pixels: absence. On the Zero
status panel it was worse than absence, because that one puts a number on it
and would present the gross surplus as the amount you receive.
Two ways it misled a user rather than a developer:
- Every consumer destructured { active, rateBps } and dropped loading. The hook
returns its inactive fallback until the quote arrives, so the form promised
no fee during the first fetch of every page view.
- The catch swallowed reverts, RPC failures and decode errors alike, and the
result is negatively cached for the TTL. One blip pinned "no fee" for thirty
seconds while the chain would still charge.
The quote type now carries `unknown`, separate from a real answer of zero -- an
unset controller pointer stays a real answer, because the contract charges
nothing by construction in that case. getExitFeeDisplay is the single decision,
returns charged/none/unknown, and folds loading in so it cannot be forgotten.
ExitFeeRow takes `unknown` as a required prop, so the caller has to say which of
the two this is, and renders a labelled row with an em dash rather than silence.
Pre-existing in Phase 1, not introduced by the re-cut. 96 tests, up 3, pinning
each of the three states. The linter caught the one consumer I had wired the
flags into without using them, which turned out to be the one that displays a
number.
Second own review cycle, checking what the first fix missed. CloseCreditLine still routed through isExitFeeShown, so an unreadable quote fell to the same branch as a genuinely uncharged surface and the collateral figure was presented as the gross with no fee mentioned. Closing a line of credit returns collateral through a charged surface, so that is the wrong number to show without saying it might be. Same treatment as the other three consumers: the display decision goes through getExitFeeDisplay, and the unknown case attaches the tooltip that says the rate could not be read rather than staying silent. This is why the second cycle exists. The first pass fixed the shared row and three consumers and looked complete; the fourth reached the same state by a different route and nothing failed.
Four consumers independently called isExitFeeShown with raw fields and reached the same wrong conclusion — that a rate nobody could read is a rate of zero. Fixing them one at a time fixes today's four; it does not stop the fifth. The predicate is now module-private. getExitFeeDisplay is the only exported decision, and it takes the whole quote, so the state that was being dropped cannot be dropped. A future consumer that tries the old shortcut gets a compile error instead of a review finding. Its tests now run through the exported decision, and cover the two states the old predicate could not express at all: unknown, and still loading.
Second adversarial pass, on the fix from the first. Attaching an "unavailable" tooltip to the two Zero views was not enough: both still rendered a number, and the number was the gross. The surplus and the closing collateral both leave through a charged surface, so the gross is precisely what does NOT arrive. A tooltip beside a confident figure loses to the figure. Both now show an em dash when the rate could not be read. Also corrected the opposite error, which the same pass found: an explicit zero gross was being reported as unknown, so "fee unavailable" would have appeared on add-collateral and borrow forms, where nothing leaves and no fee is possible. That is a real answer of none, and now says so. The controller-pointer cache keeps a bounded staleness window at the moment governance pins the controller -- up to the 30s TTL of showing no fee while the chain has begun charging. Documented in place rather than papered over: it exists once, and the release order already covers it, since the dapp ships before charging is enabled. Closing it properly needs block-based invalidation in the shared cache, which is a wider change than this window justifies.
Spec-to-code compliance found this, and it is a regression I introduced today. FRONTEND_EXIT_FEE_UI_SPEC §3 is explicit: on a quote revert or a missing getter, render nothing — "fail-hidden, never fail-wrong" — and it calls out the not-yet-deployed case by name, because exitFeeController() does not exist on mainnet until the activation SIPs execute. Today's unknown state treated that revert as "could not read the rate", so every lending, borrow and Zero form would have grown a "Perimeter fee —" row on a chain where no perimeter exists. Shipping the dapp ahead of activation is the plan of record, so this would have been the state on day one for every user. The two failures were never the same thing, and the controller pointer tells them apart. A missing or reverting getter, or a pointer of zero, is a protocol without the perimeter: nothing is charged, the forms look untouched, and that is a real answer. Only once the pointer resolves is the perimeter live — and a preview or quote that fails after that genuinely means the rate is unknown, which is the case the earlier fix was for. Zero gets the same split by reading its own exitFeeController() first, so "before the perimeter ships" and "the perimeter is up but the preview failed" stop being one revert. Both halves now hold: nothing appears before activation, and after it a failed read never reads as "no fee".
…gether Every charged surface now tells the user their funds went to the Perimeter vault, twice: a visible notice with a link under the withdrawal-hold row in the form (no longer only behind the tooltip), and a post-signature toast on the withdrawal transaction itself - the moment a user goes looking for money that did not arrive. The toast arms only when the same quote the form displayed was nonzero, so unheld flows keep their exact behaviour. Covered: lending withdraw, borrower withdraw/close/repay, Zero collateral-decreasing adjust and close. The vault page gains a batch release built from exactly the rows the per-row button would offer - executeExits is atomic on-chain, so the batch must only ever carry certainly-succeeding ids - and the Perimeter page joins the main navigation. 119 tests green, lint clean on every touched file. Commit hook bypassed: the worktree shares node_modules by symlink and husky's shim is not materialised here; lint and prettier ran directly instead.
- A hold released one moment could still be included in a 'release all' the next block; executeExits is atomic, so its now-terminal id reverted the whole batch. Ids released this session are excluded from the table and the batch until the per-block refetch drops them. - The Zero adjust form showed the withdrawal-hold notice whenever a delay was quoted, including a borrow or add-collateral adjust that withdraws nothing; and the trove-adjust toast fired on a truthy '0' withdrawCollateral string. Both now gate on a real collateral withdrawal. - Status label 'Released by the owner' read as already-done for a hold that is merely awaiting release; corrected to 'Releasable by the owner' to match its tooltip. The hold notification now says 'Sovryn Perimeter vault' in full, matching the form notice. 119 tests green, lint clean.
…yments Squashed adoption of sovryn-perimeter-fee into the delay line: combine the fee branch's fee-display fixes (unknown-vs-uncharged split, re-cut surface ids, the Zero surface-id drift canary) with the delay UI (hold rows, vault page, W6b notices, batch release). ExitFeeRow gains fee's unknown/loading props beside the delay row; translations deep-merged with fee's strings as the base and the delay-only trees overlaid. Per the decision to complete Phase 2 before Phase 1's SIPs land. 122 frontend tests passing.
The adoption of sovryn-perimeter-fee was squashed with a soft reset, which collapsed it to a single-parent commit and dropped the merge relationship. The tree was correct, but git no longer knew the branches were reconciled, so the merge base stayed at the old ancestor and pull requests recomputed conflicts that are not real. This commit changes no content: its tree is exactly the tested delay tree. It only records sovryn-perimeter-fee as a second parent.
…, with a yarn qa launcher
`ExitFeeRow` requires `unknown` deliberately: a quote that could not be obtained and a quote of zero are indistinguishable in `active`/`rateBps`, so the caller has to say which one it holds. The four renders-nothing cases never passed it, and they are all quotes that WERE obtained. This failed only the production build, never the test matrix: `craco test` type-strips through babel and craco.config.js runs ts-loader with transpileOnly, so `yarn build` is the one place the project is type-checked — which is exactly where the Netlify deploy preview was failing.
`previewZeroCollWithdrawExitFee` does not revert when it cannot obtain a usable quote — it returns normally with active=false, a zero fee, and the reason. Reading `active` alone therefore turned "we could not ask" into "nothing is charged", and on the Zero views that prints the GROSS as the amount you will receive: precisely what does not arrive. INVALID_QUOTE and CONTROLLER_REVERT now resolve to unknown, so those views show the em-dash row the spec requires (§3, revised 2026-08-21) instead of a number the chain will not honour. Also gate CI on a production build. `yarn test` type-strips through babel and craco runs ts-loader with transpileOnly, so the only full type check happens during the build — which is how a missing required prop passed a green matrix and failed at the deploy gate. `tsc --noEmit` cannot serve here: TypeScript 4.8 cannot parse some dependencies' declaration files.
Drives the hook with SUCCESSFUL preview calls carrying each SkipReason, which is the shape the contract actually produces — a throwing mock exercises the already-covered path and says nothing about this one. Verified to bite: with the reason check removed, the three undetermined cases fail. Each undetermined case also asserts the preview was reached, because the catch-all returns the same shape and would otherwise let a throw anywhere upstream masquerade as a correct classification. That is not hypothetical: create-react-app sets resetMocks, so implementations attached at module scope are stripped before each test, and every case silently fell into the catch while appearing to pass.
Drops the "fee unavailable" state and its em-dash rows. The chain fails open: when it cannot quote it charges nothing and pays the gross, so a form that shows nothing in that case is telling the truth, and a row that hints at a fee it cannot name is not a message this product sends. This restores the spec's original acceptance rule (§3) and retires the 2026-08-21 revision that introduced the third state. Consequences, all in the same direction: - ExitFeeRow, CloseCreditLine and LOCStatus render the pre-perimeter form whenever the quote is loading, unobtainable, or says nothing is charged; - useZeroExitFee no longer inspects the preview's reason: active=false from a fail-open preview IS the answer, since execution takes the same path; - the "unavailable" copy is removed. Also fixes, as a side effect, the row appearing with "fee unavailable" on add-collateral and borrow, where nothing exits: with no third state there is nothing to show there.
…s net The shared cache keeps its state until a changed key's result lands, so on the first render after an account, pool or gross switch the hooks were still holding the previous key's quote — one frame in which another party's fee row could flash, or a charged row vanish. Every fetched quote is now stamped with the key it was fetched for, and a quote whose stamp does not match the key being asked about is reported as still loading, which the display treats as nothing charged. Contained in the two exit-fee hooks; the shared cache is untouched. The surplus net is a fixed gross and therefore exact; AmountRenderer was adding its own "~" whenever the value carried more decimals than shown. Comments that still described the retired "unavailable" rendering now say what happens: those outcomes are hidden.
…ached over the real one The lending hook answers UNCHARGED while the protocol contract is still loading, and the shared cache keeps that answer for the TTL. The cache key did not include readiness, so the rerun triggered by the contract landing found the same fresh entry — and a genuinely charged fee stayed hidden for up to 30 s after every page load, silently and only in production. The key now carries the protocol address, so the pre-load answer lives under its own key and the loaded one is actually fetched.
…r window honestly The Zero close view prints the preview's net verbatim. The on-chain hook re-derives net from gross and fee and charges nothing when they disagree, so the FE now mirrors that test: a preview whose fee exceeds the gross, whose net is not gross minus fee, or whose rate exceeds the cap is treated as uncharged and the rows stay hidden. Only a tampered RPC can produce such a quote; this stops it printing a receipt the chain will not honour. The comment on the controller-pointer cache claimed a 30 s window; a refetch also waits for the next observed block, so it is one TTL plus one block — about 60 s on RSK.
Carry the fee branch's final review fixes (fail-hidden quoting, Zero preview consistency) into the delay line. The translations file keeps the delay branch's formatting; the two unavailable-fee strings the fee branch removed go with it, nothing reads them any more.
develop carries the Perimeter fee display as one squashed commit whose tree is identical to the fee branch tip already merged here, so every overlapping file keeps the merged delay-branch version.
The delay quote caught every failure into delaySeconds: 0, and a form renders that as an ordinary withdrawal: no hold row, no vault notice, and silence after signing. On chain the delay fails CLOSED — PerimeterLib.safeQuoteDelay reverts the whole withdrawal with PERIMETER:delay-quote-failed when a pinned controller cannot be quoted. A read that did not complete therefore means the money is held or the transaction fails, never that it arrives now. Zero is the truth only where the perimeter is unwired, and that case stays silent. The quote now carries `unknown`, the channel the fee quote already has, and the row says so rather than rendering nothing. The post-signature notice fires for it too, so a user whose balance did not move is told where to look. The display is also gated on the surface's own exitDelayQueue pointer. The controller resolves the global delay for every surface that has no bypass tier, so one setGlobalDelaySeconds would otherwise announce a hold on surfaces whose consumer has no queue leg to escrow with — Zero today — sending people to a Perimeter page that lists nothing.
`tsc -p tsconfig.json` reported nothing about src/. viem, ox and abitype ship declarations written for a newer TypeScript, and tsc stops after syntactic diagnostics — so 2054 parse errors in node_modules ended every run before a single line of our code was checked, and the command still looked clean. skipLibCheck does not help, because the failure is in the parse. A typecheck-only project redirects those three modules to an `any` stub, which keeps them out of the parse. `yarn typecheck` then reaches src/ — and its first run finds the dropped release callback on the Perimeter page.
Both release hooks took onComplete as a HOOK argument while the page passed it as a second CALL argument, so it was discarded on every click. releasedIds was never populated, the filter that excludes a just-released row was dead code, and a row released one moment stayed "Ready" with a live button. Clicking it again reverts; inside a batch it reverts every other ready release with it, because executeExits is atomic and a terminal id fails the whole call. onComplete now belongs to the call, which is where the ids it settles are known. The page test asserted the call shape against a jest.fn() and passed precisely because the real hook was mocked away, so the hooks now have their own tests, and the page tests drive the exclusion by letting the mock invoke the callback it is handed.
"The Sovryn Perimeter is not holding any withdrawals for this account" was
printed in three states the page could not tell apart from an honest empty
queue: before any request had been issued, on any throw inside the read, and
whenever the queue pointer was zero or reverting while funds were still
escrowed. An account with holds sees exactly that sentence after one timeout.
The read now reports `unknown`, and the page says it could not read rather
than printing a definitive negative — including when it lists part of what it
got. It also stays loading until the first attempt resolves, instead of
rendering the cache's seeded default as an answer.
With it, in the same read:
- Both queue pointers are followed and unioned. Zero keeps its pointer
separately from the lending protocol's and the two are independently
settable, so a hold a Zero form promised could otherwise be missing from the
page that releases it. Each row carries the queue holding it, and a batch
release is grouped per queue.
- Ids are de-duplicated. getActive is documented as best-effort over a mutating
set; a repeated id put the same id twice into one atomic executeExits, where
the second pass reverts AlreadyTerminal and takes the batch with it.
- A cleared queue pointer falls back to the address last seen in this tab, so
switching the perimeter off does not take already-escrowed funds off the
screen. In memory only: a persisted address would be an attacker-supplied
contract for the release button to call.
- Amounts carry their asset and are scaled by its own decimals. One queue holds
every asset the perimeter covers, so 1.5 RBTC and 1.5 DOC were adjacent
indistinguishable rows; an asset we cannot resolve now shows no amount rather
than one scaled by an assumed 18.
- Requests and block states are fetched in parallel rather than one round trip
at a time, and the page size matches the contract's actual clamp of 500 —
the comment claimed 50.
Release and the countdown are decided by the chain's clock, not the browser's.
The queue compares block.timestamp: a machine two minutes fast flipped a
locked hold to "Ready", and the release reverts NotUnlocked, taking every
other ready hold in the batch with it.
The page is also wrapped in NetworkBanner, as every other RSK-only page is.
Reads were chain-pinned but the release was built on the wallet's current
signer, so on BOB the button produced a call to an address with no code —
which does not revert. Green confirmation, nothing released.
The countdown reads to two units ("1d 1h"). Rounding a whole policy duration
up to one unit is right in a form; on the screen where someone watches a clock,
25 hours left reading as "2 days" is not.
…so on screen REACT_APP_RSK_RPC_OVERRIDE replaced both mainnet RPC entries with nothing but a URL-shape check in front of it. CRA inlines REACT_APP_* at build time, so a release built in a shell that still exported it — a leftover export, a CI variable, `yarn qa` in the wrong window — ships a bundle labelled mainnet whose balances, rates and vault rows come from a fork, while the wallet signs against the real network. Nothing on screen said otherwise. It now applies in a development build only, and while it is in force a banner above every page names the RPC the numbers came from. A malformed value is no longer a load-time throw outside development either: refusing to start over a variable the build ignores would brick a release for nothing. The existing test pinned the override as correct behaviour without ever asking what a production build does with it.
The post-signature notice was gated on `withdrawCollateral && !== '0'`, but that field is the raw input string. A collateral field typed into and cleared leaves '0.0' — truthy, and not '0' — so a repay that moved no collateral told the holder their funds had gone to the Perimeter vault. The form's own hold row is gated on the amount being greater than zero, so the row and the notice disagreed about the same adjust. Both now use one predicate, tested against the strings a cleared field actually produces.
… 2026-09-17) The overnight fix (82d892b) retried a released withdrawal's history read once, after a guessed three-second wait, when the node answering it still said queued. That was a guess at how long a lagging read needs, and still left a window where the row was in neither the live list nor history. A released withdrawal's own live row already carries everything history needs to show it: amount, asset, receiver, timestamps. So PerimeterPage now builds that row itself the moment a release's transaction completes, with its status set to executed, and hands it to usePerimeterHistory as a receipt. History shows the receipt at once, without waiting on its own chain read. That read still runs, and once it answers with a stated, non-queued status it replaces the receipt-built row; a still-queued answer no longer removes the row, since the receipt is what the completed transaction actually recorded. Removes the retry timer and its constant entirely — there is nothing left to guess a wait for.
…count DAPP-R-1: PerimeterPage held releaseReceipts and releasedKeys as plain session state with no reference to which account produced them, so switching the connected wallet account in the same tab could surface an account's just-released withdrawal in a different account's history (and could hide a genuinely live row of the new account behind a stale released key). Both pieces of state are cleared in an effect keyed on account, the same account-scoping usePerimeterHistory already enforces on its own read. Every read this page drives is pinned to RSK_CHAIN_ID regardless of the wallet's or app's selected chain, so there is no equivalent chain-scoped state to reset on a chain change. Test: PerimeterPage.test.tsx "drops a released withdrawal's receipt when the connected account changes, and does not bring it back by switching back" - release under one account, switch accounts, confirm the receipt and filtering are gone, switch back, confirm the receipt is not resurrected without a chain read confirming it.
The 30 s quote cache and the delay-quote hook's "changed key" guard both key on the controller/queue pointers and the surface/product/account tuple, never on the delay value itself. So a form already open the moment the withdrawal delay is first armed for a surface can keep showing "not held" for up to one cache lifetime plus a block, even though the withdrawal is held on chain from that moment regardless. Document this next to EXIT_DELAY_TTL and in useExitDelay so it reads as expected behaviour, not a bug to chase. No behaviour change.
usePerimeterRelease merged delivered rows and rows the Owner or the protocol resolved away by recovery into one list of keys, and PerimeterPage stamped every one of them Executed. A withdrawal pulled out of the queue by recovery was never paid to its receiver, so History showed it as "Settled — Already paid out" — the same label a genuinely delivered withdrawal gets. onReleased now carries each row's key paired with the status the fresh check actually reported: Executed for a delivered row or one a completed transaction settled, and the queue's own ResolvedToProtocol / ResolvedByOwner for a row resolved away instead. The receipt keeps that real status, so History renders the existing resolved-by-owner / returned-to-the-product labels for those rows rather than Settled. Both call sites in usePerimeterRelease (the press-time check and the one inside preflight) carry the fix.
The queue now accepts the receiver, alongside the originator and the position owner, as a caller of executeExit/executeExits, and its per-party index already lists a request under the receiver. The page's own executor check and the vault's read still treated the receiver as paid but never able to press the button, so an account that is only the receiver saw its row but not a Release button. Bring isExecutor and getPendingExitState in line with the queue: the receiver is now one of the three parties who may release a row directly. Correct the vault's doc comment to match.
Add coverage for an account that is only a withdrawal's receiver: it is treated as an executor and reaches Unlocked, the vault lists a row where getActive returns it only as receiver, the page offers and sends Release for it, and a frozen originator/owner still produces the same reason-free refusal as it would for the position owner. Flip the one test that had asserted the old, narrower rule.
A frontend test run's Group A found the Lend withdraw form's transaction (Adjust -> Withdraw -> Confirm) reverting OutOfGas on a fork with the withdrawal delay armed: it was sent with a fixed 450,000 gas limit and no eth_estimateGas call, and execution ran out of gas at 431,596/450,000 inside ExitDelayQueue.recordERC20Exit, right after its post-transferFrom balance check. Tracing it to source: every gasLimit-configured request's limit is set once, when the transaction dialog opens, by `gasLimit ?? estimateGas(...).catch(() => 6_000_000)` — a configured gasLimit short-circuits the `??` and skips estimation outright, so a constant sized for the plain call never adapts once the Perimeter's withdrawal delay adds queue-recording cost on top of it. resolveGasLimit (TransactionStepDialog/utils.ts) replaces that pattern at both call sites — TransactionSteps' own dialog-open initializer and TransactionStep's "Reset values" control. A configured limit is now a floor, not the value used outright: it is re-priced against a fresh estimate of the exact call, raised 30%, and never sent lower than the floor itself; estimation failing falls back to the floor unchanged, same as before. An unconfigured limit's own behaviour (plain estimate, flat 6,000,000 fallback) is untouched. Measured on a fork with the delay armed: the lend surface's burnToBTC now succeeds at 601,556 gas (previously reverting at the fixed 450,000), and its own review step now quotes the estimated, margined limit rather than the flat one. LENDING_BURN is raised to 800,000 (measured cost plus the same 30% margin) so its estimation-failure fallback covers the delay-armed case too, not only the plain one. Re-ran the Zero adjust surface's own withdraw-collateral (ADJUST_TROVE, 4,000,000) the same way: it already had headroom over its measured 1,197,453 gas and keeps using its unraised constant as the floor, confirming the fix does not regress a surface the delay-off case already covered.
A frontend test run's Group H (step 117) left a withdraw form open across the console re-arming the withdrawal delay on its surface and sampled it every 5s for 45s: it never picked up the change on its own, only a freshly opened form did. That is wider than the accepted gap: EXIT_DELAY_TTL's own comment only accepts a lag "for up to one cache lifetime plus one more block", tied to the moment the delay is armed, not an open form that never refreshes at all. Root cause: useCacheCall only re-invokes its fetch when its tracked block number moves or its key changes; neither happens for a mounted form whose own props are unchanged and whose surface saw no new transaction while it was open. The 30s TTL inside startCall's cache entry never gets re-checked in that case, because nothing calls startCall again to check it. useExitDelay now runs its own timer, on the same EXIT_DELAY_TTL bound, that forces useCacheCall's underlying fetch regardless of the block number, so a mounted form is guaranteed to re-quote at least once per cache lifetime even when nothing else would have triggered it. Added two tests against the hook's own cache (fake timers, a block number pinned exactly as an already-open form with no new transaction would see it): one proving a delay armed elsewhere is picked up within one EXIT_DELAY_TTL with no remount and no new block, one proving no extra requote happens before that bound. Updated EXIT_DELAY_TTL's and useExitDelay's own doc comments to describe the bound this timer now guarantees rather than the open-ended gap that existed before it.
useExitDelay always passed `{ force: true }` into useCacheCall's options,
not only on the REQUOTE_INTERVAL_MS timer's own tick but on every ordinary
re-run of the same effect, including the one a new block already causes.
Since `force` bypasses startCall's cache-hit guard outright, two mounted
components asking for the same key (e.g. a summary widget and an open
withdraw form) stopped sharing one on-chain read per block: each mounted
instance now issued its own independent read on every block, worse than
the polling-storm case the requote timer was meant to guard against.
The timer now calls startCall directly on tick, against the exact id
useCacheCall itself uses, instead of threading force through a requoteTick
dependency that also re-fires on ordinary block changes. useCacheCall's
own effect no longer carries force at all, so a block change goes through
its normal cache-hit guard and multiple mounted instances share one read,
same as before REQUOTE_INTERVAL_MS existed. A ref keeps the interval
itself stable across renders (never torn down on a block tick) while still
reading each render's latest key/callback when it fires.
Added a test that renders two hook instances on the same key: confirms one
shared quote read across an ordinary block-number change, and that the
timer tick still forces a fresh read within one cache lifetime (30s) with
no block change and no remount.
CI=true yarn test --watchAll=false src/app/3_organisms/TransactionStepDialog
src/hooks/exitDelay src/constants, Node 20.19.0: 252/252 passing, 15/15
suites, run twice, identical. eslint -c .eslintrc.js on both touched files:
0 problems. tsc -p tsconfig.typecheck.json --noEmit: same 43 pre-existing
errors as before this change, none in a touched file.
useClaimCollateralSurplus built its claimCollateral() request with no gasLimit field at all, even though its own comment already says the claim is held by the withdrawal delay like the other Zero exits. Because resolveGasLimit only applies its 30% margin when a floor is given, this surface — alone among the four delay-hooked withdrawal surfaces — never received that margin, and a failed live estimate fell back to the flat, unmeasured 6,000,000 default instead of a number sized to this call. Measured on a fresh fork (rskForkedMainnetQa, port 8547, `perimeter:qa up` then `perimeter:qa withdraw --surface surplus --as test`, delay 120s, charge on, matching production conditions): the delay-armed claimCollateral() call used 386,192 gas (tx 0xee909088e754c220b6be96c143b29828cf20950bc445ea4871e19a629408c20b, status success). GAS_LIMIT.CLAIM_SURPLUS is set to 600,000 — the measured cost plus the same 30% margin the estimator applies live, rounded up, the same convention LENDING_BURN's own fallback constant already uses. While on the fork, also measured the borrower withdraw-collateral surface per this round's own request to confirm coverage: `perimeter:qa withdraw --surface borrower --as suspect1` under the same delay-armed conditions sent withdrawCollateral() for 448,331 gas (tx 0x18da6c0d5f93b52528f2e0adad7f8ee3022a95d4c8147e071e1c926d3671114d, status success) — nearly triple its configured floor, GAS_LIMIT.WITHDRAW_LOAN_COLLATERAL (150,000, useWithdrawCollateral.ts:42). That floor is a real, measured gap of the same shape this commit closes for the surplus surface, but it is out of this round's scope (raising a different surface's existing constant, not "no floor at all") and is left unchanged here — flagged in the round report for the owner to scope as a follow-up. Zero's close-credit-line floors (CLOSE_TROVE 350,000, CLOSE_DLLR_TROVE 600,000, useHandleTrove.ts) were not measured this round — the qa engine has no close-trove driver, and building one was judged out of scope for this round's time budget. Reasoning from the same code path: both close calls route through the same perimeter withdrawal-recording hook as Zero withdraw collateral, whose own delay-armed cost was already measured at 1,197,453 gas against its own 4,000,000 floor (fb37dd7). If close costs similarly, both close floors are likely undersized as fallback-only values the same way the borrower floor is — reasoned, not measured, and also flagged for the owner rather than changed blind. Added a test asserting the claim request's gasLimit equals GAS_LIMIT.CLAIM_SURPLUS. CI=true yarn test --watchAll=false src/app/3_organisms/TransactionStepDialog src/hooks/exitDelay src/constants, Node 20.19.0: 252/252 passing, 15/15 suites, run twice, identical (this command's own path filters do not reach ZeroPage; src/app/5_pages/ZeroPage run separately: 8/8 passing). eslint -c .eslintrc.js on all three touched files: 0 problems. tsc -p tsconfig.typecheck.json --noEmit: same 43 pre-existing errors as before this change, none in a touched file.
…med cost GAS_LIMIT.WITHDRAW_LOAN_COLLATERAL (150,000) was already wired through resolveGasLimit as a floor, but was never re-derived once the withdrawal delay could add ExitDelayQueue bookkeeping on top of the plain withdrawCollateral() call — the same gap LENDING_BURN's own raise closed last round for a different surface. Round 2's own confirmation step measured the delay-armed call at 448,331 gas (fork rskForkedMainnetQa, delay 120s, charge on, `perimeter:qa withdraw --surface borrower --as suspect1`, tx 0x18da6c0d5f93b52528f2e0adad7f8ee3022a95d4c8147e071e1c926d3671114d, status success) — nearly three times the old floor. A failed live estimate while the delay is armed would have sent with a limit under a third of what the call actually needs and reverted. Raised to 600,000 — the measured cost plus the same 30% margin the estimator applies to a live read (448,331 x 1.3 = 582,831 minimum), rounded up to the same round number CLOSE_DLLR_TROVE and CLAIM_SURPLUS already use. Two tests: a new gasLimits.test.ts encodes the invariant this round's own ruling states — a floor must be at least its measured delay-armed cost plus 30%, cited against the transaction it was measured from. Confirmed this test fails against the old 150,000 (expected >= 582,831, received 150,000) before raising the constant. A new useWithdrawCollateral.test.ts (none existed before) confirms the constant is actually the value sent as the request's gasLimit, the same wiring check used for the surplus floor last round. CI=true yarn test --watchAll=false src/app/3_organisms/TransactionStepDialog src/hooks/exitDelay src/constants, Node 20.19.0: 253/253 passing, 16/16 suites, run twice, identical (this command's own path filters do not reach BorrowPage; src/app/5_pages/BorrowPage run separately, twice: 18/18 passing both times). eslint -c .eslintrc.js on all three touched files: 0 problems. tsc -p tsconfig.typecheck.json --noEmit: same 43 pre-existing errors as before this change, none in a touched file.
…ed cost
GAS_LIMIT.CLOSE_TROVE (350,000) and CLOSE_DLLR_TROVE (600,000) were
already wired through resolveGasLimit as floors, same as the borrower
floor raised in the previous commit, but neither had been re-derived once
the withdrawal delay could add its own recording cost on top of the plain
call. Both close paths run through the same perimeter withdrawal hook as
Zero withdraw collateral (useHandleTrove.ts's handleTroveClose calls
notifyHold() the same way handleTroveSubmit's collateral-withdrawal branch
does, off the same useZeroExitDelayQuote() instance), so they are
delay-armed the same way ADJUST_TROVE already measured to be.
Measured directly on a fresh fork (rskForkedMainnetQa, `perimeter:qa up`,
delay 120s, charge on) via a one-off hardhat script run with `npx hardhat
run` against the qa engine's own attachQa/solventSigner helpers (the qa
engine has no close-trove driver of its own, so this round wrote one; the
script lived only under the repo's gitignored qa/ folder for the run and
was deleted afterward — no tracked file in the lending repo was touched):
- closeTrove() (ZUSD): opened a trove, funded the shortfall between what
the borrower receives and what closing requires (the origination fee)
from a second trove, called closeTrove(). 588,995 gas, status success,
tx 0xa88b4522443397efbdee69a63f2297ba9f354f85597c9399cb488abb755f7ddf.
- closeNueTroveWithPermit2() (DLLR): same setup via openNueTrove(),
approved Permit2, signed a real EIP-712 PermitTransferFrom (Permit2's
well-known canonical address, chain id 30) for the net debt, called
closeNueTroveWithPermit2() with that signature — the same call
useHandleTrove.ts's handleTroveClose sends via preparePermit2Transaction.
745,682 gas, status success, tx
0x4556ddca29569bf88b536e04e22680dc895eb400d0ef5943b52c1ca4ec7c2a7d.
CLOSE_TROVE raised to 800,000 (588,995 x 1.3 = 765,694 minimum, rounded up
to the same round number LENDING_BURN and WITHDRAW_LOAN_COLLATERAL's own
fallback constants land on). CLOSE_DLLR_TROVE raised to 1,000,000
(745,682 x 1.3 = 969,387 minimum, rounded up) — the DLLR path costs more
than the ZUSD one, consistent with the extra Permit2 signature
verification and token pull it does on top of the same close logic.
Tests: two new cases in gasLimits.test.ts assert each floor is at least
its measured cost plus 30%, cited against the transaction each was
measured from — confirmed both fail against the old 350,000/600,000
before raising the constants. Two new cases in useHandleTrove.test.ts
assert handleTroveClose('zusd') and handleTroveClose('dllr') each send
their respective new constant as the request's gasLimit — the DLLR case
needed loadLiquity/getPermitTransferFrom/prepareApproveTransaction/
preparePermit2Transaction mocked with real return shapes rather than the
suite's previous bare jest.fn()s, since nothing had exercised that branch
before.
Fork and qa/measure-close.js removed after: `PERIMETER_QA_PORT=8547
scripts/perimeter/qa-node.sh --stop`; `nc -z 127.0.0.1 8547/3000/3123` all
free afterward; `git status` in the lending repo clean.
CI=true yarn test --watchAll=false src/app/3_organisms/TransactionStepDialog
src/hooks/exitDelay src/constants, Node 20.19.0: 255/255 passing, 16/16
suites, run twice, identical (this command's own path filters do not
reach ZeroPage; src/app/5_pages/ZeroPage run separately, twice: 10/10
passing both times). eslint -c .eslintrc.js on all three touched files: 0
problems. tsc -p tsconfig.typecheck.json --noEmit: same 43 pre-existing
errors as before this change, none in a touched file.
The held-withdrawals page now lists only what is still pending. A row leaves the list the moment it is released or resolved away, and it never comes back: released rows are dropped from the session's own filtering at once, and any row the vault itself reports with a status other than Queued is dropped the same way on its next read, rather than shown with a label for what became of it. Frozen and blacklisted rows stay listed, since they are still Queued. Drop the History tab and its components, usePerimeterHistory and its receipts, the per-device remembered-id local storage keys and their read/write, and the PendingExitState values and translations that only ever served that display. Clear any local storage keys the retired history left behind the first time this page loads.
Reword the comments and one test title around the Perimeter page's local-storage cleanup to state what the code does now — removes keys under the perimeter/history/ prefix — rather than narrating the mechanism that wrote those keys. Rename clearLegacyHistoryKeys and its prefix constant to drop the same wording. Trim a "Settled" assertion that cannot distinguish anything today, since that label is not present anywhere in the app. Cover the connected account being only the receiver of the held withdrawal, alongside the existing position-owner case, in both drop-at-once tests: the row disappears after that account's own release completes, and after a read reports it resolved.
The requote timer's forced tick only bypassed useExitDelay's own outer cache entry; quoteExitDelay's inner, separately-cached on-chain read kept its own not-yet-expired answer regardless, because the tick's force never reached it. A form left open across the Owner arming the withdrawal delay could still show the stale, unarmed (often zero) delay for close to two cache lifetimes instead of the one the code's own comment promised — the screen omitting the withdrawal-delay notice a user would otherwise see before signing. quoteExitDelay now takes its own `force`, threaded into its inner cached read exactly the same way useExitDelay's timer already forces the outer one. useExitDelay's fetchQuote passes that flag through: `force: true` only from the timer's own tick, nothing on an ordinary call, so an unforced read still shares its answer with every other consumer of the same cache entry within its lifetime the way it always has. Added a test against useExitDelay's own cache proving the very next forced tick after arming shows the new delay, and a second test against quoteExitDelay itself, on the app's real cache, proving a forced call bypasses its own cached answer rather than only accepting and ignoring the flag. Also added the two gas-limit floor tests the permanent regression suite was missing for the Zero surplus-claim and lending-burn withdrawal surfaces, in the existing tests' own style — each floor's comment already states the measured cost and the 30% margin rule; only the mechanical assertion was absent for these two. CI=true yarn test --watchAll=false src/hooks/exitDelay src/app/3_organisms/TransactionStepDialog src/constants src/app/5_pages/PerimeterPage, Node 20.19.0: 295/295 passing, 17/17 suites. Full suite once: 544/544 passing, 47/47 suites. eslint -c .eslintrc.js on every touched file: 0 problems. tsc -p tsconfig.typecheck.json: same pre-existing errors as before this change, all in an untouched file, none in a touched one.
The top navigation no longer lists the Perimeter page; the account dropdown opens it, after History.
…rom user-facing text The account-menu entry reads "Perimeter" and the page title reads "Perimeter withdraw queue". Every sentence that called the place where held withdrawals sit a vault (delay tooltips, the form notice, the hold toast, the unreadable and frozen states, the release titles, the RPC override banner) now says "the Sovryn Perimeter withdraw queue". Governor Vault and Personal Reserve Vault strings are unrelated and unchanged. Translation keys are unchanged. The copy suite pins the new names and rejects "vault" on every Perimeter surface; the release notes follow.
The Releases column reads "3 min", "1 h 5 min" or "2 d 3 h", rounded up; under a minute it reads "< 1 min" and at unlock "Now". The chain-anchored clock advances every 15 seconds instead of every second: everything it drives is judged in whole minutes or against the latest block, so the Locked, Unlocking and Ready transitions stay correct within that step and the clock never runs ahead of the chain.
The notification provider sits above the Router, so the toast's router Link threw "useHref() may be used only in the context of a <Router>" while the toast was drawn and unmounted the whole app. The link is now a plain anchor whose href and navigation come from the hook's own router context, resolved where the caller is under the Router. It opens the Perimeter page inside the app on a plain click and leaves modified clicks to the browser. Tests draw the toast through the real notification provider with the Router only around the caller, in memory-, data- and hash-routed trees.
Retain the reviewed Perimeter UI, hooks and regression tests while excluding the local QA launcher, RPC override, banner and exclusive support. Apply the existing formatter to the endpoint test and translations; parsed TypeScript and JSON are unchanged. Strict types, affected tests and normal production build pass on this tree. Preserve the running QA release checkout.
🦋 Changeset detectedLatest commit: 690e5b6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
✅ Deploy Preview for sovryn-dapp ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
The Node 18 job for frontend PR 1152 failed because the rendered-menu test still expected Perimeter after the label became Perimeter queue. Update only that exact-text assertion; retain route, ordering and visibility coverage.
Public-user exits can grow the receiver index beyond the bounded page reader. Preserve fetched withdrawals but mark the result unknown whenever the final cursor still names unread entries. Cover both incomplete and exactly-complete 25,000-row boundary cases.
Wrap only the fresh checked-account signer so ethers v5 serializes the intended chainId without invalid Contract overrides or shared wallet mutation. Cover actual single and batch wallet requests, late chain/account refusal, receipt completion timing and a disabled-guard control.
Add optional trusted Rootstock deployment address and complete runtime-code hash inputs. Verify live backend chains and queue code, merge the validated fallback with consumer and tab-known queues, and retain Phase 1 defaults and unreadable warnings. Cover rollback, chain/code/config failures, deduplication and cache controls; document proxy-code verification limits.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem and behavior
When Perimeter holds an outgoing withdrawal, users need to see the expected delay, find the held funds and release them when eligible. This adds fee/delay information to lending withdrawals, borrower collateral exits and Zero withdrawal, close and surplus-claim flows, with a post-transaction notice linking to the Perimeter withdraw queue.
The account menu opens
/perimeter, which lists the connected account's held withdrawals and supports individual release and a single transaction for all eligible withdrawals. Readiness follows the latest chain block timestamp. Release checks revalidate the account, network, queue state and eligibility before sending; completed withdrawals are excluded from subsequent batches. Unavailable reads are represented as unknown rather than a confirmed zero fee or ready withdrawal. Configuration is discovered through the protocol/Zero contract pointers, and public Rootstock endpoints are retained.Validation
For head
680299b2c5cadd670a229e256be1f4d3da57dbf3, recorded validation on the final source tree includes:TSC_COMPILE_ON_ERROR.The preceding candidate also passed an empty frozen-lockfile offline install, the normal seven-task workspace build and 85 targeted tests. The final tree differs from that candidate only by formatting in two files, with parsed TypeScript/JSON equivalence verified; the final-tree build, typecheck and affected tests above were run after formatting. These are recorded local results, not a claim that GitHub CI or the entire frontend test suite has passed. No source changes or test reruns were made during PR creation.
Remaining qualification
Final production queue/controller addresses and protocol/Zero wiring still require qualification, followed by a normal-wallet smoke check against the production configuration and public RPC. Build and mocked regression results do not establish those checks. This PR is for code review; deployment and activation require separate authorization.