From 213f1e315fb30f539b4fed3a4c3f8126f60af27a Mon Sep 17 00:00:00 2001 From: DevScoopee Date: Sat, 26 Sep 2026 23:14:01 -0400 Subject: [PATCH] I added a creator-only curve migration section to the creator key dashboard's Governance tab. It lists pending proposals with their proposed parameters, a live timelock countdown, and vote approval percentage, and it keeps the Execute button disabled until the vote has reached quorum with majority support and the timelock has fully elapsed. Executed migrations are kept in a history section showing the parameters they applied and the date they were applied. The work added six new files (types, pure utilities, service, hook, component, and documentation), extended two existing files, and added forty-nine passing tests across six test files. A curve migration changes the bonding-curve parameters that every holder buys and sells at, so rather than being applied directly it is handled as a governance action with two independent gates. The vote must reach quorum and carry strictly more weight in favour than against, and the post-vote timelock must have elapsed so holders have a window to exit before the new pricing goes live. Both gates are implemented as pure functions in a shared utilities module, which means the panel derives the Execute button's disabled state and its explanatory text from the same source, so the message can never contradict the button. The panel itself only renders, and follows the patterns of the vesting panel that already sits nearby in the codebase. The contract call is assembled by a pure helper so the exact call can be verified in tests without signing anything, and on success both of the repository's cache layers are invalidated so the migration appears in history with its real applied parameters. Creator-only visibility reuses the page's existing owner check, so for non-creators the section never renders and the query never runs. Closes #985 --- docs/CurveMigrationPanel.md | 75 ++++ src/components/common/CurveMigrationPanel.tsx | 371 ++++++++++++++++++ .../__tests__/CurveMigrationPanel.test.tsx | 293 ++++++++++++++ ...torContractActions.curveMigration.test.tsx | 90 +++++ .../__tests__/useCurveMigrations.test.tsx | 109 +++++ src/hooks/useCreatorContractActions.ts | 37 +- src/hooks/useCurveMigrations.ts | 27 ++ src/lib/queryKeys.ts | 2 + src/pages/CreatorDashboardPage.tsx | 53 ++- ...rdPage.curveMigration.integration.test.tsx | 248 ++++++++++++ .../__tests__/curveMigration.service.test.ts | 133 +++++++ src/services/curveMigration.service.ts | 60 +++ src/types/curveMigration.ts | 90 +++++ .../__tests__/curveMigration.utils.test.ts | 274 +++++++++++++ src/utils/curveMigration.utils.ts | 361 +++++++++++++++++ 15 files changed, 2217 insertions(+), 6 deletions(-) create mode 100644 docs/CurveMigrationPanel.md create mode 100644 src/components/common/CurveMigrationPanel.tsx create mode 100644 src/components/common/__tests__/CurveMigrationPanel.test.tsx create mode 100644 src/hooks/__tests__/useCreatorContractActions.curveMigration.test.tsx create mode 100644 src/hooks/__tests__/useCurveMigrations.test.tsx create mode 100644 src/hooks/useCurveMigrations.ts create mode 100644 src/pages/__tests__/CreatorDashboardPage.curveMigration.integration.test.tsx create mode 100644 src/services/__tests__/curveMigration.service.test.ts create mode 100644 src/services/curveMigration.service.ts create mode 100644 src/types/curveMigration.ts create mode 100644 src/utils/__tests__/curveMigration.utils.test.ts create mode 100644 src/utils/curveMigration.utils.ts diff --git a/docs/CurveMigrationPanel.md b/docs/CurveMigrationPanel.md new file mode 100644 index 00000000..28e0c5f7 --- /dev/null +++ b/docs/CurveMigrationPanel.md @@ -0,0 +1,75 @@ +# Curve Migration Panel + +Creator-only management surface for the bonding-curve migrations queued against +a creator key. It lives in the **Governance** tab of the creator dashboard +(`src/pages/CreatorDashboardPage.tsx`). + +- Component: `CurveMigrationPanel` +- File: `src/components/common/CurveMigrationPanel.tsx` +- Hook: `useCurveMigrations` — `src/hooks/useCurveMigrations.ts` +- Service: `curveMigrationService` — `src/services/curveMigration.service.ts` +- Types: `src/types/curveMigration.ts` +- Pure helpers: `src/utils/curveMigration.utils.ts` +- Execute mutation: `useExecuteCurveMigrationMutation` — + `src/hooks/useCreatorContractActions.ts` + +## Why + +A curve migration changes the price every holder buys and sells at, so it is +governed rather than applied directly. Two **independent** gates must both be +satisfied before the creator can execute one: + +1. **Vote approval** — quorum reached *and* more weight `for` than `against`. +2. **Timelock elapsed** — the post-vote delay has passed, giving holders a + window to exit before the new pricing goes live. + +Either gate being unmet keeps Execute disabled, and the panel shows which one is +still blocking. + +## What the panel renders + +| Region | Test id | Contents | +| --- | --- | --- | +| Pending list | `curve-migration-pending-section` | One card per `pending` migration, soonest timelock first | +| Proposed params | `curve-migration-params-{id}` | `from → to` diff of base price, growth factor, and graduated curve tiers; unchanged params carry `data-changed="false"` | +| Timelock | `curve-migration-timelock-{id}` | Live `HH:MM:SS` countdown, or `Ready` once elapsed | +| Vote approval | `curve-migration-vote-approval-{id}` | Share of cast weight that voted `for` | +| Vote status | `curve-migration-vote-status-{id}` | `Approved` / `Not approved`, with quorum and participation below | +| Execute | `curve-migration-execute-{id}` | Enabled only when both gates pass | +| History | `curve-migration-history-section` | Executed migrations with applied params and execution date | + +The countdown only re-renders while a timelock is actually running. + +## Visibility + +`CreatorDashboardPage` gates the section on `isKeyCreator` — the connected +wallet must match the key's `instructorId`. For non-creators the section is not +rendered at all **and** the `useCurveMigrations` query stays disabled, so no +migration data is fetched for wallets that cannot act on it. + +## Execute call + +Executing submits `execute_curve_migration`, built by the pure +`buildCurveMigrationExecuteCall` helper so the exact call is assertable without +signing anything: + +```ts +buildCurveMigrationExecuteCall({ creatorId, migrationId }); +// { functionName: 'execute_curve_migration', args: { creatorId, migrationId } } +``` + +On success both cache layers are invalidated together — the 15s +`curve_migrations_{keyId}` entry and the +`queryKeys.creators.curveMigrations(creatorId)` query — so the migration moves +from the pending list into the history with the params it actually applied. + +## Local validation + +```bash +pnpm test src/utils/__tests__/curveMigration.utils.test.ts +pnpm test src/services/__tests__/curveMigration.service.test.ts +pnpm test src/hooks/__tests__/useCurveMigrations.test.tsx +pnpm test src/hooks/__tests__/useCreatorContractActions.curveMigration.test.tsx +pnpm test src/components/common/__tests__/CurveMigrationPanel.test.tsx +pnpm test src/pages/__tests__/CreatorDashboardPage.curveMigration.integration.test.tsx +``` diff --git a/src/components/common/CurveMigrationPanel.tsx b/src/components/common/CurveMigrationPanel.tsx new file mode 100644 index 00000000..6d47adf5 --- /dev/null +++ b/src/components/common/CurveMigrationPanel.tsx @@ -0,0 +1,371 @@ +import React, { useEffect, useState } from 'react'; +import { AlertTriangle, CheckCircle2, Clock, Lock } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { AsyncButton } from '@/components/ui/async-button'; +import { cn } from '@/lib/utils'; +import { bpsToPercent, formatPercent } from '@/utils/numberFormat.utils'; +import { + canExecuteCurveMigration, + formatCurveMigrationCountdown, + formatCurveMigrationDate, + getCurveMigrationApprovalPercent, + getCurveMigrationExecuteDisabledReason, + getCurveMigrationParticipationPercent, + getCurveMigrationTimelockRemainingMs, + getCurveParamChanges, + isCurveMigrationVoteApproved, + type CurveParamChange, +} from '@/utils/curveMigration.utils'; +import type { + CurveMigration, + CurveMigrationParams, +} from '@/types/curveMigration'; + +/** Countdown refresh rate — a second is the resolution the label shows. */ +const COUNTDOWN_TICK_MS = 1_000; + +export interface CurveMigrationPanelProps { + /** Pending and executed migrations for the key, in any order. */ + migrations: CurveMigration[]; + /** Whether the migrations query is still in flight. */ + isLoading?: boolean; + /** Whether the migrations query failed. */ + isError?: boolean; + /** Id of the migration currently being executed, for its pending state. */ + executingMigrationId?: string | null; + /** Submits the `execute_curve_migration` call for a migration id. */ + onExecute: (migrationId: string) => void; + className?: string; +} + +/** + * Renders the `from → to` diff between the live curve and the proposed one. + * Unchanged parameters are dimmed so the creator sees what actually moves. + */ +const CurveParamDiffList: React.FC<{ + migrationId: string; + current: CurveMigrationParams | null | undefined; + proposed: CurveMigrationParams | null | undefined; + testId: string; +}> = ({ migrationId, current, proposed, testId }) => ( +
+ {getCurveParamChanges(current, proposed).map((change: CurveParamChange) => ( +
+
{change.label}
+
+ {change.from} + {change.changed && ( + + )} + {change.to} +
+
+ ))} +
+); + +/** + * Curve migration management for the creator dashboard. + * + * Pending migrations show the proposed parameter diff, the live timelock + * countdown, and the governance vote tally; the Execute action is enabled only + * once the vote carried the migration *and* the timelock has elapsed. Executed + * migrations are listed underneath with the params they applied and the date + * they were applied. + * + * The panel is creator-only — see `CreatorDashboardPage`, which gates it on the + * connected wallet matching the key's creator address. + */ +export const CurveMigrationPanel: React.FC = ({ + migrations, + isLoading = false, + isError = false, + executingMigrationId = null, + onExecute, + className, +}) => { + const [now, setNow] = useState(() => Date.now()); + const list = Array.isArray(migrations) ? migrations : []; + const pending = list.filter(migration => migration.status === 'pending'); + const hasCountdown = pending.some( + migration => getCurveMigrationTimelockRemainingMs(migration, now) > 0 + ); + + // Tick only while a timelock is actually running so the countdown stays + // live without re-rendering the panel for the whole page session. + useEffect(() => { + if (!hasCountdown) return; + const id = window.setInterval( + () => setNow(Date.now()), + COUNTDOWN_TICK_MS + ); + return () => window.clearInterval(id); + }, [hasCountdown]); + + if (isLoading) { + return ( +
+ +
+ + + +
+ +
+ ); + } + + if (isError) { + return ( +
+
+ ); + } + + if (list.length === 0) { + return ( +
+ No curve migrations yet. A migration proposal will appear here with + its proposed pricing, vote status, and timelock. +
+ ); + } + + return ( +
+ {pending.length > 0 && ( +
+

+ Pending migrations +

+ + {pending.map(migration => { + const remainingMs = getCurveMigrationTimelockRemainingMs( + migration, + now + ); + const canExecute = canExecuteCurveMigration(migration, now); + const disabledReason = getCurveMigrationExecuteDisabledReason( + migration, + now + ); + const isExecuting = executingMigrationId === migration.id; + + return ( +
+
+
+

+ {migration.title} +

+ {migration.description && ( +

+ {migration.description} +

+ )} +
+ + {canExecute ? ( + +
+ + + + {/* Timelock + governance vote status */} +
+
+
+ Timelock +
+
+ {formatCurveMigrationCountdown(remainingMs)} +
+
+
+
+ Vote approval +
+
+ {formatPercent( + getCurveMigrationApprovalPercent(migration), + { maximumFractionDigits: 1 } + )} +
+
+
+
+ Vote status +
+
+ {isCurveMigrationVoteApproved(migration) + ? 'Approved' + : 'Not approved'} +
+
+ Quorum {bpsToPercent(migration.quorumBps)} ·{' '} + {formatPercent( + getCurveMigrationParticipationPercent(migration), + { maximumFractionDigits: 1 } + )}{' '} + participation +
+
+
+ +
+ {disabledReason ? ( +

+

+ ) : ( +

+ Vote carried and timelock elapsed — this migration + can be applied. +

+ )} + onExecute(migration.id)} + > + Execute migration + +
+
+ ); + })} +
+ )} + + {/* Executed history — applied params and execution date */} + {list.some(migration => migration.status === 'executed') && ( +
+

+ Executed migrations +

+ {list + .filter(migration => migration.status === 'executed') + .map(migration => ( +
+
+
+

+ {migration.title} +

+

+ Executed {formatCurveMigrationDate(migration.executedAt)} +

+
+ + Executed + +
+ +
+ ))} +
+ )} +
+ ); +}; + +export default CurveMigrationPanel; diff --git a/src/components/common/__tests__/CurveMigrationPanel.test.tsx b/src/components/common/__tests__/CurveMigrationPanel.test.tsx new file mode 100644 index 00000000..c12dca41 --- /dev/null +++ b/src/components/common/__tests__/CurveMigrationPanel.test.tsx @@ -0,0 +1,293 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; +import CurveMigrationPanel from '../CurveMigrationPanel'; +import type { + CurveMigration, + CurveMigrationParams, +} from '@/types/curveMigration'; + +const NOW = Date.parse('2026-09-26T12:00:00.000Z'); +const HOUR_MS = 60 * 60 * 1000; + +const currentParams: CurveMigrationParams = { + basePriceStroops: 10_000_000, + growthFactor: 1.01, + milestones: [{ supply: 100, exponent: 1 }], +}; + +const proposedParams: CurveMigrationParams = { + basePriceStroops: 20_000_000, + growthFactor: 1.02, + milestones: [ + { supply: 100, exponent: 1 }, + { supply: 500, exponent: 3 }, + ], +}; + +function createMigration(overrides: Partial = {}): CurveMigration { + return { + id: 'migration-1', + keyId: 'creator-1', + title: 'Tighten the curve', + description: 'Steepen the curve after the first tier.', + status: 'pending', + currentParams, + proposedParams, + timelockEndsAt: new Date(NOW - HOUR_MS).toISOString(), + forVotes: 60, + againstVotes: 20, + quorumBps: 4000, + eligibleVotingWeight: 100, + totalCirculatingSupply: 100, + totalVotingWeight: 80, + ...overrides, + }; +} + +describe('CurveMigrationPanel', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('renders the loading and error states', () => { + const { unmount } = render( + + ); + expect( + screen.getByTestId('curve-migration-panel-loading') + ).toBeInTheDocument(); + unmount(); + + render( + + ); + expect( + screen.getByTestId('curve-migration-panel-error') + ).toBeInTheDocument(); + }); + + it('renders an empty state when the key has no migrations', () => { + render(); + + expect( + screen.getByTestId('curve-migration-panel-empty') + ).toBeInTheDocument(); + expect( + screen.queryByTestId('curve-migration-pending-section') + ).not.toBeInTheDocument(); + }); + + it('shows the proposed param diff, timelock countdown, and vote approval', () => { + render( + + ); + + expect( + screen.getByTestId('curve-migration-pending-migration-1') + ).toBeInTheDocument(); + + // Param diff flags the changed parameters and leaves the rest dimmed. + expect( + screen.getByTestId('curve-migration-param-migration-1-basePriceStroops') + ).toHaveAttribute('data-changed', 'true'); + expect( + screen.getByTestId('curve-migration-param-migration-1-milestones') + ).toHaveAttribute('data-changed', 'true'); + + expect( + screen.getByTestId('curve-migration-timelock-migration-1') + ).toHaveTextContent('01:00:00'); + expect( + screen.getByTestId('curve-migration-vote-approval-migration-1') + ).toHaveTextContent('75%'); + expect( + screen.getByTestId('curve-migration-vote-status-migration-1') + ).toHaveTextContent('Approved'); + expect( + screen.getByTestId('curve-migration-vote-quorum-migration-1') + ).toHaveTextContent('40%'); + }); + + it('ticks the countdown down as the timelock runs', () => { + render( + + ); + + expect( + screen.getByTestId('curve-migration-timelock-migration-1') + ).toHaveTextContent('01:00:00'); + + act(() => { + vi.advanceTimersByTime(10 * 60 * 1000); + }); + + expect( + screen.getByTestId('curve-migration-timelock-migration-1') + ).toHaveTextContent('00:50:00'); + }); + + it('disables Execute while the timelock is still running', () => { + render( + + ); + + expect( + screen.getByTestId('curve-migration-execute-migration-1') + ).toBeDisabled(); + expect( + screen.getByTestId('curve-migration-disabled-reason-migration-1') + ).toHaveTextContent(/timelock/i); + expect( + screen.getByTestId('curve-migration-status-migration-1') + ).toHaveTextContent('Pending'); + }); + + it('disables Execute when quorum has not been reached', () => { + render( + + ); + + expect( + screen.getByTestId('curve-migration-execute-migration-1') + ).toBeDisabled(); + expect( + screen.getByTestId('curve-migration-vote-status-migration-1') + ).toHaveTextContent('Not approved'); + expect( + screen.getByTestId('curve-migration-disabled-reason-migration-1') + ).toHaveTextContent(/quorum/i); + }); + + it('enables Execute and submits the migration id once both conditions hold', () => { + const onExecute = vi.fn(); + render( + + ); + + const executeButton = screen.getByTestId('curve-migration-execute-migration-1'); + expect(executeButton).not.toBeDisabled(); + expect( + screen.getByTestId('curve-migration-timelock-migration-1') + ).toHaveTextContent('Ready'); + expect( + screen.getByTestId('curve-migration-status-migration-1') + ).toHaveTextContent('Ready to execute'); + expect( + screen.queryByTestId('curve-migration-disabled-reason-migration-1') + ).not.toBeInTheDocument(); + + fireEvent.click(executeButton); + expect(onExecute).toHaveBeenCalledWith('migration-1'); + }); + + it('shows the pending state on the executing migration only', () => { + render( + + ); + + expect( + screen.getByTestId('curve-migration-execute-migration-1') + ).toHaveAttribute('aria-busy', 'true'); + expect( + screen.getByTestId('curve-migration-execute-migration-2') + ).not.toHaveAttribute('aria-busy'); + }); + + it('lists executed migrations with their applied params and execution date', () => { + render( + + ); + + expect( + screen.getByTestId('curve-migration-history-section') + ).toBeInTheDocument(); + expect( + screen.getByTestId('curve-migration-history-migration-done') + ).toBeInTheDocument(); + expect( + screen.getByTestId('curve-migration-executed-at-migration-done') + ).toHaveTextContent('2027'); + expect( + screen.getByTestId( + 'curve-migration-param-migration-done-basePriceStroops' + ) + ).toHaveAttribute('data-changed', 'true'); + expect( + screen.queryByTestId('curve-migration-execute-migration-done') + ).not.toBeInTheDocument(); + }); + + it('drops unexecuted terminal migrations from both sections', () => { + render( + + ); + + expect( + screen.queryByTestId('curve-migration-pending-section') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('curve-migration-history-section') + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('curve-migration-execute-migration-1') + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/useCreatorContractActions.curveMigration.test.tsx b/src/hooks/__tests__/useCreatorContractActions.curveMigration.test.tsx new file mode 100644 index 00000000..89e185ac --- /dev/null +++ b/src/hooks/__tests__/useCreatorContractActions.curveMigration.test.tsx @@ -0,0 +1,90 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { type ReactNode } from 'react'; +import { useExecuteCurveMigrationMutation } from '@/hooks/useCreatorContractActions'; +import { queryKeys } from '@/lib/queryKeys'; +import { curveMigrationCacheKey } from '@/services/curveMigration.service'; +import { cacheManager } from '@/utils/cache.utils'; +import showToast from '@/utils/toast.util'; +import { EXECUTE_CURVE_MIGRATION_FUNCTION } from '@/utils/curveMigration.utils'; + +vi.mock('@/utils/toast.util', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +describe('useExecuteCurveMigrationMutation', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + vi.mocked(showToast.success).mockClear(); + vi.mocked(showToast.error).mockClear(); + cacheManager.invalidateAll(); + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('submits the execute call and invalidates both cache layers', async () => { + const { result } = renderHook( + () => useExecuteCurveMigrationMutation('creator-1'), + { wrapper } + ); + + // Seed both cache layers so the invalidation is observable. + queryClient.setQueryData( + queryKeys.creators.curveMigrations('creator-1'), + [] + ); + cacheManager.set(curveMigrationCacheKey('creator-1'), [], 60_000); + + await act(async () => { + await result.current.mutateAsync('migration-1'); + }); + + expect(showToast.success).toHaveBeenCalledWith('Curve migration executed'); + expect(showToast.error).not.toHaveBeenCalled(); + + await waitFor(() => + expect( + queryClient + .getQueryCache() + .find({ + queryKey: queryKeys.creators.curveMigrations('creator-1'), + }) + ).toBeDefined() + ); + expect(cacheManager.get(curveMigrationCacheKey('creator-1'))).toBeNull(); + }); + + it('exposes the migration id being executed for the pending button state', async () => { + const { result } = renderHook( + () => useExecuteCurveMigrationMutation('creator-1'), + { wrapper } + ); + + act(() => { + result.current.mutate('migration-42'); + }); + + await waitFor(() => expect(result.current.isPending).toBe(true)); + expect(result.current.variables).toBe('migration-42'); + + // The signing stub resolves on a 1.2s timer. + await waitFor(() => expect(result.current.isPending).toBe(false), { + timeout: 5000, + }); + }); + + it('names the contract function it executes', () => { + expect(EXECUTE_CURVE_MIGRATION_FUNCTION).toBe('execute_curve_migration'); + }); +}); diff --git a/src/hooks/__tests__/useCurveMigrations.test.tsx b/src/hooks/__tests__/useCurveMigrations.test.tsx new file mode 100644 index 00000000..f37571b0 --- /dev/null +++ b/src/hooks/__tests__/useCurveMigrations.test.tsx @@ -0,0 +1,109 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import React, { type ReactNode } from 'react'; +import { useCurveMigrations } from '@/hooks/useCurveMigrations'; +import { curveMigrationService } from '@/services/curveMigration.service'; +import { queryKeys } from '@/lib/queryKeys'; +import type { CurveMigration } from '@/types/curveMigration'; + +vi.mock('@/services/curveMigration.service', async importOriginal => { + const original = + await importOriginal< + typeof import('@/services/curveMigration.service') + >(); + return { + ...original, + curveMigrationService: { + ...original.curveMigrationService, + getMigrations: vi.fn(), + }, + }; +}); + +const mockGetMigrations = vi.mocked(curveMigrationService.getMigrations); + +function createMigration(overrides: Partial = {}): CurveMigration { + return { + id: 'migration-1', + keyId: 'creator-1', + title: 'Tighten the curve', + status: 'pending', + currentParams: { basePriceStroops: 10_000_000, growthFactor: 1.01, milestones: [] }, + proposedParams: { basePriceStroops: 20_000_000, growthFactor: 1.02, milestones: [] }, + timelockEndsAt: '2026-09-27T12:00:00.000Z', + forVotes: 60, + againstVotes: 20, + quorumBps: 4000, + totalCirculatingSupply: 100, + totalVotingWeight: 80, + ...overrides, + }; +} + +describe('useCurveMigrations', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + mockGetMigrations.mockReset(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it('fetches migrations for the key and partitions them', async () => { + mockGetMigrations.mockResolvedValue([ + createMigration({ id: 'executed-1', status: 'executed', executedAt: '2026-01-01T00:00:00.000Z' }), + createMigration({ id: 'pending-1' }), + createMigration({ id: 'rejected-1', status: 'rejected' }), + ]); + + const { result } = renderHook(() => useCurveMigrations('creator-1'), { + wrapper, + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(mockGetMigrations).toHaveBeenCalledWith('creator-1'); + expect(result.current.pending.map(migration => migration.id)).toEqual([ + 'pending-1', + ]); + expect(result.current.executed.map(migration => migration.id)).toEqual([ + 'executed-1', + ]); + + expect( + queryClient + .getQueryCache() + .find({ + queryKey: queryKeys.creators.curveMigrations('creator-1'), + }) + ).toBeDefined(); + }); + + it('does not fetch when the key id is empty or undefined', () => { + const { result } = renderHook(() => useCurveMigrations(undefined), { + wrapper, + }); + + expect(result.current.isFetching).toBe(false); + expect(result.current.pending).toEqual([]); + expect(result.current.executed).toEqual([]); + expect(mockGetMigrations).not.toHaveBeenCalled(); + }); + + it('exposes an error state without throwing when the query fails', async () => { + mockGetMigrations.mockRejectedValue(new Error('network down')); + + const { result } = renderHook(() => useCurveMigrations('creator-1'), { + wrapper, + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(result.current.pending).toEqual([]); + }); +}); diff --git a/src/hooks/useCreatorContractActions.ts b/src/hooks/useCreatorContractActions.ts index 9d5bc30b..83c4045e 100644 --- a/src/hooks/useCreatorContractActions.ts +++ b/src/hooks/useCreatorContractActions.ts @@ -3,6 +3,8 @@ import { queryKeys } from '@/lib/queryKeys'; import { cacheManager } from '@/utils/cache.utils'; import showToast from '@/utils/toast.util'; import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; +import { curveMigrationCacheKey } from '@/services/curveMigration.service'; +import { buildCurveMigrationExecuteCall } from '@/utils/curveMigration.utils'; import type { CreatorMetadataChange } from '@/utils/creatorMetadata.utils'; import type { GraduatedCurveMilestone } from '@/components/common/GraduatedCurvePanel'; @@ -10,7 +12,7 @@ import type { GraduatedCurveMilestone } from '@/components/common/GraduatedCurve * Creator-facing contract calls issued from the dashboard tabs * (`update_metadata` — #818, `configure_auction` / `cancel_auction` — #816, * `set_launch_penalty`, `set_max_buy_quantity`, `set_quorum_bps` — #828, - * `set_buy_cooldown` — #889). + * `set_buy_cooldown` — #889, `execute_curve_migration`). * * The on-chain wiring is not in the client yet, so each mutation simulates * signing latency and resolves. On success the creator detail query is @@ -230,6 +232,39 @@ export function useDeprecateKeyMutation(creatorId: string) { }); } +/** + * Executes an approved curve migration on a creator key. + * + * The button that triggers this is only enabled once the governance vote + * carried the migration *and* its timelock has elapsed; the contract re-checks + * both before applying {@link proposedParams}. On success the migration cache + * and query are invalidated together so the migration moves from the pending + * list into the executed history with its new params and execution date. + */ +export function useExecuteCurveMigrationMutation(creatorId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'execute_curve_migration', creatorId], + mutationFn: (migrationId: string) => { + const call = buildCurveMigrationExecuteCall({ creatorId, migrationId }); + return submitContractCall(call.functionName, call.args); + }, + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + // Drop both cache layers so the refetch returns the migration in + // its executed state with the params it actually applied. + cacheManager.invalidate(curveMigrationCacheKey(creatorId)); + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.curveMigrations(creatorId), + }); + showToast.success('Curve migration executed'); + }, + }); +} + /** * Claims the creator's vested allocation for a key (#960). * diff --git a/src/hooks/useCurveMigrations.ts b/src/hooks/useCurveMigrations.ts new file mode 100644 index 00000000..c1223ab1 --- /dev/null +++ b/src/hooks/useCurveMigrations.ts @@ -0,0 +1,27 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { curveMigrationService } from '@/services/curveMigration.service'; +import { partitionCurveMigrations } from '@/utils/curveMigration.utils'; +import type { CurveMigration } from '@/types/curveMigration'; + +/** + * Curve migrations for a creator key, split into the pending proposals the + * creator can act on and the executed history. + * + * The query stays disabled until a `keyId` is known — the panel is + * creator-only, so non-creators never reach this hook with an id. It + * refetches every 15 seconds so the timelock countdown, the vote tally, and + * the Execute button stay current without a manual refresh. + */ +export function useCurveMigrations(keyId: string | undefined) { + const query = useQuery({ + queryKey: queryKeys.creators.curveMigrations(keyId ?? ''), + queryFn: () => curveMigrationService.getMigrations(keyId!), + enabled: Boolean(keyId), + staleTime: 15_000, + refetchInterval: 15_000, + retry: false, + }); + + return { ...query, ...partitionCurveMigrations(query.data) }; +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 684e54ea..c34dfe7b 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -32,6 +32,8 @@ export const queryKeys = { ['creators', creatorId, 'stats'] as const, curveConfig: (creatorId: string) => ['creators', creatorId, 'curve-config'] as const, + curveMigrations: (creatorId: string) => + ['creators', creatorId, 'curve-migrations'] as const, buyback: (creatorId: string) => ['creators', creatorId, 'buyback'] as const, keyConfig: (creatorId: string) => diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index ad2307cf..b78ad1bc 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -12,6 +12,7 @@ import GraduatedCurvePanel from '@/components/common/GraduatedCurvePanel'; import BuyCooldownPanel from '@/components/common/BuyCooldownPanel'; import DeprecateKeyPanel from '@/components/common/DeprecateKeyPanel'; import VestingSchedulePanel from '@/components/common/VestingSchedulePanel'; +import CurveMigrationPanel from '@/components/common/CurveMigrationPanel'; import { AlertTriangle } from 'lucide-react'; import { useCancelAuctionMutation, @@ -24,8 +25,10 @@ import { useSetBuyCooldownMutation, useDeprecateKeyMutation, useClaimVestedTokensMutation, + useExecuteCurveMigrationMutation, } from '@/hooks/useCreatorContractActions'; import { useKeyVesting, useKeyVestingClaims } from '@/hooks/useKeyVesting'; +import { useCurveMigrations } from '@/hooks/useCurveMigrations'; import { isOwnWallet } from '@/utils/isOwnWallet'; import { formatDisplayKeyPrice, @@ -84,6 +87,16 @@ export default function CreatorDashboardPage() { address ?? '' ); + // Curve migrations change the pricing every holder buys at, so the panel is + // creator-only for the same reason the vesting schedule is: the query stays + // disabled and nothing is rendered for anyone but the key's creator. + const { + data: curveMigrations = [], + isLoading: isCurveMigrationsLoading, + isError: isCurveMigrationsError, + } = useCurveMigrations(isKeyCreator ? id : undefined); + const executeCurveMigration = useExecuteCurveMigrationMutation(id); + const setTab = (value: string) => { setSearchParams( prev => { @@ -411,13 +424,43 @@ export default function CreatorDashboardPage() { Set the minimum percentage of holders that must participate in a vote for a proposal to pass.

- setQuorumBps.mutate(quorumBps)} + setQuorumBps.mutate(quorumBps)} + /> + + + {/* Curve migration management — creator wallets only */} + {isKeyCreator && ( +
+

+ Curve Migrations +

+

+ Review pending bonding-curve changes, follow the timelock + and holder vote, then execute a migration once both + conditions are met. +

+ + executeCurveMigration.mutate(migrationId) + } />
- + )} + )} diff --git a/src/pages/__tests__/CreatorDashboardPage.curveMigration.integration.test.tsx b/src/pages/__tests__/CreatorDashboardPage.curveMigration.integration.test.tsx new file mode 100644 index 00000000..e9a29efd --- /dev/null +++ b/src/pages/__tests__/CreatorDashboardPage.curveMigration.integration.test.tsx @@ -0,0 +1,248 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import CreatorDashboardPage from '../CreatorDashboardPage'; +import { courseService, type Course } from '@/services/course.service'; +import { curveMigrationService } from '@/services/curveMigration.service'; +import { cacheManager } from '@/utils/cache.utils'; +import showToast from '@/utils/toast.util'; +import type { CurveMigration } from '@/types/curveMigration'; + +const CREATOR_ADDRESS = 'GCREATORWALLETADDRESS0000000000000000'; +const OTHER_ADDRESS = '0x1111111111111111111111111111111111111111'; +const KEY_ID = 'creator-1'; + +const connectedAddress = { current: OTHER_ADDRESS as string | undefined }; + +vi.mock('wagmi', () => ({ + useAccount: () => ({ address: connectedAddress.current }), +})); + +vi.mock('@/services/course.service', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + courseService: { + ...original.courseService, + getCourse: vi.fn(), + getKeyVesting: vi.fn(), + getKeyVestingClaims: vi.fn(), + }, + }; +}); + +vi.mock('@/services/curveMigration.service', async importOriginal => { + const original = + await importOriginal(); + return { + ...original, + curveMigrationService: { + ...original.curveMigrationService, + getMigrations: vi.fn(), + }, + }; +}); + +vi.mock('@/utils/toast.util', () => ({ + default: { + success: vi.fn(), + error: vi.fn(), + loading: vi.fn(), + transactionSuccess: vi.fn(), + }, +})); + +const mockGetCourse = vi.mocked(courseService.getCourse); +const mockGetKeyVesting = vi.mocked(courseService.getKeyVesting); +const mockGetKeyVestingClaims = vi.mocked(courseService.getKeyVestingClaims); +const mockGetMigrations = vi.mocked(curveMigrationService.getMigrations); + +function createCourse(overrides: Partial = {}): Course { + return { + id: KEY_ID, + title: 'Creator One', + description: 'A creator', + price: 10, + instructorId: CREATOR_ADDRESS, + category: 'Design', + level: 'BEGINNER', + quorumBps: 2000, + creatorShareSupply: 100, + ...overrides, + } as Course; +} + +function createMigration(overrides: Partial = {}): CurveMigration { + return { + id: 'migration-1', + keyId: KEY_ID, + title: 'Tighten the curve', + status: 'pending', + currentParams: { + basePriceStroops: 10_000_000, + growthFactor: 1.01, + milestones: [], + }, + proposedParams: { + basePriceStroops: 20_000_000, + growthFactor: 1.02, + milestones: [{ supply: 500, exponent: 3 }], + }, + timelockEndsAt: new Date(Date.now() - 60_000).toISOString(), + forVotes: 60, + againstVotes: 20, + quorumBps: 2000, + eligibleVotingWeight: 100, + totalCirculatingSupply: 100, + totalVotingWeight: 80, + ...overrides, + }; +} + +function renderDashboard() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return render( + + + + } + /> + + + + ); +} + +describe('CreatorDashboardPage curve migration section', () => { + beforeEach(() => { + connectedAddress.current = CREATOR_ADDRESS; + cacheManager.invalidateAll(); + mockGetCourse.mockResolvedValue(createCourse()); + mockGetKeyVesting.mockResolvedValue({ + totalAllocationXlm: 0, + claimedAmountXlm: 0, + vestedAmountXlm: 0, + claimableXlm: 0, + }); + mockGetKeyVestingClaims.mockResolvedValue([]); + mockGetMigrations.mockResolvedValue([]); + vi.mocked(showToast.success).mockClear(); + vi.mocked(showToast.error).mockClear(); + }); + + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it('renders the migration section for the creator wallet with pending and executed migrations', async () => { + mockGetMigrations.mockResolvedValue([ + createMigration(), + createMigration({ + id: 'migration-done', + status: 'executed', + executedAt: '2027-03-12T00:00:00.000Z', + }), + ]); + + renderDashboard(); + + await waitFor(() => + expect( + screen.getByTestId('curve-migration-pending-migration-1') + ).toBeInTheDocument() + ); + + expect( + screen.getByTestId('curve-migration-section') + ).toBeInTheDocument(); + expect( + screen.getByTestId('curve-migration-pending-section') + ).toBeInTheDocument(); + expect( + screen.getByTestId('curve-migration-history-section') + ).toBeInTheDocument(); + expect( + screen.getByTestId('curve-migration-history-migration-done') + ).toBeInTheDocument(); + expect(mockGetMigrations).toHaveBeenCalledWith(KEY_ID); + }); + + it('executes an approved migration and confirms it with a toast', async () => { + mockGetMigrations.mockResolvedValue([createMigration()]); + + renderDashboard(); + + const executeButton = await waitFor(() => + screen.getByTestId('curve-migration-execute-migration-1') + ); + expect(executeButton).not.toBeDisabled(); + + fireEvent.click(executeButton); + + await waitFor( + () => expect(showToast.success).toHaveBeenCalledWith('Curve migration executed'), + { timeout: 5000 } + ); + expect(showToast.error).not.toHaveBeenCalled(); + }); + + it('keeps Execute disabled while the timelock is still counting down', async () => { + mockGetMigrations.mockResolvedValue([ + createMigration({ + timelockEndsAt: new Date(Date.now() + 3_600_000).toISOString(), + }), + ]); + + renderDashboard(); + + await waitFor(() => + expect( + screen.getByTestId('curve-migration-execute-migration-1') + ).toBeDisabled() + ); + expect( + screen.getByTestId('curve-migration-disabled-reason-migration-1') + ).toHaveTextContent(/timelock/i); + }); + + it('hides the section and skips the query for a non-creator wallet', async () => { + connectedAddress.current = OTHER_ADDRESS; + mockGetMigrations.mockResolvedValue([createMigration()]); + + renderDashboard(); + + await waitFor(() => + expect( + screen.getByTestId('dashboard-governance-panel') + ).toBeInTheDocument() + ); + expect( + screen.queryByTestId('curve-migration-section') + ).not.toBeInTheDocument(); + expect(mockGetMigrations).not.toHaveBeenCalled(); + }); + + it('surfaces a migration query failure without breaking the governance tab', async () => { + mockGetMigrations.mockRejectedValue(new Error('network down')); + + renderDashboard(); + + await waitFor(() => + expect( + screen.getByTestId('curve-migration-panel-error') + ).toBeInTheDocument() + ); + expect( + screen.getByTestId('quorum-settings-panel') + ).toBeInTheDocument(); + }); +}); diff --git a/src/services/__tests__/curveMigration.service.test.ts b/src/services/__tests__/curveMigration.service.test.ts new file mode 100644 index 00000000..ed3bbc8b --- /dev/null +++ b/src/services/__tests__/curveMigration.service.test.ts @@ -0,0 +1,133 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGet, mockPost } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + post: mockPost, + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + isAxiosError: (error: unknown): boolean => + error !== null && + typeof error === 'object' && + (error as Record).isAxiosError === true, + }, +})); + +import { + curveMigrationCacheKey, + curveMigrationService, +} from '@/services/curveMigration.service'; +import { cacheManager } from '@/utils/cache.utils'; +import { ApiError } from '@/services/api.service'; +import type { CurveMigration } from '@/types/curveMigration'; + +function fakeApiResponse(data: T) { + return { data: { success: true, data, message: 'ok' } }; +} + +function createMigration(overrides: Partial = {}): CurveMigration { + return { + id: 'migration-1', + keyId: 'creator-1', + title: 'Tighten the curve', + status: 'pending', + currentParams: { basePriceStroops: 10_000_000, growthFactor: 1.01, milestones: [] }, + proposedParams: { basePriceStroops: 20_000_000, growthFactor: 1.02, milestones: [] }, + timelockEndsAt: '2026-09-27T12:00:00.000Z', + forVotes: 60, + againstVotes: 20, + quorumBps: 4000, + totalCirculatingSupply: 100, + totalVotingWeight: 80, + ...overrides, + }; +} + +describe('curveMigrationService', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + cacheManager.invalidateAll(); + }); + + it('requests migrations for a key and caches them', async () => { + const migrations = [createMigration()]; + mockGet.mockResolvedValueOnce(fakeApiResponse(migrations)); + + await expect( + curveMigrationService.getMigrations('creator-1') + ).resolves.toEqual(migrations); + expect(mockGet).toHaveBeenCalledWith('/curve-migrations', { + params: { keyId: 'creator-1' }, + }); + + // Second call is served from the cache rather than another request. + await expect( + curveMigrationService.getMigrations('creator-1') + ).resolves.toEqual(migrations); + expect(mockGet).toHaveBeenCalledTimes(1); + }); + + it('refetches once the cache entry is invalidated', async () => { + mockGet.mockResolvedValue(fakeApiResponse([createMigration()])); + + await curveMigrationService.getMigrations('creator-1'); + cacheManager.invalidate(curveMigrationCacheKey('creator-1')); + await curveMigrationService.getMigrations('creator-1'); + + expect(mockGet).toHaveBeenCalledTimes(2); + }); + + it('surfaces API errors as ApiError', async () => { + mockGet.mockRejectedValueOnce( + Object.assign(new Error('Not found'), { + isAxiosError: true, + response: { status: 404, data: { message: 'Key not found' } }, + }) + ); + + const error = await curveMigrationService + .getMigrations('missing') + .catch((raw: unknown) => raw); + + expect(error).toBeInstanceOf(ApiError); + }); + + it('posts the execute call for a migration', async () => { + mockPost.mockResolvedValueOnce( + fakeApiResponse({ transactionHash: '0xdeadbeef' }) + ); + + await expect( + curveMigrationService.executeMigration('migration-1') + ).resolves.toEqual({ transactionHash: '0xdeadbeef' }); + expect(mockPost).toHaveBeenCalledWith( + '/curve-migrations/migration-1/execute', + {} + ); + }); + + it('surfaces execute failures as ApiError', async () => { + mockPost.mockRejectedValueOnce( + Object.assign(new Error('Server error'), { + isAxiosError: true, + response: { status: 500, data: { message: 'Execute failed' } }, + }) + ); + + const error = await curveMigrationService + .executeMigration('migration-1') + .catch((raw: unknown) => raw); + + expect(error).toBeInstanceOf(ApiError); + }); +}); diff --git a/src/services/curveMigration.service.ts b/src/services/curveMigration.service.ts new file mode 100644 index 00000000..20ba56ed --- /dev/null +++ b/src/services/curveMigration.service.ts @@ -0,0 +1,60 @@ +// src/services/curveMigration.service.ts +import { BaseApiService, type APIResponse } from './api.service'; +import { cacheManager } from '@/utils/cache.utils'; +import type { CurveMigration } from '@/types/curveMigration'; + +/** + * Curve migrations move faster than key profiles — a vote or a timelock can + * land at any second — so the cache window is kept short. + */ +const CURVE_MIGRATION_CACHE_TTL = 15_000; + +/** Cache key shared with the execute mutation's invalidation. */ +export function curveMigrationCacheKey(keyId: string): string { + return `curve_migrations_${keyId}`; +} + +class CurveMigrationService extends BaseApiService { + /** + * Fetch every curve migration for a creator key, pending and executed. + * GET /curve-migrations?keyId=:id + */ + async getMigrations(keyId: string): Promise { + const cacheKey = curveMigrationCacheKey(keyId); + const cached = cacheManager.get(cacheKey); + if (cached) return cached; + + try { + const response = await this.api.get>( + '/curve-migrations', + { params: { keyId } } + ); + + const data = response.data.data; + cacheManager.set(cacheKey, data, CURVE_MIGRATION_CACHE_TTL); + return data; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Apply an approved, unlocked migration to the key's curve. + * POST /curve-migrations/:migrationId/execute + */ + async executeMigration( + migrationId: string + ): Promise<{ transactionHash?: string | null }> { + try { + const response = await this.api.post< + APIResponse<{ transactionHash?: string | null }> + >(`/curve-migrations/${migrationId}/execute`, {}); + + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const curveMigrationService = new CurveMigrationService(); diff --git a/src/types/curveMigration.ts b/src/types/curveMigration.ts new file mode 100644 index 00000000..1f0f02df --- /dev/null +++ b/src/types/curveMigration.ts @@ -0,0 +1,90 @@ +/** + * Curve migration types for Access Layer. + * + * A curve migration changes the bonding-curve parameters of a creator key. + * Because the curve governs pricing for every holder, a migration is a + * governance action with two independent gates before it can be applied: + * + * 1. **Vote approval** — the proposal must reach quorum and carry more weight + * `for` than `against` (see `@/utils/governance.utils`). + * 2. **Timelock** — after the vote passes, a delay window gives holders time + * to exit before the new curve goes live. + * + * Only once both gates are satisfied can the creator call + * `execute_curve_migration` to apply {@link CurveMigration.proposedParams}. + * Executed migrations are retained as history so the applied params and the + * execution date stay auditable. + */ + +/** A single graduated-curve milestone carried by a migration. */ +export interface CurveMigrationMilestone { + /** Supply threshold at which the tier starts. */ + supply: number; + /** Exponent applied to the tier's price curve. */ + exponent: number; +} + +/** The set of curve parameters a migration can change. */ +export interface CurveMigrationParams { + /** Base price in stroops applied when supply is zero. */ + basePriceStroops: number; + /** Per-key growth factor (e.g. `1.01` for 1% growth). */ + growthFactor: number; + /** Graduated curve tiers, ascending by supply threshold. */ + milestones: CurveMigrationMilestone[]; +} + +/** + * Lifecycle of a curve migration. + * + * `pending` migrations are still actionable (vote running or timelock + * counting down); every other status is terminal and rendered in history. + */ +export type CurveMigrationStatus = + | 'pending' + | 'executed' + | 'rejected' + | 'cancelled' + | 'expired'; + +/** + * A curve migration proposal fetched from the backend API. + * + * Vote fields mirror the governance proposal payload so the same quorum + * helpers can be reused. + */ +export interface CurveMigration { + id: string; + /** Creator key the migration applies to. */ + keyId: string; + title: string; + description?: string; + status: CurveMigrationStatus; + /** Params currently live on-chain. */ + currentParams: CurveMigrationParams; + /** Params applied once the migration is executed. */ + proposedParams: CurveMigrationParams; + /** + * When the timelock expires and the migration becomes executable. ISO + * string or epoch milliseconds; epoch **seconds** are also accepted. + */ + timelockEndsAt: string | number; + /** Aggregate weight of `for` votes. */ + forVotes: number; + /** Aggregate weight of `against` votes. */ + againstVotes: number; + /** Quorum threshold in basis points (e.g. `4000` = 40%). */ + quorumBps: number; + /** Weight that can participate in the vote. */ + eligibleVotingWeight?: number; + /** Total circulating supply of the key — fallback for eligible weight. */ + totalCirculatingSupply: number; + /** Aggregate weight cast so far. */ + totalVotingWeight: number; + /** ISO timestamp / epoch ms of execution; `null` until executed. */ + executedAt?: string | number | null; + /** Wallet that submitted the execute transaction. */ + executedBy?: string | null; + /** Transaction hash of the execution, when reported. */ + transactionHash?: string | null; +} diff --git a/src/utils/__tests__/curveMigration.utils.test.ts b/src/utils/__tests__/curveMigration.utils.test.ts new file mode 100644 index 00000000..165a7def --- /dev/null +++ b/src/utils/__tests__/curveMigration.utils.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest'; +import type { + CurveMigration, + CurveMigrationParams, +} from '@/types/curveMigration'; +import { + buildCurveMigrationExecuteCall, + canExecuteCurveMigration, + EXECUTE_CURVE_MIGRATION_FUNCTION, + formatCurveMigrationCountdown, + formatCurveMigrationDate, + getCurveMigrationApprovalPercent, + getCurveMigrationExecuteDisabledReason, + getCurveMigrationParticipationPercent, + getCurveMigrationTimelockRemainingMs, + getCurveParamChanges, + isCurveMigrationTimelockElapsed, + isCurveMigrationVoteApproved, + partitionCurveMigrations, + toTimestampMs, +} from '@/utils/curveMigration.utils'; + +const NOW = Date.parse('2026-09-26T12:00:00.000Z'); +const HOUR_MS = 60 * 60 * 1000; + +const currentParams: CurveMigrationParams = { + basePriceStroops: 10_000_000, + growthFactor: 1.01, + milestones: [{ supply: 100, exponent: 1 }], +}; + +const proposedParams: CurveMigrationParams = { + basePriceStroops: 20_000_000, + growthFactor: 1.02, + milestones: [ + { supply: 100, exponent: 1 }, + { supply: 500, exponent: 3 }, + ], +}; + +function createMigration( + overrides: Partial = {} +): CurveMigration { + return { + id: 'migration-1', + keyId: 'creator-1', + title: 'Tighten the curve', + status: 'pending', + currentParams, + proposedParams, + // Timelock already elapsed relative to NOW. + timelockEndsAt: '2026-09-26T12:00:00.000Z', + forVotes: 60, + againstVotes: 20, + quorumBps: 4000, + eligibleVotingWeight: 100, + totalCirculatingSupply: 100, + totalVotingWeight: 80, + ...overrides, + }; +} + +describe('curve migration timestamp helpers', () => { + it('normalizes ISO strings, epoch milliseconds, and epoch seconds', () => { + expect(toTimestampMs('2026-09-26T12:00:00.000Z')).toBe(NOW); + expect(toTimestampMs(NOW)).toBe(NOW); + expect(toTimestampMs(Math.floor(NOW / 1000))).toBe(NOW); + }); + + it('returns null for missing or unparseable values', () => { + expect(toTimestampMs(null)).toBeNull(); + expect(toTimestampMs(undefined)).toBeNull(); + expect(toTimestampMs('not-a-date')).toBeNull(); + expect(toTimestampMs(Number.NaN)).toBeNull(); + }); +}); + +describe('curve migration timelock', () => { + it('counts down the milliseconds left on a running timelock', () => { + const migration = createMigration({ + timelockEndsAt: new Date(NOW + 2 * HOUR_MS).toISOString(), + }); + + expect(getCurveMigrationTimelockRemainingMs(migration, NOW)).toBe(2 * HOUR_MS); + expect(isCurveMigrationTimelockElapsed(migration, NOW)).toBe(false); + }); + + it('treats a passed timelock as elapsed and never reports negative time', () => { + const migration = createMigration({ + timelockEndsAt: new Date(NOW - HOUR_MS).toISOString(), + }); + + expect(getCurveMigrationTimelockRemainingMs(migration, NOW)).toBe(0); + expect(isCurveMigrationTimelockElapsed(migration, NOW)).toBe(true); + }); + + it('treats a missing timelock as elapsed instead of NaN', () => { + const migration = createMigration({ + timelockEndsAt: undefined as unknown as string, + }); + + expect(getCurveMigrationTimelockRemainingMs(migration, NOW)).toBe(0); + expect(isCurveMigrationTimelockElapsed(migration, NOW)).toBe(true); + }); + + it('formats the countdown as HH:MM:SS and reads "Ready" once elapsed', () => { + expect(formatCurveMigrationCountdown(HOUR_MS)).toBe('01:00:00'); + expect(formatCurveMigrationCountdown(0)).toBe('Ready'); + expect(formatCurveMigrationCountdown(-5)).toBe('Ready'); + expect(formatCurveMigrationCountdown(Number.NaN)).toBe('Ready'); + }); + + it('formats the execution date and falls back to a dash', () => { + expect(formatCurveMigrationDate('2027-03-12T00:00:00.000Z')).toContain('2027'); + expect(formatCurveMigrationDate(null)).toBe('—'); + expect(formatCurveMigrationDate('nope')).toBe('—'); + }); +}); + +describe('curve migration vote status', () => { + it('reports the share of cast weight that voted for', () => { + expect(getCurveMigrationApprovalPercent(createMigration())).toBe(75); + }); + + it('reports 0% approval before any weight is cast', () => { + expect( + getCurveMigrationApprovalPercent( + createMigration({ forVotes: 0, againstVotes: 0 }) + ) + ).toBe(0); + }); + + it('measures participation against the eligible weight', () => { + expect(getCurveMigrationParticipationPercent(createMigration())).toBe(80); + expect( + getCurveMigrationParticipationPercent( + createMigration({ eligibleVotingWeight: undefined, totalCirculatingSupply: 200 }) + ) + ).toBe(40); + }); + + it('approves only when quorum is met and for-votes outweigh against', () => { + expect(isCurveMigrationVoteApproved(createMigration())).toBe(true); + expect( + isCurveMigrationVoteApproved( + createMigration({ forVotes: 20, againstVotes: 60 }) + ) + ).toBe(false); + expect( + isCurveMigrationVoteApproved( + createMigration({ totalVotingWeight: 20 }) + ) + ).toBe(false); + }); +}); + +describe('curve migration execution gate', () => { + it('allows execution only once the timelock and vote conditions both hold', () => { + expect(canExecuteCurveMigration(createMigration(), NOW)).toBe(true); + }); + + it('blocks execution while the timelock is still running', () => { + const migration = createMigration({ + timelockEndsAt: new Date(NOW + HOUR_MS).toISOString(), + }); + + expect(canExecuteCurveMigration(migration, NOW)).toBe(false); + expect(getCurveMigrationExecuteDisabledReason(migration, NOW)).toMatch( + /timelock/i + ); + }); + + it('blocks execution when the vote has not carried the migration', () => { + const migration = createMigration({ totalVotingWeight: 10 }); + + expect(canExecuteCurveMigration(migration, NOW)).toBe(false); + expect(getCurveMigrationExecuteDisabledReason(migration, NOW)).toMatch( + /quorum/i + ); + }); + + it('has no disabled reason for an executable migration', () => { + expect(getCurveMigrationExecuteDisabledReason(createMigration(), NOW)).toBeNull(); + }); + + it('refuses migrations that are no longer pending', () => { + for (const status of ['executed', 'rejected', 'cancelled', 'expired'] as const) { + const migration = createMigration({ status }); + expect(canExecuteCurveMigration(migration, NOW)).toBe(false); + expect( + getCurveMigrationExecuteDisabledReason(migration, NOW) + ).toMatch(/no longer pending/i); + } + }); +}); + +describe('curve param diff', () => { + it('flags only the parameters the migration actually changes', () => { + const changes = getCurveParamChanges(currentParams, proposedParams); + + expect(changes.map(change => change.field)).toEqual([ + 'basePriceStroops', + 'growthFactor', + 'milestones', + ]); + expect(changes.every(change => change.changed)).toBe(true); + }); + + it('reports unchanged parameters and renders their values', () => { + const changes = getCurveParamChanges(proposedParams, proposedParams); + + expect(changes.every(change => !change.changed)).toBe(true); + expect(changes[0].from).toBe(changes[0].to); + expect(changes[2].to).toBe('2 tiers'); + }); + + it('treats missing params as unknown rather than throwing', () => { + const changes = getCurveParamChanges(null, undefined); + + expect(changes).toHaveLength(3); + expect(changes[0].from).toBe('—'); + expect(changes[1].from).toBe('—'); + expect(changes[2].from).toBe('None'); + expect(changes.every(change => change.to === change.from)).toBe(true); + expect(changes.every(change => !change.changed)).toBe(true); + }); +}); + +describe('partitionCurveMigrations', () => { + it('splits pending from executed and drops unexecuted terminal records', () => { + const { pending, executed } = partitionCurveMigrations([ + createMigration({ id: 'executed-1', status: 'executed', executedAt: '2026-01-01T00:00:00.000Z' }), + createMigration({ id: 'rejected-1', status: 'rejected' }), + createMigration({ id: 'pending-1' }), + ]); + + expect(pending.map(migration => migration.id)).toEqual(['pending-1']); + expect(executed.map(migration => migration.id)).toEqual(['executed-1']); + }); + + it('orders pending by soonest timelock and history by newest execution', () => { + const { pending, executed } = partitionCurveMigrations([ + createMigration({ id: 'late', timelockEndsAt: '2026-09-28T12:00:00.000Z' }), + createMigration({ id: 'early', timelockEndsAt: '2026-09-27T12:00:00.000Z' }), + createMigration({ id: 'older', status: 'executed', executedAt: '2026-01-01T00:00:00.000Z' }), + createMigration({ id: 'newer', status: 'executed', executedAt: '2026-05-05T00:00:00.000Z' }), + ]); + + expect(pending.map(migration => migration.id)).toEqual(['early', 'late']); + expect(executed.map(migration => migration.id)).toEqual(['newer', 'older']); + }); + + it('tolerates a missing or malformed list', () => { + expect(partitionCurveMigrations(undefined)).toEqual({ + pending: [], + executed: [], + }); + }); +}); + +describe('buildCurveMigrationExecuteCall', () => { + it('builds the execute_curve_migration contract call', () => { + expect( + buildCurveMigrationExecuteCall({ + creatorId: 'creator-1', + migrationId: 'migration-1', + }) + ).toEqual({ + functionName: 'execute_curve_migration', + args: { creatorId: 'creator-1', migrationId: 'migration-1' }, + }); + expect(EXECUTE_CURVE_MIGRATION_FUNCTION).toBe('execute_curve_migration'); + }); +}); diff --git a/src/utils/curveMigration.utils.ts b/src/utils/curveMigration.utils.ts new file mode 100644 index 00000000..96e9ad78 --- /dev/null +++ b/src/utils/curveMigration.utils.ts @@ -0,0 +1,361 @@ +/** + * Curve migration math for the creator dashboard. + * + * A curve migration is a governance action that swaps a creator key's + * bonding-curve parameters. Two independent gates must both be satisfied + * before the creator can execute it: + * + * 1. **Vote approval** — quorum reached *and* more weight `for` than + * `against` (reusing the governance helpers in `@/utils/governance.utils`). + * 2. **Timelock elapsed** — the post-vote delay has passed, so holders had + * a window to exit before the new pricing goes live. + * + * Everything here is pure so the panel, the execute mutation, and the tests + * all agree on when an execute is allowed. + */ + +import { + getEligibleVotingWeight, + getParticipationPercentage, + isQuorumMet, +} from '@/utils/governance.utils'; +import { formatCountdownTime } from '@/utils/lockupCountdown.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { formatNumber } from '@/utils/numberFormat.utils'; +import type { + CurveMigration, + CurveMigrationParams, + CurveMigrationStatus, +} from '@/types/curveMigration'; + +/** Sub-second values are treated as epoch seconds rather than milliseconds. */ +const EPOCH_SECONDS_CEILING = 1e11; + +/** Contract function invoked to apply an approved, unlocked migration. */ +export const EXECUTE_CURVE_MIGRATION_FUNCTION = 'execute_curve_migration'; + +/** Vote fields shared with a governance proposal, used for quorum math. */ +export type CurveMigrationVoteFields = Pick< + CurveMigration, + | 'forVotes' + | 'againstVotes' + | 'quorumBps' + | 'eligibleVotingWeight' + | 'totalCirculatingSupply' + | 'totalVotingWeight' +>; + +/** + * Normalizes an ISO string, epoch milliseconds, or epoch seconds into + * milliseconds. Returns `null` for missing or unparseable values. + */ +export function toTimestampMs( + value: string | number | null | undefined +): number | null { + if (value == null) return null; + + if (typeof value === 'number') { + if (!Number.isFinite(value)) return null; + return value > 0 && value < EPOCH_SECONDS_CEILING ? value * 1000 : value; + } + + const parsed = new Date(value).getTime(); + return Number.isNaN(parsed) ? null : parsed; +} + +/** True while a migration is still awaiting vote approval or timelock. */ +export function isPendingCurveMigration( + migration: Pick +): boolean { + return migration.status === 'pending'; +} + +/** + * Milliseconds left on the timelock, floored at zero. Returns 0 when the + * migration reports no timelock so the panel never renders `NaN`. + */ +export function getCurveMigrationTimelockRemainingMs( + migration: Pick, + now: number = Date.now() +): number { + const endsAt = toTimestampMs(migration.timelockEndsAt); + if (endsAt == null) return 0; + return Math.max(0, endsAt - now); +} + +/** True once the timelock delay has fully elapsed. */ +export function isCurveMigrationTimelockElapsed( + migration: Pick, + now: number = Date.now() +): boolean { + return getCurveMigrationTimelockRemainingMs(migration, now) === 0; +} + +/** + * Share of cast weight that voted `for`, as a percentage (0–100). + * + * Returns 0 when no weight has been cast, so a fresh proposal reads as 0% + * rather than dividing by zero. + */ +export function getCurveMigrationApprovalPercent( + migration: Pick +): number { + const forVotes = toWeight(migration.forVotes); + const againstVotes = toWeight(migration.againstVotes); + const total = forVotes + againstVotes; + if (total <= 0) return 0; + return (forVotes / total) * 100; +} + +/** Participation as a percentage of the weight eligible to vote. */ +export function getCurveMigrationParticipationPercent( + migration: CurveMigrationVoteFields +): number { + return getParticipationPercentage( + toWeight(migration.totalVotingWeight), + getEligibleVotingWeight(migration) + ); +} + +/** + * True when the vote carried the migration: quorum reached and strictly more + * weight in favour than against. + */ +export function isCurveMigrationVoteApproved( + migration: CurveMigrationVoteFields +): boolean { + const quorumMet = isQuorumMet( + toWeight(migration.totalVotingWeight), + getEligibleVotingWeight(migration), + migration.quorumBps + ); + if (!quorumMet) return false; + return toWeight(migration.forVotes) > toWeight(migration.againstVotes); +} + +/** + * Whether the creator may execute the migration right now: the migration is + * still pending, the timelock has elapsed, and the vote carried it. + */ +export function canExecuteCurveMigration( + migration: CurveMigration, + now: number = Date.now() +): boolean { + return ( + isPendingCurveMigration(migration) && + isCurveMigrationTimelockElapsed(migration, now) && + isCurveMigrationVoteApproved(migration) + ); +} + +/** + * Why the Execute button is disabled, or `null` when it can be submitted. + * + * Ordered so the creator sees the first blocker they still need to clear. + */ +export function getCurveMigrationExecuteDisabledReason( + migration: CurveMigration, + now: number = Date.now() +): string | null { + if (!isPendingCurveMigration(migration)) { + return 'This migration is no longer pending.'; + } + if (!isCurveMigrationTimelockElapsed(migration, now)) { + return 'Waiting for the timelock to elapse before this migration can be executed.'; + } + if (!isCurveMigrationVoteApproved(migration)) { + return 'The vote has not reached quorum with enough support to execute this migration.'; + } + return null; +} + +/** Formats a timelock countdown as `HH:MM:SS`, or `Ready` once elapsed. */ +export function formatCurveMigrationCountdown(remainingMs: number): string { + if (!Number.isFinite(remainingMs) || remainingMs <= 0) return 'Ready'; + return formatCountdownTime(Math.floor(remainingMs / 1000)); +} + +/** + * Formats a migration timestamp for display, e.g. `12 Mar 2027`. + * Returns `—` for missing or unparseable values. + */ +export function formatCurveMigrationDate( + value: string | number | null | undefined +): string { + const ms = toTimestampMs(value); + if (ms == null) return '—'; + + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: '2-digit', + timeZone: 'UTC', + }).format(new Date(ms)); +} + +/** The curve parameter a change refers to. */ +export type CurveParamField = 'basePriceStroops' | 'growthFactor' | 'milestones'; + +export interface CurveParamChange { + field: CurveParamField; + label: string; + /** Formatted value currently live on-chain. */ + from: string; + /** Formatted value the migration would apply. */ + to: string; + /** Whether the migration actually changes this parameter. */ + changed: boolean; +} + +const CURVE_PARAM_LABELS: Record = { + basePriceStroops: 'Base price', + growthFactor: 'Growth factor', + milestones: 'Graduated curve', +}; + +function formatGrowthFactor(value: number | null | undefined): string { + if (value == null || !Number.isFinite(value)) return '—'; + return `${formatNumber(value, { + minimumFractionDigits: 0, + maximumFractionDigits: 4, + })}×`; +} + +function formatMilestones( + milestones: CurveMigrationParams['milestones'] | null | undefined +): string { + if (!Array.isArray(milestones) || milestones.length === 0) return 'None'; + return `${milestones.length} tier${milestones.length === 1 ? '' : 's'}`; +} + +function milestonesEqual( + a: CurveMigrationParams['milestones'] | null | undefined, + b: CurveMigrationParams['milestones'] | null | undefined +): boolean { + const left = Array.isArray(a) ? a : []; + const right = Array.isArray(b) ? b : []; + if (left.length !== right.length) return false; + return left.every( + (milestone, index) => + Number(milestone?.supply) === Number(right[index]?.supply) && + Number(milestone?.exponent) === Number(right[index]?.exponent) + ); +} + +/** + * Per-parameter diff between the live curve and the proposed one, in a fixed + * display order. Used by both the pending proposal card and the executed + * history rows. + */ +export function getCurveParamChanges( + current: CurveMigrationParams | null | undefined, + proposed: CurveMigrationParams | null | undefined +): CurveParamChange[] { + const currentBase = current?.basePriceStroops; + const proposedBase = proposed?.basePriceStroops; + const currentGrowth = current?.growthFactor; + const proposedGrowth = proposed?.growthFactor; + + return [ + { + field: 'basePriceStroops', + label: CURVE_PARAM_LABELS.basePriceStroops, + from: formatDisplayKeyPrice(currentBase ?? null), + to: formatDisplayKeyPrice(proposedBase ?? null), + changed: + Number.isFinite(currentBase) && + Number.isFinite(proposedBase) && + currentBase !== proposedBase, + }, + { + field: 'growthFactor', + label: CURVE_PARAM_LABELS.growthFactor, + from: formatGrowthFactor(currentGrowth), + to: formatGrowthFactor(proposedGrowth), + changed: + Number.isFinite(currentGrowth) && + Number.isFinite(proposedGrowth) && + currentGrowth !== proposedGrowth, + }, + { + field: 'milestones', + label: CURVE_PARAM_LABELS.milestones, + from: formatMilestones(current?.milestones), + to: formatMilestones(proposed?.milestones), + changed: !milestonesEqual(current?.milestones, proposed?.milestones), + }, + ]; +} + +export interface PartitionedCurveMigrations { + /** Migrations still awaiting execution, soonest timelock first. */ + pending: CurveMigration[]; + /** Executed migrations, most recently executed first. */ + executed: CurveMigration[]; +} + +/** + * Splits migrations into the actionable pending list and the executed history. + * Terminal-but-unexecuted records (rejected / cancelled / expired) are dropped + * from both lists so the panel only ever shows work that can move forward. + */ +export function partitionCurveMigrations( + migrations: CurveMigration[] | null | undefined +): PartitionedCurveMigrations { + const list = Array.isArray(migrations) ? migrations : []; + + const pending = list + .filter(isPendingCurveMigration) + .sort( + (a, b) => + (toTimestampMs(a.timelockEndsAt) ?? 0) - + (toTimestampMs(b.timelockEndsAt) ?? 0) + ); + const executed = list + .filter(migration => migration.status === 'executed') + .sort( + (a, b) => + (toTimestampMs(b.executedAt) ?? 0) - (toTimestampMs(a.executedAt) ?? 0) + ); + + return { pending, executed }; +} + +/** The contract call submitted to apply an approved migration. */ +export interface CurveMigrationExecuteCall { + functionName: string; + args: { + creatorId: string; + migrationId: string; + }; +} + +/** + * Builds the `execute_curve_migration` call for a migration. Kept pure so the + * exact contract call can be asserted in tests without signing anything. + */ +export function buildCurveMigrationExecuteCall(input: { + creatorId: string; + migrationId: string; +}): CurveMigrationExecuteCall { + return { + functionName: EXECUTE_CURVE_MIGRATION_FUNCTION, + args: { + creatorId: input.creatorId, + migrationId: input.migrationId, + }, + }; +} + +/** Statuses that close a migration out without executing it. */ +export const TERMINAL_CURVE_MIGRATION_STATUSES: readonly CurveMigrationStatus[] = [ + 'executed', + 'rejected', + 'cancelled', + 'expired', +]; + +function toWeight(value: number | null | undefined): number { + if (value == null || !Number.isFinite(value)) return 0; + return Math.max(0, value); +}