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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions apps/studio/public/assets/pump.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions apps/studio/public/assets/pumpswap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
168 changes: 40 additions & 128 deletions apps/studio/src/app/scenarios/page.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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<number, any[]>();

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<number, any[]>();

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<string, string>();
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<string, ApiScenario>).map(([id, scenarioData]) =>
scenarioFromApiData(scenarioData, id, templateProtocols)
);

setScenarios(loadedScenarios);
} catch (error) {
console.error('Error loading scenarios:', error);
Expand Down Expand Up @@ -213,11 +123,13 @@ function ScenariosContent() {

export default function Scenarios() {
return (
<Suspense fallback={
<div className="flex h-screen items-center justify-center">
<div className="text-lg text-zinc-600 dark:text-zinc-400">Loading scenarios...</div>
</div>
}>
<Suspense
fallback={
<div className="flex h-screen items-center justify-center">
<div className="text-lg text-zinc-600 dark:text-zinc-400">Loading scenarios...</div>
</div>
}
>
<ScenariosContent />
</Suspense>
);
Expand Down
40 changes: 39 additions & 1 deletion apps/studio/src/components/svm/ai-header.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -84,6 +84,44 @@ 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(<AIHeader />);

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(<AIHeader />);

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(
'<PASTE_TOKEN_MINT_HERE>'
);
});

it('loads an editable PumpSwap price shock prompt for a custom mint', () => {
renderWithConfig(<AIHeader />);

fireEvent.click(screen.getByText('PumpSwap Price Shock'));

const prompt = (screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value;
expect(prompt).toContain('<PASTE_TOKEN_MINT_HERE>');
expect(prompt).toContain('virtualQuoteReserves');
expect(prompt).toContain('do not build or execute a swap');
});

it('renders the model selector button', () => {
Expand Down
Loading