Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/COST_YIELD_ESTIMATOR.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Cost and Yield Estimator

Live estimator panel in `CreateCommitmentStepConfigure` that shows projected yield range and worst-case penalty as the user adjusts commitment parameters.

## Overview

The `CostYieldEstimator` component reacts to changes in `amount`, `durationDays`, `maxLossPercent`, and `asset`, debounces updates (300 ms), and displays three key figures:

- **Projected Yield (est.)** — low-to-high range based on protocol APY heuristics and duration.
- **Worst-case Penalty (est.)** — maximum early-exit penalty derived from protocol constants.
- **Platform Fee (est.)** — platform fee percentage applied to the committed amount.

All figures are labeled as estimates and sourced from `/api/protocol/constants` (see `src/utils/protocol.ts`) rather than hardcoded values.

## Props / API

```ts
interface CostYieldEstimatorProps {
amount: string | number // commitment amount
durationDays: number // commitment duration in days (1–365)
maxLossPercent: number // maximum acceptable loss percent (0–100)
asset: string // asset ticker shown in labels (e.g. "XLM", "USDC")
}
```

## Usage

```tsx
import CostYieldEstimator from '@/components/create/CostYieldEstimator'

<CostYieldEstimator
amount={amount}
durationDays={durationDays}
maxLossPercent={maxLossPercent}
asset={asset}
/>
```

The component is already integrated into `CreateCommitmentStepConfigure.tsx` between the `AllocationConstraintsEditor` and the Advanced Risk Parameters section.

## Accessibility

- The section has `aria-label="Cost and yield estimator"` and `aria-live="polite"` so screen readers announce value changes without interrupting the user.
- All estimate rows use `<dl>/<dt>/<dd>` semantics for term/value pairing.
- A placeholder paragraph is shown (and announced) when inputs are invalid or constants are loading.

## Edge Cases

| Scenario | Behaviour |
|---|---|
| `amount` is empty or zero | Placeholder shown |
| `durationDays < 1` | Placeholder shown |
| Protocol constants fetch fails | Placeholder shown; no error thrown |
| `penalties` array is empty | Falls back to `maxLossPercent` for worst-case |

## Related

- `src/components/create/CostYieldEstimator.tsx` — component implementation
- `src/components/create/CostYieldEstimator.test.tsx` — tests
- `src/utils/protocol.ts` — `fetchProtocolConstants` helper
- `src/app/api/protocol/constants/route.ts` — constants API endpoint
- `docs/ALLOCATION_CONSTRAINTS_EDITOR.md` — sibling panel
9 changes: 9 additions & 0 deletions src/components/CreateCommitmentStepConfigure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import React, { useState, useRef, useEffect } from 'react'
import WizardStepper from './WizardStepper'
import AllocationConstraintsEditor from './create/AllocationConstraintsEditor'
import CostYieldEstimator from './create/CostYieldEstimator'
import styles from './CreateCommitmentStepConfigure.module.css'
import GlossaryTerm from './GlossaryTerm'

