From f4ba0f639909970b3d212c406c0b3eaddd18933a Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 08:51:28 +0200 Subject: [PATCH 01/12] docs: design position adjustment control visibility --- ...sition-adjust-control-visibility-design.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-position-adjust-control-visibility-design.md diff --git a/docs/superpowers/specs/2026-07-16-position-adjust-control-visibility-design.md b/docs/superpowers/specs/2026-07-16-position-adjust-control-visibility-design.md new file mode 100644 index 000000000..36ee7f0f4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-position-adjust-control-visibility-design.md @@ -0,0 +1,57 @@ +# Position adjustment control visibility + +## Context + +The legacy collateral and loan panels currently derive `canAdjustCollateral` and `canAdjustLoan` from `getSafeSliderBounds(...).hasMovableRange`. Both panels use those flags to decide whether to render their adjustment controls and whether the adjustment handlers may enter editing mode. + +This hides the controls whenever the slider range is collapsed, including valid legacy positions whose deposits or borrowing increases are restricted. Slider movability is a rendering-safety concern and must not determine whether a user can open the position editor. + +## Goal + +Keep the collateral and loan adjustment controls visible for existing positions and allow users to enter adjustment mode, regardless of whether the slider has a movable range. + +## Panel behavior + +### Collateral + +- Render the adjustment controls whenever a wallet is connected and the selected collateral position has a positive total. +- Let the adjustment handler enter editing mode without checking slider movability. +- Keep the slider disabled when its normalized range is not movable. +- Keep the deposited amount field editable in adjustment mode so the user can enter a value directly. + +### Loan + +- Render the adjustment controls for the connected account whenever the loan panel is displaying an existing position. +- Let the adjustment handler enter editing mode without checking slider movability. +- Keep the slider disabled when its normalized range is not movable. +- Keep the borrowed amount field editable in adjustment mode so the user can enter a value directly. + +## Restrictions and safety + +This change only decouples control visibility and editor entry from slider movability. It does not change: + +- normalized noUiSlider bounds; +- debt-ceiling eligibility calculations; +- action maximums; +- confirmation guards for disabled collateral deposits or debt increases; +- transaction-handler guards; +- contract enforcement of collateral, debt, and balance requirements. + +The frontend continues to fail closed for explicitly disabled increases. Contracts remain authoritative for all other position constraints. + +## Testing + +Add focused regression assertions that: + +- neither panel gates its adjustment controls on `hasMovableRange`; +- neither adjustment handler returns early because its slider range is collapsed; +- both sliders continue to use `hasMovableRange` only for their disabled state; +- the existing increase and transaction guards remain present. + +Run the focused collateral eligibility regression suite followed by the workspace TypeScript check and production build. + +## Out of scope + +- Re-enabling deposits or borrowing for zero-ceiling collateral. +- Changing contract configuration or transaction behavior. +- Redesigning either panel or adding new warning messages. From 01170796bbcd9ca91b582f71e4c80bf141f990e5 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 08:53:30 +0200 Subject: [PATCH 02/12] docs: plan position adjustment control visibility --- ...7-16-position-adjust-control-visibility.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-position-adjust-control-visibility.md diff --git a/docs/superpowers/plans/2026-07-16-position-adjust-control-visibility.md b/docs/superpowers/plans/2026-07-16-position-adjust-control-visibility.md new file mode 100644 index 000000000..5eb591d48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-position-adjust-control-visibility.md @@ -0,0 +1,153 @@ +# Position Adjustment Control Visibility Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep the collateral and loan adjustment controls visible and enterable for existing positions without weakening slider safety or disabled-increase transaction guards. + +**Architecture:** Decouple the panels' control rendering and adjustment handlers from `SafeSliderBounds.hasMovableRange`. Continue using normalized bounds only to disable noUiSlider when it cannot move, while preserving direct amount entry, confirmation guards, transaction guards, and contract enforcement. + +**Tech Stack:** React, TypeScript, BigNumber.js, noUiSlider, Node.js built-in test runner, pnpm/Turborepo. + +## Global Constraints + +- Existing collateral positions expose their adjustment controls whenever a wallet is connected and the selected position has a positive total. +- Existing loan positions expose their adjustment controls whenever the loan panel is rendered for a connected account. +- Collapsed slider ranges do not prevent entering adjustment mode. +- Collapsed or invalid slider ranges remain disabled and receive only normalized noUiSlider options. +- Debt-ceiling eligibility, action maximums, confirmation guards, transaction guards, and contract behavior remain unchanged. +- Do not add new interface copy or redesign either panel. + +--- + +### Task 1: Decouple adjustment controls from slider movability + +**Files:** +- Modify: `apps/web/tests/collateralEligibility.test.ts` +- Modify: `apps/web/src/app/components/home/CollateralPanel.tsx` +- Modify: `apps/web/src/app/components/home/LoanPanel.tsx` + +**Interfaces:** +- Consumes: `SafeSliderBounds.hasMovableRange` from `apps/web/src/store/collateral/eligibility.ts` for noUiSlider's disabled state only. +- Produces: Collateral and loan panel controls whose visibility and click handlers do not depend on slider movability. + +- [ ] **Step 1: Write the failing control-visibility regression test** + +Append this test to `apps/web/tests/collateralEligibility.test.ts`: + +```ts +test('position adjustment controls are independent of slider movability', async () => { + const collateralPanelSource = await readFile( + new URL('../src/app/components/home/CollateralPanel.tsx', import.meta.url), + 'utf8', + ); + const loanPanelSource = await readFile(new URL('../src/app/components/home/LoanPanel.tsx', import.meta.url), 'utf8'); + + assert.doesNotMatch(collateralPanelSource, /const canAdjustCollateral =/); + assert.doesNotMatch(collateralPanelSource, /if \(!canAdjustCollateral\) return/); + assert.match(collateralPanelSource, /account && collateralTotal\?\.isGreaterThan\(0\) && \(/); + assert.match(collateralPanelSource, /disabled=\{!isAdjusting \|\| !collateralSliderBounds\.hasMovableRange\}/); + + assert.doesNotMatch(loanPanelSource, /const canAdjustLoan =/); + assert.doesNotMatch(loanPanelSource, /if \(!canAdjustLoan\) return/); + assert.match(loanPanelSource, /\{account && \(/); + assert.match(loanPanelSource, /disabled=\{!isAdjusting \|\| !loanSliderBounds\.hasMovableRange\}/); +}); +``` + +- [ ] **Step 2: Run the focused suite and verify RED** + +Run: + +```bash +/Users/hetfly/.nvm/versions/node/v22.21.1/bin/node \ + --disable-warning=MODULE_TYPELESS_PACKAGE_JSON \ + --test apps/web/tests/collateralEligibility.test.ts +``` + +Expected: the new test fails because both panels still declare `canAdjust*`, both handlers still return early, and both control containers still include the `canAdjust*` render condition. + +- [ ] **Step 3: Remove collateral control gating** + +In `apps/web/src/app/components/home/CollateralPanel.tsx`, delete: + +```ts +const canAdjustCollateral = collateralSliderBounds.hasMovableRange; +``` + +Change the adjustment handler to: + +```ts +const handleEnableAdjusting = () => { + adjust(true); + adjustLoan(false); +}; +``` + +Change the control render condition to: + +```tsx +{account && collateralTotal?.isGreaterThan(0) && ( +``` + +Keep this slider safety condition unchanged: + +```tsx +disabled={!isAdjusting || !collateralSliderBounds.hasMovableRange} +``` + +- [ ] **Step 4: Remove loan control gating** + +In `apps/web/src/app/components/home/LoanPanel.tsx`, delete: + +```ts +const canAdjustLoan = loanSliderBounds.hasMovableRange; +``` + +Change the adjustment handler to: + +```ts +const handleEnableAdjusting = () => { + adjust(true); + adjustCollateral(false); +}; +``` + +Change the control render condition to: + +```tsx +{account && ( +``` + +Keep this slider safety condition unchanged: + +```tsx +disabled={!isAdjusting || !loanSliderBounds.hasMovableRange} +``` + +- [ ] **Step 5: Run the focused suite and verify GREEN** + +Run the Node 22 command from Step 2. + +Expected: all 13 tests pass, including the new control-visibility regression test and the existing transaction-guard tests. + +- [ ] **Step 6: Run workspace verification** + +Run: + +```bash +pnpm checkTs +pnpm build +git diff --check +``` + +Expected: all six TypeScript tasks succeed, the production web build completes, and `git diff --check` reports no whitespace errors. + +- [ ] **Step 7: Commit the implementation** + +```bash +git add \ + apps/web/tests/collateralEligibility.test.ts \ + apps/web/src/app/components/home/CollateralPanel.tsx \ + apps/web/src/app/components/home/LoanPanel.tsx +git commit -m "fix: keep position adjustment controls visible" +``` From 19924bd0977c1e9e596c848257622b8e4d544fd7 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 08:55:42 +0200 Subject: [PATCH 03/12] fix: keep position adjustment controls visible --- .../app/components/home/CollateralPanel.tsx | 4 +--- apps/web/src/app/components/home/LoanPanel.tsx | 4 +--- apps/web/tests/collateralEligibility.test.ts | 18 ++++++++++++++++++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/app/components/home/CollateralPanel.tsx b/apps/web/src/app/components/home/CollateralPanel.tsx index 3ac55bc3c..4d14017d2 100644 --- a/apps/web/src/app/components/home/CollateralPanel.tsx +++ b/apps/web/src/app/components/home/CollateralPanel.tsx @@ -175,7 +175,6 @@ const CollateralPanel = () => { tLockedAmount, collateralDecimalPlaces, ); - const canAdjustCollateral = collateralSliderBounds.hasMovableRange; const percent = collateralTotal.isZero() ? 0 : tLockedAmount.div(collateralTotal).times(100).toNumber(); const [ICXWithdrawOption, setICXWithdrawOption] = useState(ICXWithdrawOptions.KEEPSICX); const { data: icxUnstakingTime } = useICXUnstakingTime(); @@ -203,7 +202,6 @@ const CollateralPanel = () => { }); const handleEnableAdjusting = () => { - if (!canAdjustCollateral) return; adjust(true); adjustLoan(false); }; @@ -414,7 +412,7 @@ const CollateralPanel = () => { - {account && collateralTotal?.isGreaterThan(0) && canAdjustCollateral && ( + {account && collateralTotal?.isGreaterThan(0) && ( {isAdjusting ? ( <> diff --git a/apps/web/src/app/components/home/LoanPanel.tsx b/apps/web/src/app/components/home/LoanPanel.tsx index c0223fb93..167b4a5d6 100644 --- a/apps/web/src/app/components/home/LoanPanel.tsx +++ b/apps/web/src/app/components/home/LoanPanel.tsx @@ -71,7 +71,6 @@ const LoanPanel = () => { const activeLoanAccount = useActiveLoanAddress(); const usedAmount = useLoanUsedAmount(activeLoanAccount); const loanSliderBounds = getSafeSliderBounds(borrowedAmount, actionMaximum, usedAmount, 2); - const canAdjustLoan = loanSliderBounds.hasMovableRange; const { isAdjusting, inputType } = useLoanState(); @@ -111,7 +110,6 @@ const LoanPanel = () => { const addTransaction = useTransactionAdder(); const handleEnableAdjusting = () => { - if (!canAdjustLoan) return; adjust(true); adjustCollateral(false); }; @@ -282,7 +280,7 @@ const LoanPanel = () => { Loan - {account && canAdjustLoan && ( + {account && ( {isAdjusting ? ( <> diff --git a/apps/web/tests/collateralEligibility.test.ts b/apps/web/tests/collateralEligibility.test.ts index 2838a7a3e..dc0fdd0de 100644 --- a/apps/web/tests/collateralEligibility.test.ts +++ b/apps/web/tests/collateralEligibility.test.ts @@ -202,3 +202,21 @@ test('collateral and loan panels use safe slider bounds', async () => { assert.match(loanPanelSource, /loanSliderBounds\.padding/); assert.match(loanPanelSource, /loanSliderBounds\.hasMovableRange/); }); + +test('position adjustment controls are independent of slider movability', async () => { + const collateralPanelSource = await readFile( + new URL('../src/app/components/home/CollateralPanel.tsx', import.meta.url), + 'utf8', + ); + const loanPanelSource = await readFile(new URL('../src/app/components/home/LoanPanel.tsx', import.meta.url), 'utf8'); + + assert.doesNotMatch(collateralPanelSource, /const canAdjustCollateral =/); + assert.doesNotMatch(collateralPanelSource, /if \(!canAdjustCollateral\) return/); + assert.match(collateralPanelSource, /account && collateralTotal\?\.isGreaterThan\(0\) && \(/); + assert.match(collateralPanelSource, /disabled=\{!isAdjusting \|\| !collateralSliderBounds\.hasMovableRange\}/); + + assert.doesNotMatch(loanPanelSource, /const canAdjustLoan =/); + assert.doesNotMatch(loanPanelSource, /if \(!canAdjustLoan\) return/); + assert.match(loanPanelSource, /\{account && \(/); + assert.match(loanPanelSource, /disabled=\{!isAdjusting \|\| !loanSliderBounds\.hasMovableRange\}/); +}); From 8da4dfc61928ae9880ff0d74e0439299f0e7dd1e Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 09:16:05 +0200 Subject: [PATCH 04/12] docs: design exit slider interaction hotfix --- ...26-07-16-exit-slider-interaction-design.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-exit-slider-interaction-design.md diff --git a/docs/superpowers/specs/2026-07-16-exit-slider-interaction-design.md b/docs/superpowers/specs/2026-07-16-exit-slider-interaction-design.md new file mode 100644 index 000000000..24dc5054c --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-exit-slider-interaction-design.md @@ -0,0 +1,49 @@ +# Exit slider interaction + +## Context + +The zero-ceiling slider hardening added `SafeSliderBounds.hasMovableRange` and used it to disable both the collateral and loan sliders. A collapsed frontend-derived range therefore prevents all slider interaction, even though `getSafeSliderBounds()` already returns safe zero padding for that state and the contracts remain authoritative for withdrawals and repayments. + +Before this hardening, both sliders were disabled only when their panel was not in adjustment mode. Their protected padding calculations are older behavior and must remain intact. + +## Goal + +Restore slider interaction for collateral withdrawals and loan repayments without removing long-standing padding behavior or weakening numeric noUiSlider safety. + +## Behavior + +- The collateral slider is disabled only when `isAdjusting` is false. +- The loan slider is disabled only when `isAdjusting` is false. +- Both sliders continue to consume normalized `start`, `maximum`, and `padding` values from `getSafeSliderBounds()`. +- A collapsed protected range continues to receive zero padding, making the normalized slider interactive instead of disabling it. +- Zero-ceiling positions retain `maximum = current position`, so the slider can decrease the position but cannot increase it. +- Existing deposit and borrowing confirmation and transaction guards remain unchanged. +- Contracts continue to enforce withdrawal, repayment, collateralization, and balance requirements. + +## Helper cleanup + +Remove `hasMovableRange` from `SafeSliderBounds` and from every return value of `getSafeSliderBounds()`. It has no remaining production consumer after the panel fix, and keeping it available risks reintroducing frontend exit gating. + +Keep the helper's current inputs and its numeric normalization behavior unchanged: + +- valid ranges preserve the requested protected padding; +- collapsed protected ranges use `[0, 0]` padding; +- invalid or non-finite ranges use the safe fallback start, maximum, and padding. + +## Testing + +Update the focused eligibility tests to assert only `start`, `maximum`, and `padding`. Add source-level regression assertions that: + +- neither production panel references `hasMovableRange`; +- both noUiSlider instances use `disabled={!isAdjusting}`; +- both panels continue to consume normalized start, maximum, and padding; +- disabled-increase confirmation and transaction guards remain present. + +Run the focused regression suite, workspace TypeScript checks, production build, and an interactive browser check of both adjustment panels. + +## Out of scope + +- Removing or changing the long-standing collateral lock padding. +- Removing or changing the long-standing loan repayment padding. +- Changing debt-ceiling eligibility or contract calls. +- Redesigning the lock and repayable indicators. From 065505ed0fc1767d94b2b50684bfba9fc80f4bb1 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 09:35:53 +0200 Subject: [PATCH 05/12] docs: plan exit slider interaction hotfix --- .../2026-07-16-exit-slider-interaction.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-exit-slider-interaction.md diff --git a/docs/superpowers/plans/2026-07-16-exit-slider-interaction.md b/docs/superpowers/plans/2026-07-16-exit-slider-interaction.md new file mode 100644 index 000000000..15eef8728 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-exit-slider-interaction.md @@ -0,0 +1,165 @@ +# Exit Slider Interaction Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restore collateral withdrawal and loan repayment slider interaction by removing the recently added frontend movability gate. + +**Architecture:** Keep `getSafeSliderBounds()` responsible for normalized noUiSlider start, maximum, and padding values, including its existing collapsed-range fallback. Remove `hasMovableRange` from the helper contract and make both sliders depend only on panel adjustment state for their disabled state. + +**Tech Stack:** React, TypeScript, BigNumber.js, noUiSlider, Node.js built-in test runner, pnpm/Turborepo. + +## Global Constraints + +- Preserve the long-standing collateral lock padding calculation. +- Preserve the long-standing loan repayment padding calculation. +- Preserve normalized noUiSlider start, maximum, and fallback values. +- Collateral and loan sliders are disabled only when `isAdjusting` is false. +- Remove `hasMovableRange` from all production code. +- Preserve zero-ceiling action maximums, disabled-increase guards, and contract calls. +- Do not redesign either panel or its lock indicators. + +--- + +### Task 1: Remove frontend exit slider gating + +**Files:** +- Modify: `apps/web/tests/collateralEligibility.test.ts` +- Modify: `apps/web/src/store/collateral/eligibility.ts` +- Modify: `apps/web/src/app/components/home/CollateralPanel.tsx` +- Modify: `apps/web/src/app/components/home/LoanPanel.tsx` + +**Interfaces:** +- Consumes: `getSafeSliderBounds(currentAmount, maximum, requestedMinimum, decimalPlaces)`. +- Produces: `SafeSliderBounds = { start: number; maximum: number; padding: [number, number] }` and sliders disabled only by `isAdjusting`. + +- [ ] **Step 1: Change the focused tests to the required behavior** + +In `apps/web/tests/collateralEligibility.test.ts`, remove every `hasMovableRange` property from the expected `getSafeSliderBounds()` results. + +Change the panel wiring assertions to: + +```ts +test('collateral and loan panels use safe slider bounds without frontend movability gating', async () => { + const collateralPanelSource = await readFile( + new URL('../src/app/components/home/CollateralPanel.tsx', import.meta.url), + 'utf8', + ); + const loanPanelSource = await readFile(new URL('../src/app/components/home/LoanPanel.tsx', import.meta.url), 'utf8'); + + assert.match(collateralPanelSource, /collateralSliderBounds\.padding/); + assert.match(collateralPanelSource, /start=\{collateralSliderBounds\.start\}/); + assert.match(collateralPanelSource, /max: \[collateralSliderBounds\.maximum\]/); + assert.match(collateralPanelSource, /disabled=\{!isAdjusting\}/); + + assert.match(loanPanelSource, /loanSliderBounds\.padding/); + assert.match(loanPanelSource, /start=\{\[loanSliderBounds\.start\]\}/); + assert.match(loanPanelSource, /max: \[loanSliderBounds\.maximum\]/); + assert.match(loanPanelSource, /disabled=\{!isAdjusting\}/); + + assert.doesNotMatch(collateralPanelSource, /hasMovableRange/); + assert.doesNotMatch(loanPanelSource, /hasMovableRange/); +}); +``` + +Extend the test to read `../src/store/collateral/eligibility.ts` and assert: + +```ts +assert.doesNotMatch(eligibilitySource, /hasMovableRange/); +``` + +Keep the existing assertions that adjustment controls remain visible and handlers do not return early, but replace their slider-disabled assertions with `disabled={!isAdjusting}`. + +- [ ] **Step 2: Run the focused suite and verify RED** + +Run: + +```bash +/Users/hetfly/.nvm/versions/node/v22.21.1/bin/node \ + --disable-warning=MODULE_TYPELESS_PACKAGE_JSON \ + --test apps/web/tests/collateralEligibility.test.ts +``` + +Expected: FAIL because helper results still contain `hasMovableRange`, both panels still reference it, and both sliders still include it in their disabled expressions. + +- [ ] **Step 3: Remove `hasMovableRange` from the helper contract** + +Change `SafeSliderBounds` in `apps/web/src/store/collateral/eligibility.ts` to: + +```ts +export type SafeSliderBounds = { + start: number; + maximum: number; + padding: [number, number]; +}; +``` + +Remove `hasMovableRange` from the invalid fallback, collapsed fallback, and valid return objects. Do not change any calculations, arguments, or other returned values. + +- [ ] **Step 4: Restore adjustment-only slider disabling** + +In `apps/web/src/app/components/home/CollateralPanel.tsx`, change: + +```tsx +disabled={!isAdjusting || !collateralSliderBounds.hasMovableRange} +``` + +to: + +```tsx +disabled={!isAdjusting} +``` + +In `apps/web/src/app/components/home/LoanPanel.tsx`, change: + +```tsx +disabled={!isAdjusting || !loanSliderBounds.hasMovableRange} +``` + +to: + +```tsx +disabled={!isAdjusting} +``` + +Do not change either panel's `padding`, `start`, `maximum`, action maximum, confirmation guards, or transaction handlers. + +- [ ] **Step 5: Run the focused suite and verify GREEN** + +Run the Node 22 command from Step 2. + +Expected: all 13 tests pass, including safe fallback, position visibility, disabled-increase guard, and slider-interaction coverage. + +- [ ] **Step 6: Run workspace verification** + +Run: + +```bash +pnpm checkTs +pnpm build +git diff --check +``` + +Expected: all six TypeScript checks pass, the production web build succeeds, and no whitespace errors are reported. Existing dependency and Browserslist warnings may remain unchanged. + +- [ ] **Step 7: Verify the production bundle wiring** + +Inspect the generated web assets and confirm the collateral and loan noUiSlider instances retain normalized padding/start/maximum wiring without a movability-based disabled condition: + +```bash +rg -n "hasMovableRange" apps/web/dist apps/web/src/store/collateral/eligibility.ts \ + apps/web/src/app/components/home/CollateralPanel.tsx \ + apps/web/src/app/components/home/LoanPanel.tsx +``` + +Expected: no matches. + +- [ ] **Step 8: Commit the hotfix** + +```bash +git add \ + apps/web/tests/collateralEligibility.test.ts \ + apps/web/src/store/collateral/eligibility.ts \ + apps/web/src/app/components/home/CollateralPanel.tsx \ + apps/web/src/app/components/home/LoanPanel.tsx +git commit -m "fix: restore exit slider interaction" +``` From 824ebcde1a4cf630d0ebb548082fcaed13e43987 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 09:40:15 +0200 Subject: [PATCH 06/12] fix: restore exit slider interaction --- .../app/components/home/CollateralPanel.tsx | 2 +- .../web/src/app/components/home/LoanPanel.tsx | 2 +- apps/web/src/store/collateral/eligibility.ts | 4 --- apps/web/tests/collateralEligibility.test.ts | 30 +++++++++---------- 4 files changed, 17 insertions(+), 21 deletions(-) diff --git a/apps/web/src/app/components/home/CollateralPanel.tsx b/apps/web/src/app/components/home/CollateralPanel.tsx index 4d14017d2..a0e6371fb 100644 --- a/apps/web/src/app/components/home/CollateralPanel.tsx +++ b/apps/web/src/app/components/home/CollateralPanel.tsx @@ -469,7 +469,7 @@ const CollateralPanel = () => { { { start: 605.31, maximum: 605.31, padding: [500, 0], - hasMovableRange: true, }); assert.deepEqual( getSafeSliderBounds(new BigNumber('237.736999'), new BigNumber('237.736999'), new BigNumber('32.97'), 6), @@ -89,7 +85,6 @@ test('preserves valid repayment and withdrawal slider ranges', async () => { start: 237.736999, maximum: 237.736999, padding: [32.97, 0], - hasMovableRange: true, }, ); }); @@ -101,7 +96,6 @@ test('fails closed with safe numeric options for invalid slider bounds', async ( start: 0, maximum: 0.001, padding: [0, 0], - hasMovableRange: false, }; assert.deepEqual(getSafeSliderBounds(new BigNumber(0), new BigNumber(0), new BigNumber(0), 2), fallbackBounds); @@ -122,13 +116,11 @@ test('fails closed with safe numeric options for invalid slider bounds', async ( start: 10, maximum: 20, padding: [0, 0], - hasMovableRange: false, }); assert.deepEqual(getSafeSliderBounds(new BigNumber(10), new BigNumber(20), new BigNumber(Number.NaN), 2), { start: 10, maximum: 20, padding: [0, 0], - hasMovableRange: false, }); }); @@ -188,19 +180,27 @@ test('collateral and loan transaction handlers guard disabled increases', async assert.match(xLoanModal, /if \(storedModalValues\.action === XLoanAction\.BORROW && !increaseEnabled\) return/); }); -test('collateral and loan panels use safe slider bounds', async () => { +test('collateral and loan panels use safe slider bounds without frontend movability gating', async () => { const collateralPanelSource = await readFile( new URL('../src/app/components/home/CollateralPanel.tsx', import.meta.url), 'utf8', ); const loanPanelSource = await readFile(new URL('../src/app/components/home/LoanPanel.tsx', import.meta.url), 'utf8'); + const eligibilitySource = await readFile(new URL('../src/store/collateral/eligibility.ts', import.meta.url), 'utf8'); - assert.match(collateralPanelSource, /getSafeSliderBounds/); assert.match(collateralPanelSource, /collateralSliderBounds\.padding/); - assert.match(collateralPanelSource, /collateralSliderBounds\.hasMovableRange/); - assert.match(loanPanelSource, /getSafeSliderBounds/); + assert.match(collateralPanelSource, /start=\{collateralSliderBounds\.start\}/); + assert.match(collateralPanelSource, /max: \[collateralSliderBounds\.maximum\]/); + assert.match(collateralPanelSource, /disabled=\{!isAdjusting\}/); + assert.match(loanPanelSource, /loanSliderBounds\.padding/); - assert.match(loanPanelSource, /loanSliderBounds\.hasMovableRange/); + assert.match(loanPanelSource, /start=\{\[loanSliderBounds\.start\]\}/); + assert.match(loanPanelSource, /max: \[loanSliderBounds\.maximum\]/); + assert.match(loanPanelSource, /disabled=\{!isAdjusting\}/); + + assert.doesNotMatch(collateralPanelSource, /hasMovableRange/); + assert.doesNotMatch(loanPanelSource, /hasMovableRange/); + assert.doesNotMatch(eligibilitySource, /hasMovableRange/); }); test('position adjustment controls are independent of slider movability', async () => { @@ -213,10 +213,10 @@ test('position adjustment controls are independent of slider movability', async assert.doesNotMatch(collateralPanelSource, /const canAdjustCollateral =/); assert.doesNotMatch(collateralPanelSource, /if \(!canAdjustCollateral\) return/); assert.match(collateralPanelSource, /account && collateralTotal\?\.isGreaterThan\(0\) && \(/); - assert.match(collateralPanelSource, /disabled=\{!isAdjusting \|\| !collateralSliderBounds\.hasMovableRange\}/); + assert.match(collateralPanelSource, /disabled=\{!isAdjusting\}/); assert.doesNotMatch(loanPanelSource, /const canAdjustLoan =/); assert.doesNotMatch(loanPanelSource, /if \(!canAdjustLoan\) return/); assert.match(loanPanelSource, /\{account && \(/); - assert.match(loanPanelSource, /disabled=\{!isAdjusting \|\| !loanSliderBounds\.hasMovableRange\}/); + assert.match(loanPanelSource, /disabled=\{!isAdjusting\}/); }); From 0edf819ab4a26bf56fafdb8b81c5e6cda9e89493 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 10:50:15 +0200 Subject: [PATCH 07/12] docs: design position floor guardrails --- ...-07-16-position-floor-guardrails-design.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-16-position-floor-guardrails-design.md diff --git a/docs/superpowers/specs/2026-07-16-position-floor-guardrails-design.md b/docs/superpowers/specs/2026-07-16-position-floor-guardrails-design.md new file mode 100644 index 000000000..6b563e832 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-position-floor-guardrails-design.md @@ -0,0 +1,61 @@ +# Position Floor Guardrails Design + +## Problem + +The collateral and loan sliders are interactive again, but the normalized slider helper returns zero lower padding when a protected minimum consumes the available range. The removed `hasMovableRange` flag previously masked that behavior by disabling the slider. Without the flag, users can drag collateral below the displayed **Locked** threshold or debt below the displayed **Repayable** threshold. + +Manual input has the same gap. The active `Deposited` and `Borrowed` fields only enforce a maximum, and the complementary `Wallet` and `Available` fields can indirectly calculate a protected position below the displayed threshold. + +## Requirements + +- Keep the collateral and loan sliders interactive whenever their panels are in adjusting mode. +- Prevent the collateral slider from moving below the displayed Locked threshold. +- Prevent the loan slider from moving below the displayed Repayable threshold. +- Clamp direct manual input in Deposited and Borrowed to the same normalized minimum. +- Clamp complementary manual input in Wallet and Available so the calculated protected position cannot fall below that minimum. +- Keep `hasMovableRange` removed. +- Preserve the existing debt-ceiling increase restrictions, transaction guards, safety buffers, rounding precision, and contract enforcement. + +## Design + +### Shared normalized bounds + +`getSafeSliderBounds` remains the single source of truth for the slider start, maximum, and protected minimum. Its result will expose the normalized `minimum` used by both noUiSlider and the manual fields. + +For a valid requested minimum below the maximum, the helper will round it to the panel precision, cap it at the current position, and return it as both `minimum` and the left slider padding. + +When the protected minimum is equal to or above the maximum, the helper will clamp the minimum to the current position instead of returning zero padding. This recreates the historical lock floor without reintroducing a frontend movability flag. If the requested minimum is invalid while the current position and maximum are valid, the helper will fail closed at the current position. Fully invalid slider inputs retain the existing safe numeric fallback. + +### Slider behavior + +Both panels continue to use `disabled={!isAdjusting}`. Their noUiSlider instances consume `bounds.padding`, so the handle cannot cross the normalized minimum. A collapsed range remains mounted and safe, but its handle cannot move below the current position. + +### Manual input behavior + +The active fields receive `minValue` from the normalized minimum: + +- Collateral `Deposited >= collateral minimum` +- Loan `Borrowed >= loan minimum` + +The complementary fields receive a calculated maximum: + +- `Wallet <= collateralTotal - collateral minimum` +- `Available <= borrowableAmountWithReserve - loan minimum` + +The calculated maxima are floored at zero. `CurrencyField` already clamps values through `minValue` and `maxValue`, so this keeps direct and complementary typing aligned with the slider without adding reducer state or duplicating input handlers. + +## Testing + +- Prove the normalized helper preserves a valid protected floor. +- Prove a minimum that consumes the range clamps to the current position rather than zero. +- Prove an invalid requested minimum fails closed at the current position when other bounds are valid. +- Prove both sliders remain gated only by `isAdjusting` and consume normalized padding. +- Prove Deposited and Borrowed use the normalized minimum. +- Prove Wallet and Available use complementary maxima derived from that minimum. +- Run the focused eligibility tests, repository TypeScript checks, formatting/lint hooks, and production build. + +## Out of Scope + +- Reintroducing `hasMovableRange` or hiding/disabling adjustment controls based on debt ceilings. +- Changing contract calls, debt-ceiling rules, locking ratios, repayable calculations, or safety-buffer percentages. +- Redesigning the panels or lock indicators. From bfea73f22cc9bc709b664b15f19be83b94106f18 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 10:56:02 +0200 Subject: [PATCH 08/12] docs: plan position floor guardrails --- .../2026-07-16-position-floor-guardrails.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-16-position-floor-guardrails.md diff --git a/docs/superpowers/plans/2026-07-16-position-floor-guardrails.md b/docs/superpowers/plans/2026-07-16-position-floor-guardrails.md new file mode 100644 index 000000000..958aa78f5 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-position-floor-guardrails.md @@ -0,0 +1,226 @@ +# Position Floor Guardrails Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Keep collateral and loan adjustment controls interactive while preventing slider or manual input from moving a position below its displayed Locked or Repayable threshold. + +**Architecture:** Extend the existing normalized slider-bounds result with a numeric `minimum` and fail closed at the current position when the requested floor consumes the range or is invalid. Both panels reuse that minimum for noUiSlider padding, direct-field minimums, and complementary-field maximums. + +**Tech Stack:** React 18, TypeScript, BigNumber.js, noUiSlider 14, Node.js test runner, pnpm/Turborepo. + +## Global Constraints + +- Keep both sliders disabled only when `!isAdjusting`; do not reintroduce `hasMovableRange`. +- Preserve existing debt-ceiling increase restrictions, transaction guards, locking/repayable calculations, safety buffers, and decimal precision. +- Use one normalized minimum for the slider and both manual-input directions in each panel. +- Fully invalid slider inputs retain safe finite fallback options. + +--- + +### Task 1: Preserve protected floors in normalized slider bounds + +**Files:** +- Modify: `apps/web/src/store/collateral/eligibility.ts:28-86` +- Test: `apps/web/tests/collateralEligibility.test.ts:49-124` + +**Interfaces:** +- Consumes: `getSafeSliderBounds(currentAmount: BigNumber, maximum: BigNumber, requestedMinimum: BigNumber, decimalPlaces: number)` +- Produces: `SafeSliderBounds` with `start: number`, `minimum: number`, `maximum: number`, and `padding: [number, number]`. + +- [ ] **Step 1: Write failing normalized-floor tests** + +Update every expected bounds object to include `minimum`. Consumed ranges must expect the current position as the floor: + +```ts +assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('605.32'), 2), { + start: 605.31, + minimum: 605.31, + maximum: 605.31, + padding: [605.31, 0], +}); +``` + +Valid floors keep the requested minimum: + +```ts +assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('500'), 2), { + start: 605.31, + minimum: 500, + maximum: 605.31, + padding: [500, 0], +}); +``` + +Negative and `NaN` requested floors fail closed at the current position: + +```ts +assert.deepEqual(getSafeSliderBounds(new BigNumber(10), new BigNumber(20), new BigNumber(-1), 2), { + start: 10, + minimum: 10, + maximum: 20, + padding: [10, 0], +}); +``` + +The fully invalid fallback remains `start: 0`, `minimum: 0`, `maximum: 0.001`, and `padding: [0, 0]`. + +- [ ] **Step 2: Run the focused test and verify RED** + +```bash +/Users/hetfly/.nvm/versions/node/v22.21.1/bin/node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test apps/web/tests/collateralEligibility.test.ts +``` + +Expected: FAIL because the helper does not return `minimum` and still returns zero padding for consumed or invalid requested floors. + +- [ ] **Step 3: Implement the normalized protected minimum** + +Extend the result type: + +```ts +export type SafeSliderBounds = { + start: number; + minimum: number; + maximum: number; + padding: [number, number]; +}; +``` + +Include `minimum: 0` in the fully invalid fallback. For valid current and maximum values, replace the zero-padding collapsed branches with: + +```ts +const minimum = + roundedRequestedMinimum.isFinite() && !roundedRequestedMinimum.isNegative() + ? BigNumber.min(roundedRequestedMinimum, roundedCurrentAmount).toNumber() + : start; +const safeMinimum = Number.isFinite(minimum) ? minimum : start; + +return { + start, + minimum: safeMinimum, + maximum: numericMaximum, + padding: [safeMinimum, 0], +}; +``` + +The earlier `start > numericMaximum` validation guarantees the normalized floor cannot exceed the maximum after it is capped at the current amount. + +- [ ] **Step 4: Run the focused test and verify GREEN** + +Run the same Node 22 command. Expected: all focused tests pass. + +- [ ] **Step 5: Commit the slider-floor repair** + +```bash +git add apps/web/src/store/collateral/eligibility.ts apps/web/tests/collateralEligibility.test.ts +git commit -m "fix: preserve protected slider floors" +``` + +### Task 2: Clamp direct and complementary manual inputs + +**Files:** +- Modify: `apps/web/src/app/components/home/CollateralPanel.tsx:158-180,490-520` +- Modify: `apps/web/src/app/components/home/LoanPanel.tsx:64-78,338-380` +- Test: `apps/web/tests/collateralEligibility.test.ts:180-225` + +**Interfaces:** +- Consumes: `collateralSliderBounds.minimum` and `loanSliderBounds.minimum` from Task 1. +- Produces: `CurrencyField` bounds that enforce the same floor as each panel's slider. + +- [ ] **Step 1: Write failing panel-wiring tests** + +Add these source assertions while retaining checks for `disabled={!isAdjusting}`, normalized padding, and the absence of `hasMovableRange`: + +```ts +assert.match(collateralPanelSource, /const collateralMinimum = new BigNumber\(collateralSliderBounds\.minimum\)/); +assert.match(collateralPanelSource, /const walletMaximum = BigNumber\.max\(collateralTotal\.minus\(collateralMinimum\), 0\)/); +assert.match(collateralPanelSource, /label="Deposited"[\s\S]*?minValue=\{collateralMinimum\}/); +assert.match(collateralPanelSource, /label="Wallet"[\s\S]*?maxValue=\{walletMaximum\}/); + +assert.match(loanPanelSource, /const loanMinimum = new BigNumber\(loanSliderBounds\.minimum\)/); +assert.match(loanPanelSource, /const availableMaximum = BigNumber\.max\(borrowableAmountWithReserve\.minus\(loanMinimum\), 0\)/); +assert.match(loanPanelSource, /label="Borrowed"[\s\S]*?minValue=\{loanMinimum\}/); +assert.match(loanPanelSource, /label="Available"[\s\S]*?maxValue=\{availableMaximum\}/); +``` + +- [ ] **Step 2: Run the focused test and verify RED** + +Run the Node 22 focused-test command. Expected: FAIL only on the new manual-input assertions. + +- [ ] **Step 3: Wire collateral manual-input bounds** + +After computing `collateralSliderBounds`, add: + +```ts +const collateralMinimum = new BigNumber(collateralSliderBounds.minimum); +const walletMaximum = BigNumber.max(collateralTotal.minus(collateralMinimum), 0); +``` + +Pass `minValue={collateralMinimum}` to Deposited and replace Wallet's `maxValue={collateralTotal}` with `maxValue={walletMaximum}`. + +- [ ] **Step 4: Wire loan manual-input bounds** + +After computing `loanSliderBounds`, add: + +```ts +const loanMinimum = new BigNumber(loanSliderBounds.minimum); +const availableMaximum = BigNumber.max(borrowableAmountWithReserve.minus(loanMinimum), 0); +``` + +Pass `minValue={loanMinimum}` to both Borrowed render branches and `maxValue={availableMaximum}` to Available. + +- [ ] **Step 5: Run the focused test and verify GREEN** + +Run the Node 22 focused-test command. Expected: all focused tests pass. + +- [ ] **Step 6: Commit the manual-input repair** + +```bash +git add apps/web/src/app/components/home/CollateralPanel.tsx apps/web/src/app/components/home/LoanPanel.tsx apps/web/tests/collateralEligibility.test.ts +git commit -m "fix: clamp protected position inputs" +``` + +### Task 3: Verify the exact committed implementation + +**Files:** +- Verify: `apps/web/src/store/collateral/eligibility.ts` +- Verify: `apps/web/src/app/components/home/CollateralPanel.tsx` +- Verify: `apps/web/src/app/components/home/LoanPanel.tsx` +- Verify: `apps/web/tests/collateralEligibility.test.ts` + +**Interfaces:** +- Consumes: completed normalized bounds and panel wiring. +- Produces: verification evidence for the committed branch; no new production interface. + +- [ ] **Step 1: Run focused regression tests** + +```bash +/Users/hetfly/.nvm/versions/node/v22.21.1/bin/node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --test apps/web/tests/collateralEligibility.test.ts +``` + +Expected: zero failures. + +- [ ] **Step 2: Run repository TypeScript checks** + +```bash +pnpm checkTs +``` + +Expected: all six packages pass. + +- [ ] **Step 3: Build the production web application** + +```bash +pnpm build +``` + +Expected: the web build succeeds. Existing Vite dependency and chunk-size warnings may remain. + +- [ ] **Step 4: Verify scope and repository state** + +```bash +git diff --check +if rg -n "hasMovableRange" apps/web/src apps/web/dist; then exit 1; fi +git status --short +``` + +Expected: no whitespace errors, no `hasMovableRange` matches in source or built output, and a clean worktree. From 4c57d908acefbd4ffc889c522b5a0f98c5c79e46 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 10:57:40 +0200 Subject: [PATCH 09/12] fix: preserve protected slider floors --- apps/web/src/store/collateral/eligibility.ts | 25 +++++++------------- apps/web/tests/collateralEligibility.test.ts | 18 ++++++++++---- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/apps/web/src/store/collateral/eligibility.ts b/apps/web/src/store/collateral/eligibility.ts index 858745bae..f8303711f 100644 --- a/apps/web/src/store/collateral/eligibility.ts +++ b/apps/web/src/store/collateral/eligibility.ts @@ -27,6 +27,7 @@ export function getActionMaximum( export type SafeSliderBounds = { start: number; + minimum: number; maximum: number; padding: [number, number]; }; @@ -56,31 +57,23 @@ export function getSafeSliderBounds( ) { return { start: 0, + minimum: 0, maximum: FALLBACK_SLIDER_MAXIMUM, padding: [0, 0], }; } - const collapsedBounds: SafeSliderBounds = { - start, - maximum: numericMaximum, - padding: [0, 0], - }; - - if (!roundedRequestedMinimum.isFinite() || roundedRequestedMinimum.isNegative()) { - return collapsedBounds; - } - - const numericMinimum = BigNumber.min(roundedRequestedMinimum, roundedCurrentAmount).toNumber(); - - if (!Number.isFinite(numericMinimum) || numericMinimum >= numericMaximum) { - return collapsedBounds; - } + const minimum = + roundedRequestedMinimum.isFinite() && !roundedRequestedMinimum.isNegative() + ? BigNumber.min(roundedRequestedMinimum, roundedCurrentAmount).toNumber() + : start; + const safeMinimum = Number.isFinite(minimum) ? minimum : start; return { start, + minimum: safeMinimum, maximum: numericMaximum, - padding: [numericMinimum, 0], + padding: [safeMinimum, 0], }; } diff --git a/apps/web/tests/collateralEligibility.test.ts b/apps/web/tests/collateralEligibility.test.ts index 9b7bacb1d..4830396c2 100644 --- a/apps/web/tests/collateralEligibility.test.ts +++ b/apps/web/tests/collateralEligibility.test.ts @@ -46,18 +46,20 @@ test('enabled collateral retains increases and the full available maximum', () = assert.equal(getActionMaximum(current, maximum, true).toFixed(), '25'); }); -test('collapses slider bounds when protected minimum consumes the range', async () => { +test('clamps the slider floor when the protected minimum consumes the range', async () => { const { getSafeSliderBounds } = await import('../src/store/collateral/eligibility.ts'); assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('605.32'), 2), { start: 605.31, + minimum: 605.31, maximum: 605.31, - padding: [0, 0], + padding: [605.31, 0], }); assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('605.31'), 2), { start: 605.31, + minimum: 605.31, maximum: 605.31, - padding: [0, 0], + padding: [605.31, 0], }); }); @@ -66,6 +68,7 @@ test('preserves upward borrowing headroom when the requested minimum exceeds the assert.deepEqual(getSafeSliderBounds(new BigNumber('100'), new BigNumber('100.05'), new BigNumber('100.10'), 2), { start: 100, + minimum: 100, maximum: 100.05, padding: [100, 0], }); @@ -76,6 +79,7 @@ test('preserves valid repayment and withdrawal slider ranges', async () => { assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('500'), 2), { start: 605.31, + minimum: 500, maximum: 605.31, padding: [500, 0], }); @@ -83,6 +87,7 @@ test('preserves valid repayment and withdrawal slider ranges', async () => { getSafeSliderBounds(new BigNumber('237.736999'), new BigNumber('237.736999'), new BigNumber('32.97'), 6), { start: 237.736999, + minimum: 32.97, maximum: 237.736999, padding: [32.97, 0], }, @@ -94,6 +99,7 @@ test('fails closed with safe numeric options for invalid slider bounds', async ( const fallbackBounds = { start: 0, + minimum: 0, maximum: 0.001, padding: [0, 0], }; @@ -114,13 +120,15 @@ test('fails closed with safe numeric options for invalid slider bounds', async ( ); assert.deepEqual(getSafeSliderBounds(new BigNumber(10), new BigNumber(20), new BigNumber(-1), 2), { start: 10, + minimum: 10, maximum: 20, - padding: [0, 0], + padding: [10, 0], }); assert.deepEqual(getSafeSliderBounds(new BigNumber(10), new BigNumber(20), new BigNumber(Number.NaN), 2), { start: 10, + minimum: 10, maximum: 20, - padding: [0, 0], + padding: [10, 0], }); }); From e22cd817f9513a7b5754c29333f8c8ba057aa77a Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 10:59:23 +0200 Subject: [PATCH 10/12] fix: clamp protected position inputs --- .../src/app/components/home/CollateralPanel.tsx | 5 ++++- apps/web/src/app/components/home/LoanPanel.tsx | 5 +++++ apps/web/tests/collateralEligibility.test.ts | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/components/home/CollateralPanel.tsx b/apps/web/src/app/components/home/CollateralPanel.tsx index a0e6371fb..0f8b2103d 100644 --- a/apps/web/src/app/components/home/CollateralPanel.tsx +++ b/apps/web/src/app/components/home/CollateralPanel.tsx @@ -175,6 +175,8 @@ const CollateralPanel = () => { tLockedAmount, collateralDecimalPlaces, ); + const collateralMinimum = new BigNumber(collateralSliderBounds.minimum); + const walletMaximum = BigNumber.max(collateralTotal.minus(collateralMinimum), 0); const percent = collateralTotal.isZero() ? 0 : tLockedAmount.div(collateralTotal).times(100).toNumber(); const [ICXWithdrawOption, setICXWithdrawOption] = useState(ICXWithdrawOptions.KEEPSICX); const { data: icxUnstakingTime } = useICXUnstakingTime(); @@ -496,6 +498,7 @@ const CollateralPanel = () => { value={formattedAmounts[Field.LEFT]} decimalPlaces={collateralDecimalPlaces} currency={isHandlingICX ? 'ICX' : formatSymbol(collateralType)} + minValue={collateralMinimum} maxValue={actionMaximum} onUserInput={onFieldAInput} /> @@ -512,7 +515,7 @@ const CollateralPanel = () => { value={formattedAmounts[Field.RIGHT]} decimalPlaces={collateralDecimalPlaces} currency={isHandlingICX ? 'ICX' : formatSymbol(collateralType)} - maxValue={collateralTotal} + maxValue={walletMaximum} onUserInput={onFieldBInput} /> diff --git a/apps/web/src/app/components/home/LoanPanel.tsx b/apps/web/src/app/components/home/LoanPanel.tsx index 5471a8a86..08edd4a6c 100644 --- a/apps/web/src/app/components/home/LoanPanel.tsx +++ b/apps/web/src/app/components/home/LoanPanel.tsx @@ -71,6 +71,8 @@ const LoanPanel = () => { const activeLoanAccount = useActiveLoanAddress(); const usedAmount = useLoanUsedAmount(activeLoanAccount); const loanSliderBounds = getSafeSliderBounds(borrowedAmount, actionMaximum, usedAmount, 2); + const loanMinimum = new BigNumber(loanSliderBounds.minimum); + const availableMaximum = BigNumber.max(borrowableAmountWithReserve.minus(loanMinimum), 0); const { isAdjusting, inputType } = useLoanState(); @@ -349,6 +351,7 @@ const LoanPanel = () => { noticeText={'10 bnUSD minimum'} value={formattedAmounts[Field.LEFT]} currency={'bnUSD'} + minValue={loanMinimum} maxValue={actionMaximum} onUserInput={onFieldAInput} /> @@ -360,6 +363,7 @@ const LoanPanel = () => { tooltipText="Your collateral balance. It earns interest from staking, but is also sold over time to repay your loan." value={formattedAmounts[Field.LEFT]} currency={'bnUSD'} + minValue={loanMinimum} maxValue={actionMaximum} onUserInput={onFieldAInput} /> @@ -374,6 +378,7 @@ const LoanPanel = () => { tooltipText="The amount of ICX available to deposit from your wallet." value={formattedAmounts[Field.RIGHT]} currency={'bnUSD'} + maxValue={availableMaximum} onUserInput={onFieldBInput} /> diff --git a/apps/web/tests/collateralEligibility.test.ts b/apps/web/tests/collateralEligibility.test.ts index 4830396c2..5a871baf4 100644 --- a/apps/web/tests/collateralEligibility.test.ts +++ b/apps/web/tests/collateralEligibility.test.ts @@ -200,11 +200,25 @@ test('collateral and loan panels use safe slider bounds without frontend movabil assert.match(collateralPanelSource, /start=\{collateralSliderBounds\.start\}/); assert.match(collateralPanelSource, /max: \[collateralSliderBounds\.maximum\]/); assert.match(collateralPanelSource, /disabled=\{!isAdjusting\}/); + assert.match(collateralPanelSource, /const collateralMinimum = new BigNumber\(collateralSliderBounds\.minimum\)/); + assert.match( + collateralPanelSource, + /const walletMaximum = BigNumber\.max\(collateralTotal\.minus\(collateralMinimum\), 0\)/, + ); + assert.match(collateralPanelSource, /label="Deposited"[\s\S]*?minValue=\{collateralMinimum\}/); + assert.match(collateralPanelSource, /label="Wallet"[\s\S]*?maxValue=\{walletMaximum\}/); assert.match(loanPanelSource, /loanSliderBounds\.padding/); assert.match(loanPanelSource, /start=\{\[loanSliderBounds\.start\]\}/); assert.match(loanPanelSource, /max: \[loanSliderBounds\.maximum\]/); assert.match(loanPanelSource, /disabled=\{!isAdjusting\}/); + assert.match(loanPanelSource, /const loanMinimum = new BigNumber\(loanSliderBounds\.minimum\)/); + assert.match( + loanPanelSource, + /const availableMaximum = BigNumber\.max\(borrowableAmountWithReserve\.minus\(loanMinimum\), 0\)/, + ); + assert.equal(loanPanelSource.match(/minValue=\{loanMinimum\}/g)?.length, 2); + assert.match(loanPanelSource, /label="Available"[\s\S]*?maxValue=\{availableMaximum\}/); assert.doesNotMatch(collateralPanelSource, /hasMovableRange/); assert.doesNotMatch(loanPanelSource, /hasMovableRange/); From 26d933d0cfacf64767a330267c98a62e179f6968 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 11:26:11 +0200 Subject: [PATCH 11/12] fix: restore historical position slider ranges --- .../app/components/home/CollateralPanel.tsx | 29 ++-- .../web/src/app/components/home/LoanPanel.tsx | 27 ++-- apps/web/src/store/collateral/eligibility.ts | 60 -------- apps/web/tests/collateralEligibility.test.ts | 137 ++++-------------- 4 files changed, 53 insertions(+), 200 deletions(-) diff --git a/apps/web/src/app/components/home/CollateralPanel.tsx b/apps/web/src/app/components/home/CollateralPanel.tsx index 0f8b2103d..0c7b4c72d 100644 --- a/apps/web/src/app/components/home/CollateralPanel.tsx +++ b/apps/web/src/app/components/home/CollateralPanel.tsx @@ -22,6 +22,7 @@ import { Typography } from '@/app/theme'; import IconUnstakeSICX from '@/assets/icons/timer-color.svg'; import IconKeepSICX from '@/assets/icons/wallet-tick-color.svg'; import { NETWORK_ID } from '@/constants/config'; +import { SLIDER_RANGE_MAX_BOTTOM_THRESHOLD } from '@/constants/index'; import { MODAL_ID, modalActions } from '@/hooks/useModalStore'; import useWidth from '@/hooks/useWidth'; import { useICXUnstakingTime } from '@/store/application/hooks'; @@ -33,12 +34,7 @@ import { useIsHandlingICX, useSupportedCollateralTokens, } from '@/store/collateral/hooks'; -import { - getActionMaximum, - getSafeSliderBounds, - isCollateralEnabled, - isIncreaseAllowed, -} from '@/store/collateral/eligibility'; +import { isCollateralEnabled, isIncreaseAllowed } from '@/store/collateral/eligibility'; import { Field } from '@/store/collateral/reducer'; import { useLoanActionHandlers, useLockedCollateralAmount } from '@/store/loan/hooks'; import { useRatio } from '@/store/ratio/hooks'; @@ -160,7 +156,6 @@ const CollateralPanel = () => { const { data: supportedCollateralTokens } = useSupportedCollateralTokens(); const contractSymbol = useWrongSymbol(collateralType); const increaseEnabled = isCollateralEnabled(supportedCollateralTokens, contractSymbol); - const actionMaximum = getActionMaximum(collateralDeposit, collateralTotal, increaseEnabled); const lockedCollateral = useLockedCollateralAmount(); const shouldShowLock = !lockedCollateral.isZero(); @@ -169,13 +164,7 @@ const CollateralPanel = () => { () => BigNumber.min(lockedCollateral.times(shouldShowLock ? 1.005 : 1), collateralDeposit), [lockedCollateral, collateralDeposit, shouldShowLock], ); - const collateralSliderBounds = getSafeSliderBounds( - collateralDeposit, - actionMaximum, - tLockedAmount, - collateralDecimalPlaces, - ); - const collateralMinimum = new BigNumber(collateralSliderBounds.minimum); + const collateralMinimum = BigNumber.max(tLockedAmount.dp(collateralDecimalPlaces), 0); const walletMaximum = BigNumber.max(collateralTotal.minus(collateralMinimum), 0); const percent = collateralTotal.isZero() ? 0 : tLockedAmount.div(collateralTotal).times(100).toNumber(); const [ICXWithdrawOption, setICXWithdrawOption] = useState(ICXWithdrawOptions.KEEPSICX); @@ -472,12 +461,16 @@ const CollateralPanel = () => { { if (instance) { @@ -499,7 +492,7 @@ const CollateralPanel = () => { decimalPlaces={collateralDecimalPlaces} currency={isHandlingICX ? 'ICX' : formatSymbol(collateralType)} minValue={collateralMinimum} - maxValue={actionMaximum} + maxValue={collateralTotal} onUserInput={onFieldAInput} /> diff --git a/apps/web/src/app/components/home/LoanPanel.tsx b/apps/web/src/app/components/home/LoanPanel.tsx index 08edd4a6c..ee970c5d8 100644 --- a/apps/web/src/app/components/home/LoanPanel.tsx +++ b/apps/web/src/app/components/home/LoanPanel.tsx @@ -11,6 +11,7 @@ import LockBar from '@/app/components/LockBar'; import Modal from '@/app/components/Modal'; import { BoxPanel, BoxPanelWrap } from '@/app/components/Panel'; import { Typography } from '@/app/theme'; +import { SLIDER_RANGE_MAX_BOTTOM_THRESHOLD } from '@/constants/index'; import { useActiveLocale } from '@/hooks/useActiveLocale'; import useInterval from '@/hooks/useInterval'; import { @@ -18,12 +19,7 @@ import { useDerivedCollateralInfo, useSupportedCollateralTokens, } from '@/store/collateral/hooks'; -import { - getActionMaximum, - getSafeSliderBounds, - isCollateralEnabled, - isIncreaseAllowed, -} from '@/store/collateral/eligibility'; +import { isCollateralEnabled, isIncreaseAllowed } from '@/store/collateral/eligibility'; import { useActiveLoanAddress, useDerivedLoanInfo, @@ -67,11 +63,9 @@ const LoanPanel = () => { } = useDerivedLoanInfo(); const { data: supportedCollateralTokens } = useSupportedCollateralTokens(); const increaseEnabled = isCollateralEnabled(supportedCollateralTokens, useWrongSymbol(collateralType)); - const actionMaximum = getActionMaximum(borrowedAmount, borrowableAmountWithReserve, increaseEnabled); const activeLoanAccount = useActiveLoanAddress(); const usedAmount = useLoanUsedAmount(activeLoanAccount); - const loanSliderBounds = getSafeSliderBounds(borrowedAmount, actionMaximum, usedAmount, 2); - const loanMinimum = new BigNumber(loanSliderBounds.minimum); + const loanMinimum = BigNumber.max(BigNumber.min(usedAmount.dp(2), borrowableAmountWithReserve.dp(2)), 0); const availableMaximum = BigNumber.max(borrowableAmountWithReserve.minus(loanMinimum), 0); const { isAdjusting, inputType } = useLoanState(); @@ -323,13 +317,18 @@ const LoanPanel = () => { { if (instance) { @@ -352,7 +351,7 @@ const LoanPanel = () => { value={formattedAmounts[Field.LEFT]} currency={'bnUSD'} minValue={loanMinimum} - maxValue={actionMaximum} + maxValue={borrowableAmountWithReserve} onUserInput={onFieldAInput} /> ) : ( @@ -364,7 +363,7 @@ const LoanPanel = () => { value={formattedAmounts[Field.LEFT]} currency={'bnUSD'} minValue={loanMinimum} - maxValue={actionMaximum} + maxValue={borrowableAmountWithReserve} onUserInput={onFieldAInput} /> )} diff --git a/apps/web/src/store/collateral/eligibility.ts b/apps/web/src/store/collateral/eligibility.ts index f8303711f..e7066c691 100644 --- a/apps/web/src/store/collateral/eligibility.ts +++ b/apps/web/src/store/collateral/eligibility.ts @@ -17,66 +17,6 @@ export function isCollateralEnabled(tokens: CollateralTokenMap | undefined, symb return Boolean(tokens?.[symbol]); } -export function getActionMaximum( - currentAmount: BigNumber, - availableMaximum: BigNumber, - increaseEnabled: boolean, -): BigNumber { - return increaseEnabled ? availableMaximum : currentAmount; -} - -export type SafeSliderBounds = { - start: number; - minimum: number; - maximum: number; - padding: [number, number]; -}; - -const FALLBACK_SLIDER_MAXIMUM = 0.001; - -export function getSafeSliderBounds( - currentAmount: BigNumber, - maximum: BigNumber, - requestedMinimum: BigNumber, - decimalPlaces: number, -): SafeSliderBounds { - const roundedCurrentAmount = currentAmount.dp(decimalPlaces); - const roundedMaximum = maximum.dp(decimalPlaces); - const roundedRequestedMinimum = requestedMinimum.dp(decimalPlaces); - const start = roundedCurrentAmount.toNumber(); - const numericMaximum = roundedMaximum.toNumber(); - - if ( - !roundedCurrentAmount.isFinite() || - !roundedMaximum.isFinite() || - !Number.isFinite(start) || - !Number.isFinite(numericMaximum) || - roundedCurrentAmount.isNegative() || - roundedMaximum.isLessThanOrEqualTo(0) || - start > numericMaximum - ) { - return { - start: 0, - minimum: 0, - maximum: FALLBACK_SLIDER_MAXIMUM, - padding: [0, 0], - }; - } - - const minimum = - roundedRequestedMinimum.isFinite() && !roundedRequestedMinimum.isNegative() - ? BigNumber.min(roundedRequestedMinimum, roundedCurrentAmount).toNumber() - : start; - const safeMinimum = Number.isFinite(minimum) ? minimum : start; - - return { - start, - minimum: safeMinimum, - maximum: numericMaximum, - padding: [safeMinimum, 0], - }; -} - export function isIncreaseAllowed(currentAmount: BigNumber, nextAmount: BigNumber, increaseEnabled: boolean): boolean { return increaseEnabled || nextAmount.isLessThanOrEqualTo(currentAmount); } diff --git a/apps/web/tests/collateralEligibility.test.ts b/apps/web/tests/collateralEligibility.test.ts index 5a871baf4..f12c017a0 100644 --- a/apps/web/tests/collateralEligibility.test.ts +++ b/apps/web/tests/collateralEligibility.test.ts @@ -5,7 +5,6 @@ import test from 'node:test'; import BigNumber from 'bignumber.js'; import { - getActionMaximum, hasPositiveDebtCeiling, isCollateralEnabled, isIncreaseAllowed, @@ -35,116 +34,38 @@ test('disabled collateral can decrease but not increase its deposited amount or assert.equal(isIncreaseAllowed(current, new BigNumber(5), false), true); assert.equal(isIncreaseAllowed(current, new BigNumber(10), false), true); assert.equal(isIncreaseAllowed(current, new BigNumber(11), false), false); - assert.equal(getActionMaximum(current, new BigNumber(25), false).toFixed(), '10'); }); -test('enabled collateral retains increases and the full available maximum', () => { +test('enabled collateral retains increases', () => { const current = new BigNumber(10); - const maximum = new BigNumber(25); assert.equal(isIncreaseAllowed(current, new BigNumber(11), true), true); - assert.equal(getActionMaximum(current, maximum, true).toFixed(), '25'); }); -test('clamps the slider floor when the protected minimum consumes the range', async () => { - const { getSafeSliderBounds } = await import('../src/store/collateral/eligibility.ts'); - - assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('605.32'), 2), { - start: 605.31, - minimum: 605.31, - maximum: 605.31, - padding: [605.31, 0], - }); - assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('605.31'), 2), { - start: 605.31, - minimum: 605.31, - maximum: 605.31, - padding: [605.31, 0], - }); -}); - -test('preserves upward borrowing headroom when the requested minimum exceeds the current debt', async () => { - const { getSafeSliderBounds } = await import('../src/store/collateral/eligibility.ts'); - - assert.deepEqual(getSafeSliderBounds(new BigNumber('100'), new BigNumber('100.05'), new BigNumber('100.10'), 2), { - start: 100, - minimum: 100, - maximum: 100.05, - padding: [100, 0], - }); -}); - -test('preserves valid repayment and withdrawal slider ranges', async () => { - const { getSafeSliderBounds } = await import('../src/store/collateral/eligibility.ts'); - - assert.deepEqual(getSafeSliderBounds(new BigNumber('605.31'), new BigNumber('605.31'), new BigNumber('500'), 2), { - start: 605.31, - minimum: 500, - maximum: 605.31, - padding: [500, 0], - }); - assert.deepEqual( - getSafeSliderBounds(new BigNumber('237.736999'), new BigNumber('237.736999'), new BigNumber('32.97'), 6), - { - start: 237.736999, - minimum: 32.97, - maximum: 237.736999, - padding: [32.97, 0], - }, - ); -}); - -test('fails closed with safe numeric options for invalid slider bounds', async () => { - const { getSafeSliderBounds } = await import('../src/store/collateral/eligibility.ts'); - - const fallbackBounds = { - start: 0, - minimum: 0, - maximum: 0.001, - padding: [0, 0], - }; - - assert.deepEqual(getSafeSliderBounds(new BigNumber(0), new BigNumber(0), new BigNumber(0), 2), fallbackBounds); - assert.deepEqual( - getSafeSliderBounds( - new BigNumber(Number.POSITIVE_INFINITY), - new BigNumber(Number.POSITIVE_INFINITY), - new BigNumber(0), - 2, - ), - fallbackBounds, - ); - assert.deepEqual( - getSafeSliderBounds(new BigNumber('1e400'), new BigNumber('1e400'), new BigNumber(0), 2), - fallbackBounds, - ); - assert.deepEqual(getSafeSliderBounds(new BigNumber(10), new BigNumber(20), new BigNumber(-1), 2), { - start: 10, - minimum: 10, - maximum: 20, - padding: [10, 0], - }); - assert.deepEqual(getSafeSliderBounds(new BigNumber(10), new BigNumber(20), new BigNumber(Number.NaN), 2), { - start: 10, - minimum: 10, - maximum: 20, - padding: [10, 0], - }); -}); - -test('slider panels consume all normalized noUiSlider options', async () => { +test('position sliders use their historical full ranges and displayed thresholds', async () => { const collateralPanelSource = await readFile( new URL('../src/app/components/home/CollateralPanel.tsx', import.meta.url), 'utf8', ); const loanPanelSource = await readFile(new URL('../src/app/components/home/LoanPanel.tsx', import.meta.url), 'utf8'); - assert.match(collateralPanelSource, /start=\{collateralSliderBounds\.start\}/); - assert.match(collateralPanelSource, /max: \[collateralSliderBounds\.maximum\]/); - assert.match(loanPanelSource, /start=\{\[loanSliderBounds\.start\]\}/); - assert.match(loanPanelSource, /max: \[loanSliderBounds\.maximum\]/); - assert.doesNotMatch(collateralPanelSource, /SLIDER_RANGE_MAX_BOTTOM_THRESHOLD/); - assert.doesNotMatch(loanPanelSource, /SLIDER_RANGE_MAX_BOTTOM_THRESHOLD/); + assert.match( + collateralPanelSource, + /const collateralMinimum = BigNumber\.max\(tLockedAmount\.dp\(collateralDecimalPlaces\), 0\)/, + ); + assert.match(collateralPanelSource, /start=\{collateralDeposit\.toNumber\(\)\}/); + assert.match(collateralPanelSource, /padding=\{\[collateralMinimum\.toNumber\(\), 0\]\}/); + assert.match(collateralPanelSource, /collateralTotal\.dp\(collateralDecimalPlaces\)\.toNumber\(\)/); + assert.doesNotMatch(collateralPanelSource, /actionMaximum|getSafeSliderBounds|collateralSliderBounds/); + + assert.match( + loanPanelSource, + /const loanMinimum = BigNumber\.max\(\s*BigNumber\.min\(usedAmount\.dp\(2\), borrowableAmountWithReserve\.dp\(2\)\),\s*0,?\s*\)/, + ); + assert.match(loanPanelSource, /start=\{\[borrowedAmount\.dp\(2\)\.toNumber\(\)\]\}/); + assert.match(loanPanelSource, /padding=\{\[loanMinimum\.toNumber\(\), 0\]\}/); + assert.match(loanPanelSource, /borrowableAmountWithReserve\.dp\(2\)\.toNumber\(\)/); + assert.doesNotMatch(loanPanelSource, /actionMaximum|getSafeSliderBounds|loanSliderBounds/); }); test('position reads use registered collateral tokens', async () => { @@ -188,7 +109,7 @@ test('collateral and loan transaction handlers guard disabled increases', async assert.match(xLoanModal, /if \(storedModalValues\.action === XLoanAction\.BORROW && !increaseEnabled\) return/); }); -test('collateral and loan panels use safe slider bounds without frontend movability gating', async () => { +test('collateral and loan panels clamp manual input to the displayed thresholds', async () => { const collateralPanelSource = await readFile( new URL('../src/app/components/home/CollateralPanel.tsx', import.meta.url), 'utf8', @@ -196,11 +117,11 @@ test('collateral and loan panels use safe slider bounds without frontend movabil const loanPanelSource = await readFile(new URL('../src/app/components/home/LoanPanel.tsx', import.meta.url), 'utf8'); const eligibilitySource = await readFile(new URL('../src/store/collateral/eligibility.ts', import.meta.url), 'utf8'); - assert.match(collateralPanelSource, /collateralSliderBounds\.padding/); - assert.match(collateralPanelSource, /start=\{collateralSliderBounds\.start\}/); - assert.match(collateralPanelSource, /max: \[collateralSliderBounds\.maximum\]/); assert.match(collateralPanelSource, /disabled=\{!isAdjusting\}/); - assert.match(collateralPanelSource, /const collateralMinimum = new BigNumber\(collateralSliderBounds\.minimum\)/); + assert.match( + collateralPanelSource, + /const collateralMinimum = BigNumber\.max\(tLockedAmount\.dp\(collateralDecimalPlaces\), 0\)/, + ); assert.match( collateralPanelSource, /const walletMaximum = BigNumber\.max\(collateralTotal\.minus\(collateralMinimum\), 0\)/, @@ -208,11 +129,11 @@ test('collateral and loan panels use safe slider bounds without frontend movabil assert.match(collateralPanelSource, /label="Deposited"[\s\S]*?minValue=\{collateralMinimum\}/); assert.match(collateralPanelSource, /label="Wallet"[\s\S]*?maxValue=\{walletMaximum\}/); - assert.match(loanPanelSource, /loanSliderBounds\.padding/); - assert.match(loanPanelSource, /start=\{\[loanSliderBounds\.start\]\}/); - assert.match(loanPanelSource, /max: \[loanSliderBounds\.maximum\]/); assert.match(loanPanelSource, /disabled=\{!isAdjusting\}/); - assert.match(loanPanelSource, /const loanMinimum = new BigNumber\(loanSliderBounds\.minimum\)/); + assert.match( + loanPanelSource, + /const loanMinimum = BigNumber\.max\(\s*BigNumber\.min\(usedAmount\.dp\(2\), borrowableAmountWithReserve\.dp\(2\)\),\s*0,?\s*\)/, + ); assert.match( loanPanelSource, /const availableMaximum = BigNumber\.max\(borrowableAmountWithReserve\.minus\(loanMinimum\), 0\)/, @@ -222,7 +143,7 @@ test('collateral and loan panels use safe slider bounds without frontend movabil assert.doesNotMatch(collateralPanelSource, /hasMovableRange/); assert.doesNotMatch(loanPanelSource, /hasMovableRange/); - assert.doesNotMatch(eligibilitySource, /hasMovableRange/); + assert.doesNotMatch(eligibilitySource, /hasMovableRange|getSafeSliderBounds|getActionMaximum/); }); test('position adjustment controls are independent of slider movability', async () => { From 262bbd001ef2601183c2cfaaf3f23e4b446b2cb6 Mon Sep 17 00:00:00 2001 From: hetfly Date: Thu, 16 Jul 2026 17:48:08 +0200 Subject: [PATCH 12/12] fix: fix loans --- .../app/components/home/CollateralPanel.tsx | 57 +-- .../web/src/app/components/home/LoanPanel.tsx | 41 +- .../_components/xCollateralModal/index.tsx | 11 +- .../home/_components/xLoanModal/index.tsx | 6 +- apps/web/src/store/collateral/eligibility.ts | 22 - apps/web/src/store/collateral/hooks.ts | 30 +- apps/web/tests/collateralEligibility.test.ts | 165 ------- .../tests/zeroDebtCeilingCollateral.test.ts | 72 +++ .../2026-07-15-zero-ceiling-slider-crash.md | 278 ----------- ...2026-07-15-zero-debt-ceiling-collateral.md | 440 ------------------ .../2026-07-16-exit-slider-interaction.md | 165 ------- ...7-16-position-adjust-control-visibility.md | 153 ------ .../2026-07-16-position-floor-guardrails.md | 226 --------- ...-07-15-zero-ceiling-slider-crash-design.md | 54 --- ...-15-zero-debt-ceiling-collateral-design.md | 64 --- ...26-07-16-exit-slider-interaction-design.md | 49 -- ...sition-adjust-control-visibility-design.md | 57 --- ...-07-16-position-floor-guardrails-design.md | 61 --- 18 files changed, 103 insertions(+), 1848 deletions(-) delete mode 100644 apps/web/src/store/collateral/eligibility.ts delete mode 100644 apps/web/tests/collateralEligibility.test.ts create mode 100644 apps/web/tests/zeroDebtCeilingCollateral.test.ts delete mode 100644 docs/superpowers/plans/2026-07-15-zero-ceiling-slider-crash.md delete mode 100644 docs/superpowers/plans/2026-07-15-zero-debt-ceiling-collateral.md delete mode 100644 docs/superpowers/plans/2026-07-16-exit-slider-interaction.md delete mode 100644 docs/superpowers/plans/2026-07-16-position-adjust-control-visibility.md delete mode 100644 docs/superpowers/plans/2026-07-16-position-floor-guardrails.md delete mode 100644 docs/superpowers/specs/2026-07-15-zero-ceiling-slider-crash-design.md delete mode 100644 docs/superpowers/specs/2026-07-15-zero-debt-ceiling-collateral-design.md delete mode 100644 docs/superpowers/specs/2026-07-16-exit-slider-interaction-design.md delete mode 100644 docs/superpowers/specs/2026-07-16-position-adjust-control-visibility-design.md delete mode 100644 docs/superpowers/specs/2026-07-16-position-floor-guardrails-design.md diff --git a/apps/web/src/app/components/home/CollateralPanel.tsx b/apps/web/src/app/components/home/CollateralPanel.tsx index 0c7b4c72d..2f4788cd7 100644 --- a/apps/web/src/app/components/home/CollateralPanel.tsx +++ b/apps/web/src/app/components/home/CollateralPanel.tsx @@ -32,9 +32,7 @@ import { useCollateralTokens, useDerivedCollateralInfo, useIsHandlingICX, - useSupportedCollateralTokens, } from '@/store/collateral/hooks'; -import { isCollateralEnabled, isIncreaseAllowed } from '@/store/collateral/eligibility'; import { Field } from '@/store/collateral/reducer'; import { useLoanActionHandlers, useLockedCollateralAmount } from '@/store/loan/hooks'; import { useRatio } from '@/store/ratio/hooks'; @@ -153,20 +151,6 @@ const CollateralPanel = () => { const ratio = useRatio(); const isHandlingICX = useIsHandlingICX(); const { data: collateralTokens } = useCollateralTokens(); - const { data: supportedCollateralTokens } = useSupportedCollateralTokens(); - const contractSymbol = useWrongSymbol(collateralType); - const increaseEnabled = isCollateralEnabled(supportedCollateralTokens, contractSymbol); - const lockedCollateral = useLockedCollateralAmount(); - const shouldShowLock = !lockedCollateral.isZero(); - - // add small amount of collateral to lock to avoid tx errors. - const tLockedAmount = React.useMemo( - () => BigNumber.min(lockedCollateral.times(shouldShowLock ? 1.005 : 1), collateralDeposit), - [lockedCollateral, collateralDeposit, shouldShowLock], - ); - const collateralMinimum = BigNumber.max(tLockedAmount.dp(collateralDecimalPlaces), 0); - const walletMaximum = BigNumber.max(collateralTotal.minus(collateralMinimum), 0); - const percent = collateralTotal.isZero() ? 0 : tLockedAmount.div(collateralTotal).times(100).toNumber(); const [ICXWithdrawOption, setICXWithdrawOption] = useState(ICXWithdrawOptions.KEEPSICX); const { data: icxUnstakingTime } = useICXUnstakingTime(); const isSuperSmall = useMedia(`(max-width: 359px)`); @@ -209,11 +193,6 @@ const CollateralPanel = () => { const isCrossChain = !(sourceChain === '0x1.icon' || sourceChain === '0x2.icon'); const toggleOpen = () => { - if (open) { - setOpen(false); - return; - } - if (!isIncreaseAllowed(collateralDeposit, parsedAmount[Field.LEFT], increaseEnabled)) return; if (isCrossChain) { setStoredModalValues({ amount: `${differenceAmount.dp(collateralDecimalPlaces).toFormat()} ${formatSymbol(collateralType)}`, @@ -230,12 +209,9 @@ const CollateralPanel = () => { const addTransaction = useTransactionAdder(); const handleCollateralConfirm = async () => { - if (shouldDeposit && !increaseEnabled) return; - const collateralTokenAddress = collateralTokens?.[contractSymbol]; - if (!collateralTokenAddress) return; - window.addEventListener('beforeunload', showMessageOnBeforeUnload); - const cx = bnJs.inject({ account }).getContract(collateralTokenAddress); + const collateralTokenAddress = collateralTokens && collateralTokens[useWrongSymbol(collateralType)]; + const cx = bnJs.inject({ account }).getContract(collateralTokenAddress!); const decimals: string = await cx.decimals(); if (shouldDeposit) { @@ -374,6 +350,17 @@ const CollateralPanel = () => { ); }, [collateralDecimalPlaces, sliderInstance.current]); + const lockedCollateral = useLockedCollateralAmount(); + const shouldShowLock = !lockedCollateral.isZero(); + + // add small amount of collateral to lock to avoid tx errors. + const tLockedAmount = React.useMemo( + () => BigNumber.min(lockedCollateral.times(shouldShowLock ? 1.005 : 1), collateralTotal), + [lockedCollateral, collateralTotal, shouldShowLock], + ); + + const percent = collateralTotal.isZero() ? 0 : tLockedAmount.div(collateralTotal).times(100).toNumber(); + const hasEnoughICX = useHasEnoughICX(); const [ref, width] = useWidth(); @@ -415,11 +402,7 @@ const CollateralPanel = () => { > Cancel - @@ -462,7 +445,7 @@ const CollateralPanel = () => { id="slider-collateral" disabled={!isAdjusting} start={collateralDeposit.toNumber()} - padding={[collateralMinimum.toNumber(), 0]} + padding={[Math.max(tLockedAmount.dp(collateralDecimalPlaces).toNumber(), 0), 0]} connect={[true, false]} range={{ min: [0], @@ -491,7 +474,6 @@ const CollateralPanel = () => { value={formattedAmounts[Field.LEFT]} decimalPlaces={collateralDecimalPlaces} currency={isHandlingICX ? 'ICX' : formatSymbol(collateralType)} - minValue={collateralMinimum} maxValue={collateralTotal} onUserInput={onFieldAInput} /> @@ -499,7 +481,7 @@ const CollateralPanel = () => { { value={formattedAmounts[Field.RIGHT]} decimalPlaces={collateralDecimalPlaces} currency={isHandlingICX ? 'ICX' : formatSymbol(collateralType)} - maxValue={walletMaximum} + maxValue={collateralTotal} onUserInput={onFieldBInput} /> @@ -527,7 +509,6 @@ const CollateralPanel = () => { @@ -635,9 +616,7 @@ const CollateralPanel = () => { onClick={handleCollateralConfirm} fontSize={14} disabled={ - !hasEnoughICX || - (shouldDeposit && !increaseEnabled) || - (isHandlingICX && !shouldDeposit && ICXWithdrawOption === ICXWithdrawOptions.EMPTY) + !hasEnoughICX || (isHandlingICX && !shouldDeposit && ICXWithdrawOption === ICXWithdrawOptions.EMPTY) } > {shouldDeposit ? t`Deposit` : t`Withdraw`} diff --git a/apps/web/src/app/components/home/LoanPanel.tsx b/apps/web/src/app/components/home/LoanPanel.tsx index ee970c5d8..740821d91 100644 --- a/apps/web/src/app/components/home/LoanPanel.tsx +++ b/apps/web/src/app/components/home/LoanPanel.tsx @@ -14,12 +14,7 @@ import { Typography } from '@/app/theme'; import { SLIDER_RANGE_MAX_BOTTOM_THRESHOLD } from '@/constants/index'; import { useActiveLocale } from '@/hooks/useActiveLocale'; import useInterval from '@/hooks/useInterval'; -import { - useCollateralActionHandlers, - useDerivedCollateralInfo, - useSupportedCollateralTokens, -} from '@/store/collateral/hooks'; -import { isCollateralEnabled, isIncreaseAllowed } from '@/store/collateral/eligibility'; +import { useCollateralActionHandlers, useDerivedCollateralInfo } from '@/store/collateral/hooks'; import { useActiveLoanAddress, useDerivedLoanInfo, @@ -61,12 +56,6 @@ const LoanPanel = () => { totalBorrowableAmount, bnUSDAmount, } = useDerivedLoanInfo(); - const { data: supportedCollateralTokens } = useSupportedCollateralTokens(); - const increaseEnabled = isCollateralEnabled(supportedCollateralTokens, useWrongSymbol(collateralType)); - const activeLoanAccount = useActiveLoanAddress(); - const usedAmount = useLoanUsedAmount(activeLoanAccount); - const loanMinimum = BigNumber.max(BigNumber.min(usedAmount.dp(2), borrowableAmountWithReserve.dp(2)), 0); - const availableMaximum = BigNumber.max(borrowableAmountWithReserve.minus(loanMinimum), 0); const { isAdjusting, inputType } = useLoanState(); @@ -135,11 +124,6 @@ const LoanPanel = () => { (loanNetwork !== ICON_XCALL_NETWORK_ID || sourceChain !== ICON_XCALL_NETWORK_ID); const toggleOpen = () => { - if (open) { - setOpen(false); - return; - } - if (!isIncreaseAllowed(borrowedAmount, parsedAmount[Field.LEFT], increaseEnabled)) return; if (isCrossChain) { setStoredModalValues({ amount: roundedDisplayDiffAmount.dp(2).toFormat(), @@ -155,7 +139,6 @@ const LoanPanel = () => { }; const handleLoanConfirm = () => { - if (shouldBorrow && !increaseEnabled) return; if (!iconAccount) return; window.addEventListener('beforeunload', showMessageOnBeforeUnload); @@ -235,6 +218,8 @@ const LoanPanel = () => { const roundedDisplayDiffAmount = parsedAmount[Field.LEFT].minus(borrowedAmount.dp(2)); + const activeLoanAccount = useActiveLoanAddress(); + const usedAmount = useLoanUsedAmount(activeLoanAccount); const percent = borrowableAmountWithReserve.isZero() ? 0 : usedAmount.div(borrowableAmountWithReserve).times(100).toNumber(); @@ -291,7 +276,6 @@ const LoanPanel = () => { diff --git a/apps/web/src/app/components/home/_components/xCollateralModal/index.tsx b/apps/web/src/app/components/home/_components/xCollateralModal/index.tsx index 51a777f8f..793773450 100644 --- a/apps/web/src/app/components/home/_components/xCollateralModal/index.tsx +++ b/apps/web/src/app/components/home/_components/xCollateralModal/index.tsx @@ -34,7 +34,6 @@ export enum XCollateralAction { type XCollateralModalProps = { modalId?: MODAL_ID; account: string | undefined; - increaseEnabled: boolean; sourceChain: XChainId; storedModalValues: { amount: string; @@ -49,7 +48,6 @@ const XCollateralModal = ({ modalId = MODAL_ID.XCOLLATERAL_CONFIRM_MODAL, account, currencyAmount, - increaseEnabled, sourceChain, storedModalValues, }: XCollateralModalProps) => { @@ -104,7 +102,6 @@ const XCollateralModal = ({ const sendXTransaction = useSendXTransaction(); const handleXCollateralAction = async () => { - if (storedModalValues.action === XCollateralAction.DEPOSIT && !increaseEnabled) return; if (!account) return; if (!xCallFee) return; if (!_inputAmount) return; @@ -143,7 +140,6 @@ const XCollateralModal = ({ useLoanWalletServiceHandler(); const { isWrongChain, handleSwitchChain } = useEvmSwitchChain(sourceChain); - const increaseDisabled = storedModalValues.action === XCollateralAction.DEPOSIT && !increaseEnabled; return ( <> @@ -220,10 +216,7 @@ const XCollateralModal = ({ ) : ( <> {approvalState !== ApprovalState.APPROVED ? ( -