diff --git a/apps/studio/public/assets/pump.svg b/apps/studio/public/assets/pump.svg new file mode 100644 index 0000000..7ed3238 --- /dev/null +++ b/apps/studio/public/assets/pump.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/apps/studio/public/assets/pumpswap.svg b/apps/studio/public/assets/pumpswap.svg new file mode 100644 index 0000000..fd42933 --- /dev/null +++ b/apps/studio/public/assets/pumpswap.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/apps/studio/src/app/scenarios/page.tsx b/apps/studio/src/app/scenarios/page.tsx index 4baef2f..bbc7d01 100644 --- a/apps/studio/src/app/scenarios/page.tsx +++ b/apps/studio/src/app/scenarios/page.tsx @@ -1,11 +1,11 @@ 'use client'; -import { Suspense, useEffect, useState } from 'react'; -import { logger } from '@surfpool/shared'; -import { useSearchParams } from 'next/navigation'; import ScenariosBento from '@/components/svm/scenarios-bento'; -import { Scenario } from '@/lib/scenarios-data'; import { useAppConfig } from '@/hooks/use-app-config'; +import { ApiScenario, Scenario, scenarioFromApiData } from '@/lib/scenarios-data'; +import { logger } from '@surfpool/shared'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useState } from 'react'; function ScenariosContent() { const searchParams = useSearchParams(); @@ -38,128 +38,38 @@ function ScenariosContent() { const data = await response.json(); logger.log('Loaded scenarios from API:', data); - // Convert API response to scenarios array - // Handle both array response and object response - let loadedScenarios: Scenario[]; - - if (Array.isArray(data)) { - // API returned an array of scenarios - loadedScenarios = data.map((scenarioData: any) => { - const scenario: Scenario = { - id: scenarioData.id, // Use the ID from the scenario object itself - name: scenarioData.name || `Scenario ${scenarioData.id}`, - description: scenarioData.description, - status: scenarioData.status || 'active', - created_at: scenarioData.created_at, - updated_at: scenarioData.updated_at, - tags: scenarioData.tags, - }; - - // Convert overrides to steps/slots for UI - if (scenarioData.overrides && scenarioData.overrides.length > 0) { - // Group overrides by scenarioRelativeSlot - const slotMap = new Map(); - - scenarioData.overrides.forEach((override: any) => { - const slotNumber = override.scenarioRelativeSlot !== undefined ? override.scenarioRelativeSlot : 0; - if (!slotMap.has(slotNumber)) { - slotMap.set(slotNumber, []); - } - - // Extract protocol from templateId (everything before first dash is usually the protocol) - const templateId = override.templateId || ''; - const firstDashIndex = templateId.indexOf('-'); - const protocolId = firstDashIndex > 0 ? templateId.substring(0, firstDashIndex) : templateId; - - slotMap.get(slotNumber)!.push({ - original: override, - overrideId: override.id, // Preserve the override ID from backend - protocolId: protocolId || 'unknown', - actionId: templateId || 'unknown', // Use full templateId as actionId - protocol: protocolId.charAt(0).toUpperCase() + protocolId.slice(1), // Capitalize protocol name - action: override.label || 'Unknown Action', - account: override.account, // Preserve account data - fetchBeforeUse: override.fetchBeforeUse || false, - overrides: override.values || {}, // Preserve the values from backend - modifiedFields: Object.keys(override.values || {}), // Track which fields were modified - }); - }); - - // Convert map to array of steps - scenario.steps = Array.from(slotMap.entries()) - .sort(([a], [b]) => a - b) - .map(([slotNumber, actions]) => ({ - id: `slot-${slotNumber}`, - name: `Slot ${slotNumber}`, - type: 'slot', - status: 'pending', - slotNumber, - actions: actions, - })); - } - - return scenario; - }); - } else { - // API returned an object with scenario IDs as keys - loadedScenarios = Object.entries(data).map(([id, scenarioData]: [string, any]) => { - const scenario: Scenario = { - id: scenarioData.id || id, // Prefer scenario.id, fallback to key - name: scenarioData.name || `Scenario ${id}`, - description: scenarioData.description, - status: scenarioData.status || 'active', - created_at: scenarioData.created_at, - updated_at: scenarioData.updated_at, - tags: scenarioData.tags, - }; - - // Convert overrides to steps/slots for UI - if (scenarioData.overrides && scenarioData.overrides.length > 0) { - // Group overrides by scenarioRelativeSlot - const slotMap = new Map(); - - scenarioData.overrides.forEach((override: any) => { - const slotNumber = override.scenarioRelativeSlot !== undefined ? override.scenarioRelativeSlot : 0; - if (!slotMap.has(slotNumber)) { - slotMap.set(slotNumber, []); - } - - // Extract protocol from templateId (everything before first dash is usually the protocol) - const templateId = override.templateId || ''; - const firstDashIndex = templateId.indexOf('-'); - const protocolId = firstDashIndex > 0 ? templateId.substring(0, firstDashIndex) : templateId; - - slotMap.get(slotNumber)!.push({ - original: override, - overrideId: override.id, // Preserve the override ID from backend - protocolId: protocolId || 'unknown', - actionId: templateId || 'unknown', // Use full templateId as actionId - protocol: protocolId.charAt(0).toUpperCase() + protocolId.slice(1), // Capitalize protocol name - action: override.label || 'Unknown Action', - account: override.account, // Preserve account data - fetchBeforeUse: override.fetchBeforeUse || false, - overrides: override.values || {}, // Preserve the values from backend - modifiedFields: Object.keys(override.values || {}), // Track which fields were modified - }); - }); - - // Convert map to array of steps - scenario.steps = Array.from(slotMap.entries()) - .sort(([a], [b]) => a - b) - .map(([slotNumber, actions]) => ({ - id: `slot-${slotNumber}`, - name: `Slot ${slotNumber}`, - type: 'slot', - status: 'pending', - slotNumber, - actions: actions, - })); + // The templateId prefix heuristic below misfires on multi-dash protocols + // (pump-amm-* belongs to PumpSwap, not "pump"), so resolve the protocol + // from the templates list whenever it is reachable. + const templateProtocols = new Map(); + try { + const templatesResponse = await fetch(`${studioUrl}/v1/scenarios/templates`); + if (templatesResponse.ok) { + const templates: Array<{ id: string; protocol?: string }> = await templatesResponse.json(); + for (const template of templates) { + templateProtocols.set(template.id, template.protocol || ''); } - - return scenario; - }); + } else { + logger.warn( + 'Templates request failed, protocol names fall back to templateId prefix:', + templatesResponse.status + ); + } + } catch (error) { + logger.warn('Templates request failed, protocol names fall back to templateId prefix:', error); } + // Convert API response to scenarios array. The API returns either an array + // of scenarios or an object keyed by scenario id; both shapes convert the + // same way via scenarioFromApiData. + const loadedScenarios: Scenario[] = Array.isArray(data) + ? data.map((scenarioData: ApiScenario) => + scenarioFromApiData(scenarioData, scenarioData.id ?? '', templateProtocols) + ) + : Object.entries(data as Record).map(([id, scenarioData]) => + scenarioFromApiData(scenarioData, id, templateProtocols) + ); + setScenarios(loadedScenarios); } catch (error) { console.error('Error loading scenarios:', error); @@ -213,11 +123,13 @@ function ScenariosContent() { export default function Scenarios() { return ( - -
Loading scenarios...
- - }> + +
Loading scenarios...
+ + } + >
); diff --git a/apps/studio/src/components/svm/ai-header.test.tsx b/apps/studio/src/components/svm/ai-header.test.tsx index fb2b600..32e82e5 100644 --- a/apps/studio/src/components/svm/ai-header.test.tsx +++ b/apps/studio/src/components/svm/ai-header.test.tsx @@ -1,5 +1,5 @@ import { renderWithConfig } from '@/test-utils'; -import { screen } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import AIHeader from './ai-header'; @@ -84,6 +84,45 @@ describe('AIHeader', () => { expect(screen.getByText('DEX Arbitrage')).toBeInTheDocument(); expect(screen.getByText('Liquidation Arbitrage')).toBeInTheDocument(); expect(screen.getByText('Triangular Arbitrage')).toBeInTheDocument(); + expect(screen.getByText('Fresh Launch')).toBeInTheDocument(); + expect(screen.getByText('Pump Graduation')).toBeInTheDocument(); + expect(screen.getByText('PumpSwap Pool')).toBeInTheDocument(); + expect(screen.getByText('PumpSwap Price Shock')).toBeInTheDocument(); + }); + + it('renders example scenarios in a two-row scroller without a native scrollbar', () => { + renderWithConfig(); + + const scroller = screen.getByLabelText('Example scenarios'); + expect(scroller).toHaveClass('overflow-x-auto', '[scrollbar-width:none]', '[&::-webkit-scrollbar]:hidden'); + expect(screen.getAllByRole('group', { name: /Example scenarios row/ })).toHaveLength(2); + expect(screen.getByRole('button', { name: 'Scroll example scenarios left' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Scroll example scenarios right' })).toBeInTheDocument(); + }); + + it('loads the specialized Pump graduation prompt', () => { + renderWithConfig(); + + fireEvent.click(screen.getByText('Pump Graduation')); + + expect((screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value).toContain( + 'specialized Pump graduation tool' + ); + expect((screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value).toContain( + '' + ); + }); + + it('loads an editable PumpSwap price shock prompt for a custom mint', () => { + renderWithConfig(); + + fireEvent.click(screen.getByText('PumpSwap Price Shock')); + + const prompt = (screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value; + expect(prompt).toContain(''); + expect(prompt).toContain('pump-amm-canonical-pool'); + expect(prompt).toContain('create_scenario'); + expect(prompt).toContain('do not build or execute a swap'); }); it('renders the model selector button', () => { diff --git a/apps/studio/src/components/svm/ai-header.tsx b/apps/studio/src/components/svm/ai-header.tsx index dce9b4d..6b2306b 100644 --- a/apps/studio/src/components/svm/ai-header.tsx +++ b/apps/studio/src/components/svm/ai-header.tsx @@ -16,16 +16,65 @@ import { import { PROTOCOLS } from '@/lib/protocol-icons'; import { buildAiPrompt } from '@/lib/scenarios-api'; import * as Headless from '@headlessui/react'; -import { ArrowRightIcon, DocumentTextIcon, StopIcon } from '@heroicons/react/24/solid'; +import { + ArrowRightIcon, + ChevronLeftIcon, + ChevronRightIcon, + DocumentTextIcon, + StopIcon, +} from '@heroicons/react/24/solid'; import { Button, Dialog, DialogActions, DialogBody, DialogTitle, Switch } from '@surfpool/ui'; import React, { useState } from 'react'; -import { exampleScenarios, type GenerationLog } from './scenarios-bento.types'; +import { exampleScenarios, type ExampleScenario, type GenerationLog } from './scenarios-bento.types'; interface AIHeaderProps { onRefresh?: () => void; onScenarioNavigate?: (scenarioId: string) => void; } +interface ExampleScenarioChipProps { + disabled: boolean; + example: ExampleScenario; + onSelect: (example: ExampleScenario) => void; +} + +interface ExampleScenarioRowProps { + disabled: boolean; + examples: ExampleScenario[]; + label: string; + onSelect: (example: ExampleScenario) => void; +} + +function ExampleScenarioChip({ disabled, example, onSelect }: ExampleScenarioChipProps) { + const handleSelect = () => { + onSelect(example); + }; + + return ( + + ); +} + +function ExampleScenarioRow({ disabled, examples, label, onSelect }: ExampleScenarioRowProps) { + function renderExampleScenario(example: ExampleScenario) { + return ; + } + + return ( +
+ {examples.map(renderExampleScenario)} +
+ ); +} + export default function AIHeader({ onRefresh, onScenarioNavigate }: AIHeaderProps) { const { mcpUrl } = useAppConfig(); @@ -55,9 +104,12 @@ export default function AIHeader({ onRefresh, onScenarioNavigate }: AIHeaderProp return stored ? JSON.parse(stored) : null; }); const [ollamaStatus, setOllamaStatus] = useState({ available: false, models: [] }); + const [canScrollExamplesLeft, setCanScrollExamplesLeft] = useState(false); + const [canScrollExamplesRight, setCanScrollExamplesRight] = useState(false); const abortControllerRef = React.useRef(null); const currentPromptRef = React.useRef(''); + const exampleScrollerRef = React.useRef(null); const inputRef = React.useRef(null); const responseRef = React.useRef(null); @@ -78,6 +130,29 @@ export default function AIHeader({ onRefresh, onScenarioNavigate }: AIHeaderProp localStorage.setItem('surfpool:last-model', selectedModelId); }, [selectedModelId]); + React.useEffect(() => { + const scroller = exampleScrollerRef.current; + if (!scroller) return; + + function updateScrollControls() { + const currentScroller = exampleScrollerRef.current; + if (!currentScroller) return; + + const maxScrollLeft = currentScroller.scrollWidth - currentScroller.clientWidth; + setCanScrollExamplesLeft(currentScroller.scrollLeft > 1); + setCanScrollExamplesRight(currentScroller.scrollLeft < maxScrollLeft - 1); + } + + scroller.addEventListener('scroll', updateScrollControls, { passive: true }); + window.addEventListener('resize', updateScrollControls); + updateScrollControls(); + + return () => { + scroller.removeEventListener('scroll', updateScrollControls); + window.removeEventListener('resize', updateScrollControls); + }; + }, []); + // Find selected model const selectedModel = getModelById(selectedModelId) || @@ -100,6 +175,9 @@ export default function AIHeader({ onRefresh, onScenarioNavigate }: AIHeaderProp ]; const visibleProtocols = orderedProtocols.slice(0, PROTOCOL_ICON_LIMIT); const hiddenProtocolCount = orderedProtocols.length - visibleProtocols.length; + const exampleRowLength = Math.ceil(exampleScenarios.length / 2); + const firstExampleRow = exampleScenarios.slice(0, exampleRowLength); + const secondExampleRow = exampleScenarios.slice(exampleRowLength); const hasApiKey = (provider: AIProvider) => { const providerConfig = getProviderById(provider); @@ -220,12 +298,42 @@ export default function AIHeader({ onRefresh, onScenarioNavigate }: AIHeaderProp setIsAiProcessing(false); }; + const handleExampleScenarioSelect = (example: ExampleScenario) => { + setAiPrompt(example.prompt); + setSelectedProtocols(new Set(example.protocols)); + setTimeout(() => { + if (!inputRef.current) return; + + inputRef.current.style.height = 'auto'; + inputRef.current.style.height = `${Math.min(inputRef.current.scrollHeight, 200)}px`; + inputRef.current.focus(); + }, 0); + }; + + const scrollExampleScenarios = (direction: -1 | 1) => { + const scroller = exampleScrollerRef.current; + if (!scroller) return; + + scroller.scrollBy({ + behavior: 'smooth', + left: direction * Math.max(scroller.clientWidth * 0.7, 280), + }); + }; + + const handleScrollExamplesLeft = () => { + scrollExampleScenarios(-1); + }; + + const handleScrollExamplesRight = () => { + scrollExampleScenarios(1); + }; + return ( <> -
+
{/* v0-style input box */} -
+
{/* Subtle top glow */}
@@ -680,29 +788,47 @@ export default function AIHeader({ onRefresh, onScenarioNavigate }: AIHeaderProp )}
- {/* Example scenario buttons */} -
- {exampleScenarios.map((example) => ( - - ))} +
+ + +
+
+ + +
+
+ +
diff --git a/apps/studio/src/components/svm/generic-bento.test.tsx b/apps/studio/src/components/svm/generic-bento.test.tsx new file mode 100644 index 0000000..f756a27 --- /dev/null +++ b/apps/studio/src/components/svm/generic-bento.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { describe, expect, it } from 'vitest'; +import GenericBento, { type BentoItem } from './generic-bento'; + +const existingItem: BentoItem = { + id: 'existing', + name: 'Existing scenario', + description: 'Existing scenario description', +}; + +const createdItem: BentoItem = { + id: 'created', + name: 'Created scenario', + description: 'Created scenario description', +}; + +function renderItem(item: BentoItem): ReactNode { + return
{item.name}
; +} + +function renderDetailHeader(item: BentoItem): ReactNode { + return
Header for {item.id}
; +} + +function renderDetailContent(item: BentoItem): ReactNode { + return
Details for {item.id}
; +} + +function renderBento(items: BentoItem[], initialSelectedId?: string) { + return ( + + ); +} + +describe('GenericBento deep links', () => { + it('selects an existing item when its id arrives after the initial render', () => { + const { rerender } = render(renderBento([createdItem])); + + rerender(renderBento([createdItem], createdItem.id)); + + expect(screen.getByText(`Details for ${createdItem.id}`)).toBeInTheDocument(); + }); + + it('waits for a deep-linked item that is not loaded yet', () => { + const { rerender } = render(renderBento([existingItem], createdItem.id)); + + expect(screen.queryByText(`Details for ${createdItem.id}`)).not.toBeInTheDocument(); + + rerender(renderBento([createdItem], createdItem.id)); + + expect(screen.getByText(`Details for ${createdItem.id}`)).toBeInTheDocument(); + }); +}); diff --git a/apps/studio/src/components/svm/generic-bento.tsx b/apps/studio/src/components/svm/generic-bento.tsx index 0d0faee..6cd757f 100644 --- a/apps/studio/src/components/svm/generic-bento.tsx +++ b/apps/studio/src/components/svm/generic-bento.tsx @@ -85,11 +85,7 @@ export default function GenericBento({ // Reset initialization when initialSelectedId changes to a different non-undefined value useEffect(() => { - // Only reset if changing from one ID to another, not from undefined to an ID - const shouldReset = - initialSelectedId !== lastInitialSelectedId && - lastInitialSelectedId !== undefined && - initialSelectedId !== undefined; + const shouldReset = initialSelectedId !== undefined && initialSelectedId !== lastInitialSelectedId; if (shouldReset) { logger.log('GenericBento: initialSelectedId changed to different ID', lastInitialSelectedId, '->', initialSelectedId, 'tab:', initialTab); @@ -108,10 +104,6 @@ export default function GenericBento({ setIsExpanded(false); setLastInitialTab(undefined); } - } else if (initialSelectedId && !lastInitialSelectedId) { - // First time getting an initialSelectedId, just track it - logger.log('GenericBento: Tracking initial selectedId:', initialSelectedId); - setLastInitialSelectedId(initialSelectedId); } }, [initialSelectedId, lastInitialSelectedId, initialTab, defaultTab]); @@ -139,9 +131,7 @@ export default function GenericBento({ setSelectedItemId(initialSelectedId); setHasInitialized(true); } else { - // Item not found - maybe it doesn't exist console.warn('Deep linking: item not found', initialSelectedId, 'available IDs:', items.map(i => i.id)); - setHasInitialized(true); } } else { logger.log('Deep linking: waiting for items to load, currently', items.length); diff --git a/apps/studio/src/components/svm/pump-graduation-dialog.test.tsx b/apps/studio/src/components/svm/pump-graduation-dialog.test.tsx new file mode 100644 index 0000000..f138975 --- /dev/null +++ b/apps/studio/src/components/svm/pump-graduation-dialog.test.tsx @@ -0,0 +1,55 @@ +import { createPumpGraduationScenario } from '@/lib/scenarios-api'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import PumpGraduationDialog from './pump-graduation-dialog'; + +vi.mock('@/lib/scenarios-api', () => ({ + createPumpGraduationScenario: vi.fn(), +})); + +vi.mock('@surfpool/ui', () => ({ + Button: ({ children, ...props }: any) => , + Dialog: ({ children, open }: any) => (open ?
{children}
: null), + DialogActions: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>

{children}

, + DialogTitle: ({ children }: any) =>

{children}

, + Input: (props: any) => , +})); + +const createScenarioMock = vi.mocked(createPumpGraduationScenario); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PumpGraduationDialog', () => { + it('creates a preset for a custom mint without an LLM', async () => { + const onCreated = vi.fn(); + createScenarioMock.mockResolvedValue({ + id: 'scenario-id', + tokenMint: 'CustomMintpump', + completingBuyAmount: 10, + migrationReserve: 20, + addresses: { bondingCurve: 'curve', curveVault: 'vault', canonicalPool: 'pool' }, + }); + render(); + + fireEvent.change(screen.getByLabelText('Pump token mint'), { target: { value: ' CustomMintpump ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create scenario' })); + + await waitFor(() => { + expect(createScenarioMock).toHaveBeenCalledWith('http://studio', 'CustomMintpump'); + expect(onCreated).toHaveBeenCalledWith('scenario-id'); + }); + }); + + it('shows backend validation errors', async () => { + createScenarioMock.mockRejectedValue(new Error('Bonding curve is already complete')); + render(); + + fireEvent.change(screen.getByLabelText('Pump token mint'), { target: { value: 'GraduatedMintpump' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create scenario' })); + + expect(await screen.findByText('Bonding curve is already complete')).toBeInTheDocument(); + }); +}); diff --git a/apps/studio/src/components/svm/pump-graduation-dialog.tsx b/apps/studio/src/components/svm/pump-graduation-dialog.tsx new file mode 100644 index 0000000..7ed32da --- /dev/null +++ b/apps/studio/src/components/svm/pump-graduation-dialog.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { createPumpGraduationScenario } from '@/lib/scenarios-api'; +import { Button, Dialog, DialogActions, DialogDescription, DialogTitle, Input } from '@surfpool/ui'; +import { type ChangeEvent, type FormEvent, useState } from 'react'; + +type PumpGraduationDialogProps = { + open: boolean; + studioUrl: string; + onClose: () => void; + onCreated: (scenarioId: string) => void; +}; + +export default function PumpGraduationDialog({ open, studioUrl, onClose, onCreated }: PumpGraduationDialogProps) { + // STATE + const [tokenMint, setTokenMint] = useState(''); + const [error, setError] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + // HANDLERS + const handleClose = () => { + if (isCreating) return; + setError(null); + onClose(); + }; + + const handleMintChange = (event: ChangeEvent) => { + setTokenMint(event.target.value); + setError(null); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + const mint = tokenMint.trim(); + if (!mint || isCreating) return; + + setIsCreating(true); + setError(null); + + try { + const result = await createPumpGraduationScenario(studioUrl, mint); + setTokenMint(''); + onCreated(result.id); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : 'Failed to create Pump graduation scenario'); + } finally { + setIsCreating(false); + } + }; + + return ( + +
+ Create Pump graduation scenario + + Enter a live Token-2022 Pump mint. Surfpool validates its curve and builds one editable preparation slot. + +
+ + {!!error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/apps/studio/src/components/svm/pump-swap-price-shock-dialog.test.tsx b/apps/studio/src/components/svm/pump-swap-price-shock-dialog.test.tsx new file mode 100644 index 0000000..4549f56 --- /dev/null +++ b/apps/studio/src/components/svm/pump-swap-price-shock-dialog.test.tsx @@ -0,0 +1,66 @@ +import { createPumpSwapPriceShockScenario } from '@/lib/scenarios-api'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import PumpSwapPriceShockDialog from './pump-swap-price-shock-dialog'; + +vi.mock('@/lib/scenarios-api', () => ({ + createPumpSwapPriceShockScenario: vi.fn(), +})); + +vi.mock('@surfpool/ui', () => ({ + Button: ({ children, ...props }: any) => , + Dialog: ({ children, open }: any) => (open ?
{children}
: null), + DialogActions: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>

{children}

, + DialogTitle: ({ children }: any) =>

{children}

, + Input: (props: any) => , +})); + +const createScenarioMock = vi.mocked(createPumpSwapPriceShockScenario); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PumpSwapPriceShockDialog', () => { + it('creates a declarative price shock scenario', async () => { + const onCreated = vi.fn(); + createScenarioMock.mockResolvedValue({ + id: 'scenario-id', + tokenMint: 'MigratedMintpump', + canonicalPool: 'pool', + virtualQuoteReserves: '15000000000000', + }); + render(); + + fireEvent.change(screen.getByLabelText('Token mint'), { target: { value: ' MigratedMintpump ' } }); + fireEvent.change(screen.getByLabelText('Virtual quote reserves'), { target: { value: '15000000000000' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create scenario' })); + + await waitFor(() => { + expect(createScenarioMock).toHaveBeenCalledWith('http://studio', ' MigratedMintpump ', '15000000000000'); + expect(onCreated).toHaveBeenCalledWith('scenario-id'); + }); + }); + + it('rejects a zero reserve amount before calling the backend', () => { + render(); + + fireEvent.change(screen.getByLabelText('Token mint'), { target: { value: 'MigratedMintpump' } }); + fireEvent.change(screen.getByLabelText('Virtual quote reserves'), { target: { value: '0' } }); + + expect(screen.getByRole('button', { name: 'Create scenario' })).toBeDisabled(); + expect(createScenarioMock).not.toHaveBeenCalled(); + }); + + it('shows backend validation errors', async () => { + createScenarioMock.mockRejectedValue(new Error('Canonical PumpSwap pool not found')); + render(); + + fireEvent.change(screen.getByLabelText('Token mint'), { target: { value: 'LegacyMintpump' } }); + fireEvent.change(screen.getByLabelText('Virtual quote reserves'), { target: { value: '1' } }); + fireEvent.click(screen.getByRole('button', { name: 'Create scenario' })); + + expect(await screen.findByText('Canonical PumpSwap pool not found')).toBeInTheDocument(); + }); +}); diff --git a/apps/studio/src/components/svm/pump-swap-price-shock-dialog.tsx b/apps/studio/src/components/svm/pump-swap-price-shock-dialog.tsx new file mode 100644 index 0000000..bdf0c53 --- /dev/null +++ b/apps/studio/src/components/svm/pump-swap-price-shock-dialog.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { createPumpSwapPriceShockScenario } from '@/lib/scenarios-api'; +import { Button, Dialog, DialogActions, DialogDescription, DialogTitle, Input } from '@surfpool/ui'; +import { type ChangeEvent, type FormEvent, useState } from 'react'; + +interface PumpSwapPriceShockDialogProps { + open: boolean; + studioUrl: string; + onClose: () => void; + onCreated: (scenarioId: string) => void; +} + +export default function PumpSwapPriceShockDialog({ + open, + studioUrl, + onClose, + onCreated, +}: PumpSwapPriceShockDialogProps) { + // STATE + const [tokenMint, setTokenMint] = useState(''); + const [virtualQuoteReserves, setVirtualQuoteReserves] = useState(''); + const [error, setError] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + // DERIVED STATE + const normalizedReserves = virtualQuoteReserves.trim(); + const hasValidReserves = /^[1-9]\d*$/.test(normalizedReserves); + const canCreate = !!tokenMint.trim() && hasValidReserves && !isCreating; + + // HANDLERS + const handleClose = () => { + if (isCreating) return; + setError(null); + onClose(); + }; + + const handleMintChange = (event: ChangeEvent) => { + setTokenMint(event.target.value); + setError(null); + }; + + const handleReservesChange = (event: ChangeEvent) => { + setVirtualQuoteReserves(event.target.value); + setError(null); + }; + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (!canCreate) return; + + setIsCreating(true); + setError(null); + + try { + const result = await createPumpSwapPriceShockScenario(studioUrl, tokenMint, normalizedReserves); + setTokenMint(''); + setVirtualQuoteReserves(''); + onCreated(result.id); + } catch (requestError) { + setError(requestError instanceof Error ? requestError.message : 'Failed to create PumpSwap price shock scenario'); + } finally { + setIsCreating(false); + } + }; + + return ( + +
+ Create PumpSwap price shock + + Enter a migrated pump.fun mint and a positive raw virtual quote reserve amount. Surfpool derives its + canonical WSOL pool from the existing PumpSwap template. + +
+
+ + +
+
+ + +

+ Raw WSOL units written to the pool's virtual quote reserves. +

+
+ {!!error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 00a0477..3ab4010 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -18,6 +18,7 @@ import { logger } from '@surfpool/shared'; import { Combobox, ComboboxLabel, ComboboxOption, Select, Switch } from '@surfpool/ui'; import { AnimatePresence, motion } from 'framer-motion'; import React, { useEffect, useRef, useState } from 'react'; +import { resolveTokenSelectorOptions } from './token-selector-options'; import TransactionInspector from './transaction-inspector'; interface Protocol { @@ -317,7 +318,7 @@ export default function ScenarioEditor({ const [protocolsLoading, setProtocolsLoading] = useState(true); // Protocols to show in the scenario editor (filter the full list) - const ENABLED_PROTOCOLS = ['Pyth', 'Raydium', 'Drift']; + const ENABLED_PROTOCOLS = ['Pyth', 'Raydium', 'Drift', 'Pump', 'PumpSwap']; useEffect(() => { const fetchProtocols = async () => { @@ -1562,18 +1563,7 @@ export default function ScenarioEditor({
{selectedProtocol.title} @@ -2066,16 +2056,10 @@ export default function ScenarioEditor({ currentValue: string | number | undefined; isModified: boolean; }) => { - // Convert currentValue to string for comparison (handles numbers like config_index) - const currentValueStr = currentValue != null ? String(currentValue) : ''; - - // Find the currently selected option (case-insensitive for hex values) - const selectedOption = - constantDef.options.find((opt: any) => - currentValueStr.startsWith('0x') - ? opt.value?.toLowerCase() === currentValueStr.toLowerCase() - : opt.value === currentValueStr - ) || null; + const { options, selectedOption } = resolveTokenSelectorOptions( + constantDef.options, + currentValue + ); return ( { if (!option) return ''; // Display symbol from metadata if available @@ -2097,7 +2081,13 @@ export default function ScenarioEditor({ const symbol = (option.metadata?.symbol || option.id || '').toLowerCase(); const label = (option.label || '').toLowerCase(); const description = (option.description || '').toLowerCase(); - return symbol.includes(q) || label.includes(q) || description.includes(q); + const value = (option.value || '').toLowerCase(); + return ( + symbol.includes(q) || + label.includes(q) || + description.includes(q) || + value.includes(q) + ); }} placeholder={`Search ${constantDef.label.toLowerCase()}...`} aria-label={constantDef.label} diff --git a/apps/studio/src/components/svm/scenario-presets.test.tsx b/apps/studio/src/components/svm/scenario-presets.test.tsx new file mode 100644 index 0000000..4449c83 --- /dev/null +++ b/apps/studio/src/components/svm/scenario-presets.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import ScenarioPresets from './scenario-presets'; + +describe('ScenarioPresets', () => { + it('opens the Pump graduation preset', () => { + const onPumpGraduationSelect = vi.fn(); + + render(); + fireEvent.click(screen.getByRole('button', { name: /Pump graduation/i })); + + expect(onPumpGraduationSelect).toHaveBeenCalledOnce(); + }); + + it('opens the PumpSwap price shock preset', () => { + const onPumpSwapPriceShockSelect = vi.fn(); + + render( + + ); + fireEvent.click(screen.getByRole('button', { name: /PumpSwap price shock/i })); + + expect(onPumpSwapPriceShockSelect).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/studio/src/components/svm/scenario-presets.tsx b/apps/studio/src/components/svm/scenario-presets.tsx new file mode 100644 index 0000000..6c60688 --- /dev/null +++ b/apps/studio/src/components/svm/scenario-presets.tsx @@ -0,0 +1,57 @@ +import { getProtocolIcon } from '@/lib/protocol-icons'; +import { ArrowRightIcon } from '@heroicons/react/24/solid'; +import Image from 'next/image'; + +interface ScenarioPresetsProps { + onPumpGraduationSelect: () => void; + onPumpSwapPriceShockSelect: () => void; +} + +export default function ScenarioPresets({ onPumpGraduationSelect, onPumpSwapPriceShockSelect }: ScenarioPresetsProps) { + return ( +
+
+

+ Scenario presets +

+

Prepare common protocol states without an AI prompt.

+
+ +
+ + + +
+
+ ); +} diff --git a/apps/studio/src/components/svm/scenarios-bento.tsx b/apps/studio/src/components/svm/scenarios-bento.tsx index 4b71672..2d32dde 100644 --- a/apps/studio/src/components/svm/scenarios-bento.tsx +++ b/apps/studio/src/components/svm/scenarios-bento.tsx @@ -5,15 +5,28 @@ import { buildUpdatePayload, createScenarioPayload, scenarioToBentoItem } from ' import type { Scenario } from '@/lib/scenarios-data'; import { PencilIcon, PlusIcon, SparklesIcon, TrashIcon } from '@heroicons/react/24/solid'; import { logger } from '@surfpool/shared'; -import { Button, Dialog, DialogActions, DialogDescription, DialogTitle } from '@surfpool/ui'; +import { + Button, + Dialog, + DialogActions, + DialogDescription, + DialogTitle, + Dropdown, + DropdownButton, + DropdownItem, + DropdownMenu, +} from '@surfpool/ui'; import dynamic from 'next/dynamic'; import { useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; import AIHeader from './ai-header'; import DraftField from './draft-field'; import GenericBento from './generic-bento'; +import PumpGraduationDialog from './pump-graduation-dialog'; +import PumpSwapPriceShockDialog from './pump-swap-price-shock-dialog'; import ScenarioCard from './scenario-card'; import ScenarioDetailOverview from './scenario-detail-overview'; +import ScenarioPresets from './scenario-presets'; import type { ScenarioBentoItem, ScenariosBentoProps } from './scenarios-bento.types'; const ScenarioEditor = dynamic(() => import('./scenario-editor').then((mod) => mod.default), { @@ -40,6 +53,8 @@ export default function ScenariosBento({ const [isDetailPaneOpen, setIsDetailPaneOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [scenarioToDelete, setScenarioToDelete] = useState<{ id: string; onClose?: () => void } | null>(null); + const [pumpGraduationDialogOpen, setPumpGraduationDialogOpen] = useState(false); + const [pumpSwapPriceShockDialogOpen, setPumpSwapPriceShockDialogOpen] = useState(false); // Sync scenarios when initialScenarios changes useEffect(() => { @@ -108,6 +123,38 @@ export default function ScenariosBento({ } }; + const handleOpenPumpGraduationDialog = () => { + setPumpGraduationDialogOpen(true); + }; + + const handleClosePumpGraduationDialog = () => { + setPumpGraduationDialogOpen(false); + }; + + const handlePumpGraduationCreated = (scenarioId: string) => { + setPumpGraduationDialogOpen(false); + onRefresh?.(); + router.push(`/scenarios?id=${scenarioId}&tab=editor`); + }; + + const handleScenarioNavigate = (scenarioId: string) => { + router.push(`/scenarios?id=${scenarioId}&tab=editor`); + }; + + const handleOpenPumpSwapPriceShockDialog = () => { + setPumpSwapPriceShockDialogOpen(true); + }; + + const handleClosePumpSwapPriceShockDialog = () => { + setPumpSwapPriceShockDialogOpen(false); + }; + + const handlePumpSwapPriceShockCreated = (scenarioId: string) => { + setPumpSwapPriceShockDialogOpen(false); + onRefresh?.(); + router.push(`/scenarios?id=${scenarioId}&tab=editor`); + }; + // Update scenario const handleUpdateScenario = async (id: string, updates: Partial) => { const scenario = scenarios.find((s) => s.id === id); @@ -245,10 +292,13 @@ export default function ScenariosBento({
{/* AI Header - always visible when detail pane is closed */} {!isDetailPaneOpen && ( - router.push(`/scenarios?id=${scenarioId}&tab=editor`)} - /> + <> + + + )}

No scenarios yet

- Use the AI prompt above to generate a scenario, or click the + button to create one manually. + Use AI, choose a preset, or click the + button to create one manually.

} @@ -283,13 +333,18 @@ export default function ScenariosBento({ {/* Add New Scenario Button */} {!isDetailPaneOpen && (
- + + + + + + New scenario + +
)} @@ -318,6 +373,19 @@ export default function ScenariosBento({ + + +
); } diff --git a/apps/studio/src/components/svm/scenarios-bento.types.ts b/apps/studio/src/components/svm/scenarios-bento.types.ts index 6670900..9ee9f60 100644 --- a/apps/studio/src/components/svm/scenarios-bento.types.ts +++ b/apps/studio/src/components/svm/scenarios-bento.types.ts @@ -62,4 +62,31 @@ export const exampleScenarios: ExampleScenario[] = [ icon: '🔄', protocols: ['pyth'], }, + { + label: 'Fresh Launch', + prompt: "Reset Fartcoin's bonding curve to its fresh launch state so it can be bought from the start again", + icon: '🚀', + protocols: ['pump'], + }, + { + label: 'Pump Graduation', + prompt: + 'Create an editable Pump Graduation scenario for token mint using the specialized Pump graduation tool. Call the tool exactly once with this tokenMint. If validation fails, report the error and do not retry without tokenMint. Prepare only the three state overrides; do not build buy, migrate, or sell transactions.', + icon: '🪙', + protocols: ['pump'], + }, + { + label: 'PumpSwap Pool', + prompt: + "Set The Official 67 Coin's canonical PumpSwap pool virtual_quote_reserves to 15000000000000 so buying it becomes far more expensive", + icon: '💧', + protocols: ['pumpswap'], + }, + { + label: 'PumpSwap Price Shock', + prompt: + 'Create an editable PumpSwap price shock for token mint . Fetch the existing override templates, then call create_scenario once with the pump-amm-canonical-pool template, base_mint set to that mint, virtual_quote_reserves set to 15000000000000, slot 1, and fetchBeforeUse enabled. Prepare state only; do not build or execute a swap.', + icon: '⚡', + protocols: ['pumpswap'], + }, ]; diff --git a/apps/studio/src/components/svm/token-selector-options.test.ts b/apps/studio/src/components/svm/token-selector-options.test.ts new file mode 100644 index 0000000..dfc3616 --- /dev/null +++ b/apps/studio/src/components/svm/token-selector-options.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { resolveTokenSelectorOptions, type TokenSelectorOption } from './token-selector-options'; + +const catalogOptions: TokenSelectorOption[] = [ + { id: 'catalog-token', label: 'Catalog token', value: 'CatalogMintpump' }, + { id: 'hex-value', label: 'Hex value', value: '0xAbCd' }, +]; + +describe('resolveTokenSelectorOptions', () => { + it('preserves a custom current value outside the catalog', () => { + const result = resolveTokenSelectorOptions(catalogOptions, 'CustomMintpump'); + const reselected = resolveTokenSelectorOptions(catalogOptions, result.selectedOption?.value); + + expect(result.selectedOption).toMatchObject({ + id: 'custom-CustomMintpump', + value: 'CustomMintpump', + }); + expect(result.options[0]).toBe(result.selectedOption); + expect(result.options.slice(1)).toEqual(catalogOptions); + expect(reselected.selectedOption).toEqual(result.selectedOption); + }); + + it('reuses the catalog option for a catalog value', () => { + const result = resolveTokenSelectorOptions(catalogOptions, 'CatalogMintpump'); + + expect(result.selectedOption).toBe(catalogOptions[0]); + expect(result.options).toBe(catalogOptions); + }); + + it('matches hex values case-insensitively', () => { + const result = resolveTokenSelectorOptions(catalogOptions, '0xabcd'); + + expect(result.selectedOption).toBe(catalogOptions[1]); + expect(result.options).toBe(catalogOptions); + }); +}); diff --git a/apps/studio/src/components/svm/token-selector-options.ts b/apps/studio/src/components/svm/token-selector-options.ts new file mode 100644 index 0000000..27ccbef --- /dev/null +++ b/apps/studio/src/components/svm/token-selector-options.ts @@ -0,0 +1,36 @@ +export type TokenSelectorOption = { + id: string; + label?: string; + value?: string | number; + metadata?: { + symbol?: string; + logo_uri?: string; + }; + description?: string; +}; + +export const resolveTokenSelectorOptions = ( + catalogOptions: TokenSelectorOption[], + currentValue: string | number | undefined +) => { + const currentValueString = currentValue != null ? String(currentValue) : ''; + const catalogOption = catalogOptions.find((option) => + currentValueString.startsWith('0x') + ? String(option.value).toLowerCase() === currentValueString.toLowerCase() + : option.value === currentValueString + ); + const customOption = + !catalogOption && currentValueString + ? { + id: `custom-${currentValueString}`, + label: 'Custom value', + value: currentValueString, + metadata: { symbol: `Custom · ${currentValueString}` }, + } + : null; + + return { + options: customOption ? [customOption, ...catalogOptions] : catalogOptions, + selectedOption: catalogOption || customOption, + }; +}; diff --git a/apps/studio/src/lib/protocol-icons.test.ts b/apps/studio/src/lib/protocol-icons.test.ts index 75cbdec..1bbde06 100644 --- a/apps/studio/src/lib/protocol-icons.test.ts +++ b/apps/studio/src/lib/protocol-icons.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { PROTOCOLS, PROTOCOL_ICONS, getProtocolIcon } from './protocol-icons'; describe('PROTOCOLS', () => { @@ -36,7 +36,7 @@ describe('getProtocolIcon', () => { }); it('returns default fallback for unknown protocol', () => { - expect(getProtocolIcon('unknown')).toBe('/assets/default.svg'); + expect(getProtocolIcon('unknown')).toBe('/assets/surfpool.svg'); }); it('returns custom fallback for unknown protocol', () => { diff --git a/apps/studio/src/lib/protocol-icons.ts b/apps/studio/src/lib/protocol-icons.ts index 36e90fe..e90970a 100644 --- a/apps/studio/src/lib/protocol-icons.ts +++ b/apps/studio/src/lib/protocol-icons.ts @@ -14,13 +14,13 @@ export const PROTOCOLS: Protocol[] = [ { id: 'drift', name: 'Drift', icon: '/assets/drift.svg' }, { id: 'kamino', name: 'Kamino', icon: '/assets/kamino.svg' }, { id: 'meteora', name: 'Meteora', icon: '/assets/meteora.svg' }, + { id: 'pump', name: 'Pump', icon: '/assets/pump.svg' }, + { id: 'pumpswap', name: 'PumpSwap', icon: '/assets/pumpswap.svg' }, ]; // Shared protocol icon mappings (derived from PROTOCOLS for backwards compatibility) -export const PROTOCOL_ICONS: Record = Object.fromEntries( - PROTOCOLS.map(p => [p.id, p.icon]) -); +export const PROTOCOL_ICONS: Record = Object.fromEntries(PROTOCOLS.map((p) => [p.id, p.icon])); -export function getProtocolIcon(protocolId: string, fallback = '/assets/default.svg'): string { +export function getProtocolIcon(protocolId: string, fallback = '/assets/surfpool.svg'): string { return PROTOCOL_ICONS[protocolId] || fallback; } diff --git a/apps/studio/src/lib/scenarios-api.test.ts b/apps/studio/src/lib/scenarios-api.test.ts index ab154e5..d2c9410 100644 --- a/apps/studio/src/lib/scenarios-api.test.ts +++ b/apps/studio/src/lib/scenarios-api.test.ts @@ -1,13 +1,32 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { callMCPTool, fetchMCPTools } from './ai-client'; import { buildAiPrompt, buildUpdatePayload, + createPumpGraduationScenario, + createPumpSwapPriceShockScenario, createScenarioPayload, flattenOverrideValues, scenarioToBentoItem, } from './scenarios-api'; import type { Scenario } from './scenarios-data'; +vi.mock('./ai-client', () => ({ + callMCPTool: vi.fn(), + fetchMCPTools: vi.fn(), +})); + +const callMcpToolMock = vi.mocked(callMCPTool); +const fetchMcpToolsMock = vi.mocked(fetchMCPTools); +const fetchMock = vi.fn(); + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + const baseScenario: Scenario = { id: 'test-123', name: 'Test Scenario', @@ -34,6 +53,112 @@ const baseScenario: Scenario = { ], }; +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('createPumpGraduationScenario', () => { + it('calls the specialized MCP tool without an LLM', async () => { + fetchMcpToolsMock.mockResolvedValue({ tools: [], sessionId: 'session-id' }); + callMcpToolMock.mockResolvedValue({ + content: [ + { + type: 'text', + text: JSON.stringify({ error: null, url: 'http://studio/scenarios?id=scenario-id&tab=editor' }), + }, + ], + }); + + await expect(createPumpGraduationScenario('http://studio', ' mint ')).resolves.toEqual({ id: 'scenario-id' }); + expect(callMcpToolMock).toHaveBeenCalledWith( + 'http://studio', + 'create_pump_graduation_scenario', + { tokenMint: 'mint' }, + 'session-id' + ); + }); + + it('surfaces MCP validation failures', async () => { + fetchMcpToolsMock.mockResolvedValue({ tools: [], sessionId: 'session-id' }); + callMcpToolMock.mockResolvedValue({ + content: [{ type: 'text', text: JSON.stringify({ error: 'Bonding curve is already complete', url: null }) }], + }); + + await expect(createPumpGraduationScenario('http://studio', 'mint')).rejects.toThrow( + 'Bonding curve is already complete' + ); + }); +}); + +describe('createPumpSwapPriceShockScenario', () => { + it('creates a generic scenario from the existing PumpSwap template', async () => { + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(crypto, 'randomUUID') + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockReturnValueOnce('22222222-2222-4222-8222-222222222222'); + fetchMock + .mockResolvedValueOnce( + jsonResponse([{ id: 'pump-amm-canonical-pool', address: { pda: { programId: 'pump', seeds: [] } } }]) + ) + .mockResolvedValueOnce(jsonResponse({ id: '11111111-1111-4111-8111-111111111111' })); + + await expect(createPumpSwapPriceShockScenario('http://studio', ' mint ', ' 15000000000000 ')).resolves.toEqual( + { id: '11111111-1111-4111-8111-111111111111' } + ); + expect(fetchMock).toHaveBeenNthCalledWith(1, 'http://studio/v1/scenarios/templates'); + + const postRequest = fetchMock.mock.calls[1]; + expect(postRequest[0]).toBe('http://studio/v1/scenarios'); + const init = postRequest[1] as RequestInit; + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body as string)).toEqual({ + id: '11111111-1111-4111-8111-111111111111', + name: 'PumpSwap Price Shock', + description: 'Shift a canonical PumpSwap pool price through its virtual quote reserves.', + overrides: [ + { + id: '22222222-2222-4222-8222-222222222222', + templateId: 'pump-amm-canonical-pool', + values: { base_mint: 'mint', virtual_quote_reserves: 15000000000000 }, + scenarioRelativeSlot: 1, + label: 'PumpSwap virtual quote reserve shock', + enabled: true, + fetchBeforeUse: true, + account: { pda: { programId: 'pump', seeds: [] } }, + }, + ], + tags: ['pumpswap', 'price-shock'], + }); + }); + + it('preserves the full u64 value in the scenario JSON', async () => { + vi.stubGlobal('fetch', fetchMock); + fetchMock + .mockResolvedValueOnce( + jsonResponse([{ id: 'pump-amm-canonical-pool', address: { pda: { programId: 'pump', seeds: [] } } }]) + ) + .mockResolvedValueOnce(jsonResponse({ id: 'scenario-id' })); + + await createPumpSwapPriceShockScenario('http://studio', 'mint', '18446744073709551615'); + + const init = fetchMock.mock.calls[1][1] as RequestInit; + expect(init.body).toContain('"virtual_quote_reserves":18446744073709551615'); + }); + + it('surfaces generic scenario API failures', async () => { + vi.stubGlobal('fetch', fetchMock); + fetchMock + .mockResolvedValueOnce( + jsonResponse([{ id: 'pump-amm-canonical-pool', address: { pda: { programId: 'pump', seeds: [] } } }]) + ) + .mockResolvedValueOnce(new Response('Scenario store unavailable', { status: 503 })); + + await expect(createPumpSwapPriceShockScenario('http://studio', 'mint', '1')).rejects.toThrow('Scenario store unavailable'); + }); +}); + describe('createScenarioPayload', () => { it('returns correct shape with empty overrides and tags', () => { const result = createScenarioPayload(baseScenario); diff --git a/apps/studio/src/lib/scenarios-api.ts b/apps/studio/src/lib/scenarios-api.ts index c161dd2..bb16d48 100644 --- a/apps/studio/src/lib/scenarios-api.ts +++ b/apps/studio/src/lib/scenarios-api.ts @@ -1,7 +1,132 @@ import type { ScenarioBentoItem } from '@/components/svm/scenarios-bento.types'; +import { LosslessNumber, stringify } from 'lossless-json'; +import { callMCPTool, fetchMCPTools } from './ai-client'; import { PROTOCOLS } from './protocol-icons'; import type { Scenario } from './scenarios-data'; +export type PumpGraduationScenarioResult = { + id: string; + tokenMint?: string; + completingBuyAmount?: number; + migrationReserve?: number; + addresses?: { + bondingCurve: string; + curveVault: string; + canonicalPool: string; + }; +}; + +export async function createPumpGraduationScenario( + studioUrl: string, + tokenMint: string +): Promise { + return createPumpScenarioWithMcp(studioUrl, 'create_pump_graduation_scenario', { + tokenMint: tokenMint.trim(), + }); +} + +export type PumpSwapPriceShockScenarioResult = { + id: string; + tokenMint?: string; + canonicalPool?: string; + virtualQuoteReserves?: string; +}; + +type ScenarioTemplate = { + id: string; + address: unknown; +}; + +function findScenarioTemplate(templates: ScenarioTemplate[], templateId: string): ScenarioTemplate | undefined { + for (const template of templates) { + if (template.id === templateId) return template; + } + return undefined; +} + +export async function createPumpSwapPriceShockScenario( + studioUrl: string, + tokenMint: string, + virtualQuoteReserves: string +): Promise { + const templatesResponse = await fetch(`${studioUrl}/v1/scenarios/templates`); + if (!templatesResponse.ok) { + throw new Error(`Failed to load scenario templates: ${templatesResponse.status}`); + } + + const templates = (await templatesResponse.json()) as ScenarioTemplate[]; + const template = findScenarioTemplate(templates, 'pump-amm-canonical-pool'); + if (!template) throw new Error('PumpSwap canonical pool template is unavailable'); + + const scenarioId = crypto.randomUUID(); + const normalizedMint = tokenMint.trim(); + const normalizedReserves = virtualQuoteReserves.trim(); + const scenario = { + id: scenarioId, + name: 'PumpSwap Price Shock', + description: 'Shift a canonical PumpSwap pool price through its virtual quote reserves.', + overrides: [ + { + id: crypto.randomUUID(), + templateId: template.id, + values: { + base_mint: normalizedMint, + virtual_quote_reserves: new LosslessNumber(normalizedReserves), + }, + scenarioRelativeSlot: 1, + label: 'PumpSwap virtual quote reserve shock', + enabled: true, + fetchBeforeUse: true, + account: template.address, + }, + ], + tags: ['pumpswap', 'price-shock'], + }; + const body = stringify(scenario); + if (!body) throw new Error('Failed to serialize PumpSwap price shock scenario'); + + const response = await fetch(`${studioUrl}/v1/scenarios`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + }); + if (!response.ok) { + const message = await response.text(); + throw new Error(message || `Failed to create PumpSwap price shock scenario: ${response.status}`); + } + + const result = (await response.json()) as { id?: string }; + if (!result.id) throw new Error('Surfpool returned no scenario id'); + return { id: result.id }; +} + +async function createPumpScenarioWithMcp( + studioUrl: string, + toolName: string, + args: Record +): Promise { + const { sessionId } = await fetchMCPTools(studioUrl); + const result = (await callMCPTool(studioUrl, toolName, args, sessionId)) as { + content?: Array<{ type?: string; text?: string }>; + }; + let text: string | undefined; + for (const content of result.content ?? []) { + if (content.type === 'text' && content.text) { + text = content.text; + break; + } + } + if (!text) throw new Error(`Surfpool MCP tool ${toolName} returned no result`); + + const payload = JSON.parse(text) as { error?: string | null; url?: string | null }; + if (payload.error) throw new Error(payload.error); + if (!payload.url) throw new Error(`Surfpool MCP tool ${toolName} returned no scenario URL`); + + const scenarioId = new URL(payload.url).searchParams.get('id'); + if (!scenarioId) throw new Error(`Surfpool MCP tool ${toolName} returned an invalid scenario URL`); + return { id: scenarioId }; +} + /** * Build the POST body for creating a new scenario. */ diff --git a/apps/studio/src/lib/scenarios-data.test.ts b/apps/studio/src/lib/scenarios-data.test.ts new file mode 100644 index 0000000..1450bd2 --- /dev/null +++ b/apps/studio/src/lib/scenarios-data.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { resolveProtocol, scenarioFromApiData } from './scenarios-data'; + +const templates = new Map([ + ['pyth-price-feed-v2', 'Pyth'], + ['pump-bonding-curve-custom', 'Pump'], + ['pump-amm-canonical-pool', 'PumpSwap'], + ['kamino-obligation-health', 'kamino'], +]); + +describe('resolveProtocol', () => { + it('uses the authoritative protocol name for multi-dash templateIds', () => { + // The regression this guards: the prefix heuristic turns pump-amm-* into "pump". + expect(resolveProtocol('pump-amm-canonical-pool', templates)).toEqual({ + protocolId: 'pumpswap', + displayName: 'PumpSwap', + }); + }); + + it('preserves canonical casing from the templates list (kamino -> Kamino)', () => { + expect(resolveProtocol('kamino-obligation-health', templates)).toEqual({ + protocolId: 'kamino', + displayName: 'Kamino', + }); + }); + + it('falls back to the templateId prefix when the template is unknown', () => { + expect(resolveProtocol('raydium-clmm-custom', new Map())).toEqual({ + protocolId: 'raydium', + displayName: 'Raydium', + }); + }); +}); + +describe('scenarioFromApiData', () => { + const scenarioData = { + id: 's1', + name: 'demo', + overrides: [ + { id: 'o1', templateId: 'pump-amm-canonical-pool', scenarioRelativeSlot: 0, values: { lp_supply: 1 } }, + { id: 'o2', templateId: 'pyth-price-feed-v2', scenarioRelativeSlot: 750, values: {} }, + ], + }; + + it('groups overrides into slot steps and resolves each protocol', () => { + const s = scenarioFromApiData(scenarioData, 's1', templates); + expect(s.id).toBe('s1'); + expect(s.steps).toHaveLength(2); + // Sparse slots keep their real numbers (0 and 750), not dense positions. + expect(s.steps!.map((step) => step.slotNumber)).toEqual([0, 750]); + expect(s.steps![0].actions![0].protocol).toBe('PumpSwap'); + expect(s.steps![0].actions![0].overrideId).toBe('o1'); + }); + + it('falls back to the object key when the body omits id', () => { + const { id, ...noId } = scenarioData; + expect(scenarioFromApiData(noId, 'key-42', templates).id).toBe('key-42'); + }); + + it('handles a scenario with no overrides', () => { + const s = scenarioFromApiData({ id: 'empty' }, 'empty', templates); + expect(s.steps).toBeUndefined(); + }); +}); diff --git a/apps/studio/src/lib/scenarios-data.ts b/apps/studio/src/lib/scenarios-data.ts index 2aa3781..48abb38 100644 --- a/apps/studio/src/lib/scenarios-data.ts +++ b/apps/studio/src/lib/scenarios-data.ts @@ -34,3 +34,99 @@ export type Scenario = { tags?: string[]; metadata?: Record; }; + +// Raw shapes as they arrive from GET /v1/scenarios (fields are best-effort). +export type ApiOverride = { + id?: string; + templateId?: string; + scenarioRelativeSlot?: number; + label?: string; + account?: unknown; + fetchBeforeUse?: boolean; + values?: Record; +}; + +export type ApiScenario = { + id?: string; + name?: string; + description?: string; + status?: string; + created_at?: string; + updated_at?: string; + tags?: string[]; + overrides?: ApiOverride[]; +}; + +// The templateId prefix breaks on multi-dash protocols (pump-amm-* is PumpSwap, not +// "pump"), so the templates-list name wins; the prefix is only the fallback. +export function resolveProtocol( + templateId: string, + templateProtocols: Map +): { protocolId: string; displayName: string } { + const protocolName = templateProtocols.get(templateId) || ''; + const firstDashIndex = templateId.indexOf('-'); + const heuristicId = firstDashIndex > 0 ? templateId.substring(0, firstDashIndex) : templateId; + const protocolId = protocolName ? protocolName.toLowerCase().replace(/\s+/g, '-') : heuristicId; + const base = protocolName || protocolId; + return { + protocolId: protocolId || 'unknown', + displayName: base ? base.charAt(0).toUpperCase() + base.slice(1) : 'Unknown', + }; +} + +// `fallbackId` is the object key used when the API body omits its own id. +export function scenarioFromApiData( + scenarioData: ApiScenario, + fallbackId: string, + templateProtocols: Map +): Scenario { + const scenario: Scenario = { + id: scenarioData.id || fallbackId, + name: scenarioData.name || `Scenario ${fallbackId}`, + description: scenarioData.description, + status: scenarioData.status || 'active', + created_at: scenarioData.created_at, + updated_at: scenarioData.updated_at, + tags: scenarioData.tags, + }; + + if (scenarioData.overrides && scenarioData.overrides.length > 0) { + const slotMap = new Map(); + + scenarioData.overrides.forEach((override: ApiOverride) => { + const slotNumber = override.scenarioRelativeSlot !== undefined ? override.scenarioRelativeSlot : 0; + if (!slotMap.has(slotNumber)) { + slotMap.set(slotNumber, []); + } + + const templateId = override.templateId || ''; + const { protocolId, displayName } = resolveProtocol(templateId, templateProtocols); + + slotMap.get(slotNumber)!.push({ + original: override, + overrideId: override.id, + protocolId, + actionId: templateId || 'unknown', + protocol: displayName, + action: override.label || 'Unknown Action', + account: override.account, + fetchBeforeUse: override.fetchBeforeUse || false, + overrides: override.values || {}, + modifiedFields: Object.keys(override.values || {}), + }); + }); + + scenario.steps = Array.from(slotMap.entries()) + .sort(([a], [b]) => a - b) + .map(([slotNumber, actions]) => ({ + id: `slot-${slotNumber}`, + name: `Slot ${slotNumber}`, + type: 'slot', + status: 'pending', + slotNumber, + actions, + })); + } + + return scenario; +}