Skip to content

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

Open
tjcloa wants to merge 9 commits into
developfrom
sovryn-perimeter-fee
Open

feat: Sovryn Perimeter Fee display on withdraw and close flows#1151
tjcloa wants to merge 9 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).

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. The Trezor connectSrc sanitizer hardening that
    travelled with the original branch is not in this PR — it already landed on
    develop independently (d96a4247).
  • 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 failed. Why did it fail? →

Name Link
🔨 Latest commit 90ab5c0
🔍 Latest deploy log https://app.netlify.com/projects/sovryn-dapp/deploys/6a8869b21a15470008642811

@changeset-bot

changeset-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 90ab5c0

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 8 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".
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