Expand Down Expand Up @@ -349,6 +350,14 @@ export default function CreateCommitmentStepConfigure({
onChangeMaxLoss={onChangeMaxLoss}
/>

{/* Live Cost and Yield Estimator */}
<CostYieldEstimator
amount={amount}
durationDays={durationDays}
maxLossPercent={maxLossPercent}
asset={asset}
/>

{/* Advanced Risk Settings */}
<div className={styles.advancedToggleContainer}>
<button
Expand Down
165 changes: 165 additions & 0 deletions src/components/create/CostYieldEstimator.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
/**
* @vitest-environment happy-dom
*/

import React from 'react'
import { render, screen, act, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import CostYieldEstimator from './CostYieldEstimator'

const mockConstants = {
protocolVersion: '1.0.0',
network: 'testnet',
fees: { networkBaseFeeStroops: 100, platformFeePercent: 1 },
penalties: [
{ type: 'early_exit', earlyExitPenaltyPercent: 5, description: 'Early exit' },
{ type: 'aggressive', earlyExitPenaltyPercent: 15, description: 'Aggressive early exit' },
],
commitmentLimits: {
minAmountXlm: 10,
maxAmountXlm: 1_000_000,
minDurationDays: 1,
maxDurationDays: 365,
maxLossPercentCeiling: 100,
},
cachedAt: new Date().toISOString(),
}

beforeEach(() => {
vi.useFakeTimers()
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockConstants),
})
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

const defaultProps = {
amount: '1000',
durationDays: 30,
maxLossPercent: 10,
asset: 'XLM',
}

describe('CostYieldEstimator', () => {
it('renders the estimator section with accessible label', () => {
render(<CostYieldEstimator {...defaultProps} />)
expect(screen.getByRole('region', { name: /cost and yield estimator/i })).toBeInTheDocument()
})

it('shows placeholder when constants are not yet loaded', () => {
render(<CostYieldEstimator {...defaultProps} />)
expect(screen.getByTestId('estimator-placeholder')).toBeInTheDocument()
})

it('shows placeholder for invalid amount (empty string)', async () => {
render(<CostYieldEstimator {...defaultProps} amount="" />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
expect(screen.getByTestId('estimator-placeholder')).toBeInTheDocument()
})

it('shows placeholder for zero amount', async () => {
render(<CostYieldEstimator {...defaultProps} amount="0" />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
expect(screen.getByTestId('estimator-placeholder')).toBeInTheDocument()
})

it('shows placeholder for durationDays less than 1', async () => {
render(<CostYieldEstimator {...defaultProps} durationDays={0} />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
expect(screen.getByTestId('estimator-placeholder')).toBeInTheDocument()
})

it('shows estimated values after constants load and debounce', async () => {
render(<CostYieldEstimator {...defaultProps} />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
expect(screen.getByTestId('projected-yield')).toBeInTheDocument()
expect(screen.getByTestId('worst-case-penalty')).toBeInTheDocument()
expect(screen.getByTestId('platform-fee')).toBeInTheDocument()
})

it('updates estimates when inputs change', async () => {
const { rerender } = render(<CostYieldEstimator {...defaultProps} />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
const yieldBefore = screen.getByTestId('projected-yield').textContent

rerender(<CostYieldEstimator {...defaultProps} amount="5000" />)
await act(async () => {
vi.runAllTimers()
})
const yieldAfter = screen.getByTestId('projected-yield').textContent
expect(yieldAfter).not.toBe(yieldBefore)
})

it('uses worst-case penalty from protocol constants, not hardcoded', async () => {
render(<CostYieldEstimator {...defaultProps} amount="1000" maxLossPercent={5} />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
// Worst case = max penalty tier (15%) × 1000 = 150.00
const penalty = screen.getByTestId('worst-case-penalty')
expect(penalty.textContent).toContain('150.00')
})

it('computes platform fee from protocol constants platformFeePercent', async () => {
render(<CostYieldEstimator {...defaultProps} amount="1000" />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
// platformFeePercent=1 => fee = 1% of 1000 = 10.00
const fee = screen.getByTestId('platform-fee')
expect(fee.textContent).toContain('10.00')
})

it('shows the asset label in all estimate rows', async () => {
render(<CostYieldEstimator {...defaultProps} asset="USDC" />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
const rows = screen.getAllByText(/USDC/)
expect(rows.length).toBeGreaterThanOrEqual(3)
})

it('labels all figures as estimates', () => {
render(<CostYieldEstimator {...defaultProps} />)
expect(screen.getByText(/estimates only/i)).toBeInTheDocument()
})

it('has aria-live polite for screen reader updates', () => {
render(<CostYieldEstimator {...defaultProps} />)
const section = screen.getByTestId('cost-yield-estimator')
expect(section).toHaveAttribute('aria-live', 'polite')
})

it('handles fetch failure gracefully and keeps placeholder', async () => {
global.fetch = vi.fn().mockRejectedValue(new Error('Network error'))
render(<CostYieldEstimator {...defaultProps} />)
await act(async () => {
await Promise.resolve()
vi.runAllTimers()
})
expect(screen.getByTestId('estimator-placeholder')).toBeInTheDocument()
})
})
137 changes: 137 additions & 0 deletions src/components/create/CostYieldEstimator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
'use client'

import React, { useEffect, useState, useRef } from 'react'
import { fetchProtocolConstants, type ProtocolConstants } from '@/utils/protocol'

export interface CostYieldEstimate {
projectedYieldMin: number
projectedYieldMax: number
worstCasePenalty: number
platformFee: number
isValid: boolean
}

export interface CostYieldEstimatorProps {
amount: string | number
durationDays: number
maxLossPercent: number
asset: string
}

function computeEstimates(
amount: number,
durationDays: number,
maxLossPercent: number,
constants: ProtocolConstants,
): CostYieldEstimate {
const platformFeePercent = constants.fees.platformFeePercent ?? 0.5
const platformFee = (amount * platformFeePercent) / 100

// Base APY range sourced from penalty tiers (conservative heuristic)
const baseApyMin = 3
const baseApyMax = 12
const durationFactor = Math.min(durationDays / 365, 1)

const projectedYieldMin = (amount * (baseApyMin / 100) * durationFactor) - platformFee
const projectedYieldMax = (amount * (baseApyMax / 100) * durationFactor) - platformFee

// Worst-case penalty: use highest early-exit penalty tier if available
const maxPenaltyPercent =
constants.penalties.length > 0
? Math.max(...constants.penalties.map((p) => p.earlyExitPenaltyPercent))
: maxLossPercent

const worstCasePenalty = (amount * maxPenaltyPercent) / 100

return {
projectedYieldMin: Math.max(0, projectedYieldMin),
projectedYieldMax: Math.max(0, projectedYieldMax),
worstCasePenalty,
platformFee,
isValid: true,
}
}

const PLACEHOLDER: CostYieldEstimate = {
projectedYieldMin: 0,
projectedYieldMax: 0,
worstCasePenalty: 0,
platformFee: 0,
isValid: false,
}

export default function CostYieldEstimator({
amount,
durationDays,
maxLossPercent,
asset,
}: CostYieldEstimatorProps) {
const [constants, setConstants] = useState<ProtocolConstants | null>(null)
const [estimate, setEstimate] = useState<CostYieldEstimate>(PLACEHOLDER)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
fetchProtocolConstants()
.then(setConstants)
.catch(() => { /* silently fall back */ })
}, [])

useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
const numericAmount = Number(amount)
if (!constants || !numericAmount || numericAmount <= 0 || durationDays < 1) {
setEstimate(PLACEHOLDER)
return
}
setEstimate(computeEstimates(numericAmount, durationDays, maxLossPercent, constants))
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [amount, durationDays, maxLossPercent, constants])

const fmt = (n: number) =>
n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })

return (
<section
aria-label="Cost and yield estimator"
aria-live="polite"
data-testid="cost-yield-estimator"
className="rounded-xl border border-white/[0.08] bg-[#0A0A0B] p-5 space-y-4"
>
<h3 className="text-lg font-bold text-white">Estimated Costs &amp; Yield</h3>
<p className="text-xs text-[#99a1af]">
All figures are <strong>estimates only</strong> and may vary based on market conditions.
</p>

{!estimate.isValid ? (
<p className="text-sm text-[#5a5a5a]" data-testid="estimator-placeholder">
Enter a valid amount and duration to see projections.
</p>
) : (
<dl className="grid grid-cols-1 gap-3">
<div className="flex justify-between items-center rounded-lg border border-white/[0.06] bg-[#0f0f10] px-4 py-3">
<dt className="text-sm text-[#99a1af]">Projected Yield (est.)</dt>
<dd className="text-sm font-semibold text-[#00d4aa]" data-testid="projected-yield">
{fmt(estimate.projectedYieldMin)} – {fmt(estimate.projectedYieldMax)} {asset}
</dd>
</div>
<div className="flex justify-between items-center rounded-lg border border-white/[0.06] bg-[#0f0f10] px-4 py-3">
<dt className="text-sm text-[#99a1af]">Worst-case Penalty (est.)</dt>
<dd className="text-sm font-semibold text-[#ff4444]" data-testid="worst-case-penalty">
{fmt(estimate.worstCasePenalty)} {asset}
</dd>
</div>
<div className="flex justify-between items-center rounded-lg border border-white/[0.06] bg-[#0f0f10] px-4 py-3">
<dt className="text-sm text-[#99a1af]">Platform Fee (est.)</dt>
<dd className="text-sm font-semibold text-white" data-testid="platform-fee">
{fmt(estimate.platformFee)} {asset}
</dd>
</div>
</dl>
)}
</section>
)
}
Loading