Skip to content

feat: Sovryn Perimeter Fee display on withdraw and close flows - #1151

Merged
tjcloa merged 16 commits into
developfrom
sovryn-perimeter-fee
Sep 4, 2026
Merged

feat: Sovryn Perimeter Fee display on withdraw and close flows#1151
tjcloa merged 16 commits into
developfrom
sovryn-perimeter-fee

Conversation

@tjcloa

@tjcloa tjcloa commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Sovryn Perimeter Fee — fee display on withdraw and close flows

Shows the perimeter fee (rate, amount, and net "You will receive") on lending
withdrawals, borrower exits, Zero collateral withdrawal/close, and the surplus-claim
view. Part of the Security Perimeter phase 1 release (SIP-0094 @ 340148c, sha256 496b69a6761f4e41d5e551f9c9f3962570beaba9e4aeb66613265c97fe09c756 — the shipping pin the three on-chain proposals carry in their descriptions).

Dormant until governance acts

Display is gated purely on on-chain state and fails hidden: a fee row renders only
when the deployed ExitFeeController quotes an active policy with a non-zero rate and
fee for that surface. Until SIP-0094 executes and charging is enabled, every form
renders exactly as it does on develop today — no feature flag, no env var, nothing to
configure. Once charging is enabled on-chain, the display lights up on its own (quotes
are cached for 30 seconds).

Merge timing — opposite of the contract PRs, deliberately

The contract PRs (Sovryn-smart-contracts#580, zero-contracts#10) merge only after
the SIP executes. This PR should merge and deploy before charging is enabled: the
UI is provably inert until then, and having it live first guarantees users see the fee
the moment it exists. The one ordering that must not happen is enabling charging while
the dapp still lacks this UI — fees would apply without being displayed.

Notes for reviewers

  • All user-facing copy says "Perimeter fee" (renamed from the internal working title
    during this port; tests 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.
  • Rebased onto current develop.
  • Affected tests: 11 suites / 38 tests green locally via the pre-commit hook.
  • The Spanish locale remains the app-wide English-fallback stub; no fee-specific
    regression there.

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.
@netlify

netlify Bot commented Aug 13, 2026

Copy link
Copy Markdown

Deploy Preview for sovryn-dapp ready!

Name Link
🔨 Latest commit 9a1f8fd
🔍 Latest deploy log https://app.netlify.com/projects/sovryn-dapp/deploys/6a9a81ebe1f69f00085703a5
😎 Deploy Preview https://deploy-preview-1151.preview.sovryns.dev
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9a1f8fd

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
frontend Minor

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

tjcloa added 15 commits August 14, 2026 01:15
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".
`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.
@tjcloa
tjcloa merged commit d748a54 into develop Sep 4, 2026
8 checks passed
@tjcloa
tjcloa deleted the sovryn-perimeter-fee branch September 4, 2026 09:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant