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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions apps/studio/public/assets/humidifi.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions apps/studio/src/components/svm/ai-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ describe('AIHeader', () => {
expect(screen.getByText('PumpSwap Price Shock')).toBeInTheDocument();
expect(screen.getByText('Phoenix Liquidation Cascade')).toBeInTheDocument();
expect(screen.getByText('Tessera Stale Quote')).toBeInTheDocument();
expect(screen.getByText('HumidiFi Stale Quote')).toBeInTheDocument();
expect(screen.getByText('HumidiFi Liquidity Stress')).toBeInTheDocument();
});

it('renders example scenarios in a two-row scroller without a native scrollbar', () => {
Expand Down Expand Up @@ -168,6 +170,28 @@ describe('AIHeader', () => {
expect(prompt).not.toContain('create_tessera_depth_scenario');
});

it('loads a HumidiFi staleness goal without coupling the chip to template ids or tool names', () => {
renderWithConfig(<AIHeader />);

fireEvent.click(screen.getByText('HumidiFi Stale Quote'));

const prompt = (screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value;
expect(prompt).toContain('rejection boundary');
expect(prompt).toContain('do not build or execute a swap');
expect(prompt).not.toMatch(/create_humidifi|humidifi-|fetchBeforeUse|templateId|max_staleness|\d/);
});

it('loads a HumidiFi liquidity goal without coupling the chip to template ids or tool names', () => {
renderWithConfig(<AIHeader />);

fireEvent.click(screen.getByText('HumidiFi Liquidity Stress'));

const prompt = (screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value;
expect(prompt).toContain('sliver of its base inventory');
expect(prompt).toContain('do not build or execute a swap');
expect(prompt).not.toMatch(/create_humidifi|humidifi-|spl-token|fetchBeforeUse|templateId|bps|\d/);
});

it('renders the model selector button', () => {
renderWithConfig(<AIHeader />);
expect(screen.getByLabelText('Select AI model')).toBeInTheDocument();
Expand Down
94 changes: 90 additions & 4 deletions apps/studio/src/components/svm/pmm-fair-value-dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
import { createTesseraFairValueScenario, fetchTesseraMarkets } from '@/lib/scenarios-api';
import {
createHumidifiFairValueScenario,
createTesseraFairValueScenario,
fetchHumidifiMarkets,
fetchTesseraMarkets,
} from '@/lib/scenarios-api';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import PmmFairValueDialog from './pmm-fair-value-dialog';

vi.mock('@/lib/scenarios-api', () => ({
createHumidifiFairValueScenario: vi.fn(),
createTesseraFairValueScenario: vi.fn(),
fetchHumidifiMarkets: vi.fn(),
fetchTesseraMarkets: vi.fn(),
}));

Expand All @@ -29,31 +36,87 @@ vi.mock('@surfpool/ui', () => ({

const createScenarioMock = vi.mocked(createTesseraFairValueScenario);
const fetchMarketsMock = vi.mocked(fetchTesseraMarkets);
const createHumidifiMock = vi.mocked(createHumidifiFairValueScenario);
const fetchHumidifiMarketsMock = vi.mocked(fetchHumidifiMarkets);

const markets = [
{ label: 'SOL/USDC', value: 'FLckHLGM' },
{ label: 'cbBTC/USDC', value: '9NkuAWB4' },
];

const humidifiMarkets = [{ label: 'JUP/USDC', value: 'hKgG7iED' }];

const renderDialog = (onCreated = vi.fn()) =>
render(<PmmFairValueDialog open studioUrl="http://studio" onClose={vi.fn()} onCreated={onCreated} />);

beforeEach(() => {
fetchMarketsMock.mockResolvedValue(markets);
fetchHumidifiMarketsMock.mockResolvedValue(humidifiMarkets);
});

afterEach(() => {
vi.clearAllMocks();
});

describe('PmmFairValueDialog', () => {
it('offers Tessera through the PMM protocol selector', () => {
it('offers Tessera and HumidiFi through the PMM protocol selector', async () => {
renderDialog();

await screen.findByLabelText('Price of SOL in USDC');
const protocolListbox = screen.getByLabelText('PMM protocol');
expect(protocolListbox).toHaveValue('tessera');
expect(within(protocolListbox).getAllByRole('option')).toHaveLength(1);
expect(within(protocolListbox).getByRole('option', { name: 'Tessera' })).toBeInTheDocument();
expect(
within(protocolListbox)
.getAllByRole('option')
.map((option) => option.textContent)
).toEqual(['Tessera', 'HumidiFi']);
});

it('preserves the selected market when selecting the current protocol again', async () => {
renderDialog();

await screen.findByLabelText('Price of SOL in USDC');
fireEvent.change(screen.getByLabelText('PMM market'), { target: { value: '9NkuAWB4' } });
fireEvent.change(screen.getByLabelText('PMM protocol'), { target: { value: 'tessera' } });

const marketField = screen.getByLabelText('PMM market');
expect(marketField).not.toBeDisabled();
expect(marketField).toHaveValue('9NkuAWB4');
expect(screen.getByRole('button', { name: 'Create scenario' })).not.toBeDisabled();
expect(fetchMarketsMock).toHaveBeenCalledTimes(1);
});

it('routes creation through the selected PMM adapter and refetches its markets', async () => {
const onCreated = vi.fn();
createHumidifiMock.mockResolvedValue({ id: 'humidifi-1' });
renderDialog(onCreated);

await screen.findByLabelText('Price of SOL in USDC');
fireEvent.change(screen.getByLabelText('PMM protocol'), { target: { value: 'humidifi' } });
const price = await screen.findByLabelText('Price of JUP in USDC');
expect(fetchHumidifiMarketsMock).toHaveBeenCalledWith('http://studio');
fireEvent.change(price, { target: { value: '104' } });
fireEvent.click(screen.getByRole('button', { name: 'Create scenario' }));

await waitFor(() => {
expect(createHumidifiMock).toHaveBeenCalledWith('http://studio', 'hKgG7iED', '104');
expect(createScenarioMock).not.toHaveBeenCalled();
expect(onCreated).toHaveBeenCalledWith('humidifi-1');
});
});

it("locks the market field again while the selected PMM's catalog loads", async () => {
fetchHumidifiMarketsMock.mockReturnValue(new Promise(() => {}));
renderDialog();

await screen.findByLabelText('Price of SOL in USDC');
fireEvent.change(screen.getByLabelText('PMM protocol'), { target: { value: 'humidifi' } });

const marketField = screen.getByLabelText('PMM market');
expect(fetchHumidifiMarketsMock).toHaveBeenCalledWith('http://studio');
expect(marketField).toBeDisabled();
expect(marketField).toHaveAttribute('placeholder', 'Loading markets…');
expect(screen.getByRole('button', { name: 'Create scenario' })).toBeDisabled();
});

it('labels the price with the selected discovered pair', async () => {
Expand Down Expand Up @@ -158,6 +221,29 @@ describe('PmmFairValueDialog', () => {
});
});

it.each(['', ' '])('requires an explicit HumidiFi market when discovery fails for %j', async (blankMarket) => {
fetchHumidifiMarketsMock.mockResolvedValue([]);
createHumidifiMock.mockResolvedValue({ id: 'humidifi-scenario-id' });
renderDialog();

await screen.findByLabelText('Price of SOL in USDC');
fireEvent.change(screen.getByLabelText('PMM protocol'), { target: { value: 'humidifi' } });
const marketField = await screen.findByLabelText('PMM market');
await waitFor(() => expect(marketField).toHaveAttribute('placeholder', 'Enter a market account address'));
expect(
screen.getByText('Live market list unavailable. Enter a market account address to continue.')
).toBeInTheDocument();
fireEvent.change(marketField, { target: { value: blankMarket } });
expect(screen.getByRole('button', { name: 'Create scenario' })).toBeDisabled();

fireEvent.change(marketField, { target: { value: 'HumidiFiMarket111' } });
fireEvent.click(screen.getByRole('button', { name: 'Create scenario' }));

await waitFor(() => {
expect(createHumidifiMock).toHaveBeenCalledWith('http://studio', 'HumidiFiMarket111', '100');
});
});

it('rejects zero and excessive precision before calling the backend', async () => {
renderDialog();

Expand Down
92 changes: 76 additions & 16 deletions apps/studio/src/components/svm/pmm-fair-value-dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
'use client';

import { createTesseraFairValueScenario, fetchTesseraMarkets, type TesseraMarketOption } from '@/lib/scenarios-api';
import {
createHumidifiFairValueScenario,
createTesseraFairValueScenario,
fetchHumidifiMarkets,
fetchTesseraMarkets,
type PmmMarketOption,
type ScenarioCreationResult,
} from '@/lib/scenarios-api';
import {
Button,
Dialog,
Expand All @@ -20,32 +27,76 @@ interface PmmFairValueDialogProps {
onCreated: (scenarioId: string) => void;
}

const renderMarketOption = (market: TesseraMarketOption) => (
const PmmProtocol = {
Tessera: 'tessera',
HumidiFi: 'humidifi',
} as const;
type PmmProtocol = (typeof PmmProtocol)[keyof typeof PmmProtocol];

interface PmmAdapter {
id: PmmProtocol;
label: string;
fetchMarkets: (studioUrl: string) => Promise<PmmMarketOption[]>;
createScenario: (studioUrl: string, market: string, price: string) => Promise<ScenarioCreationResult>;
}

const PMM_ADAPTERS: readonly PmmAdapter[] = [
{
id: PmmProtocol.Tessera,
label: 'Tessera',
fetchMarkets: fetchTesseraMarkets,
createScenario: createTesseraFairValueScenario,
},
{
id: PmmProtocol.HumidiFi,
label: 'HumidiFi',
fetchMarkets: fetchHumidifiMarkets,
createScenario: createHumidifiFairValueScenario,
},
];

const renderProtocolOption = (adapter: PmmAdapter) => (
<ListboxOption key={adapter.id} value={adapter.id}>
{adapter.label}
</ListboxOption>
);

const renderMarketOption = (market: PmmMarketOption) => (
<ListboxOption key={market.value} value={market.value}>
{market.label}
</ListboxOption>
);

const priceLabelFor = (market: TesseraMarketOption | undefined) => {
const priceLabelFor = (market: PmmMarketOption | undefined) => {
const [base, quote] = market?.label.split('/') ?? [];
return base && quote ? `Price of ${base} in ${quote}` : 'Price in quote tokens';
};

const adapterFor = (protocol: PmmProtocol) =>
PMM_ADAPTERS.find((adapter) => adapter.id === protocol) ?? PMM_ADAPTERS[0];

const isPmmProtocol = (value: string): value is PmmProtocol => PMM_ADAPTERS.some((adapter) => adapter.id === value);

export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated }: PmmFairValueDialogProps) {
// STATE
const [protocol, setProtocol] = useState<PmmProtocol>(PmmProtocol.Tessera);
const [market, setMarket] = useState('');
const [price, setPrice] = useState('100');
const [error, setError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [marketOptions, setMarketOptions] = useState<TesseraMarketOption[] | null>(null);
const [marketOptions, setMarketOptions] = useState<PmmMarketOption[] | null>(null);

// DERIVED STATE
const adapter = adapterFor(protocol);
const normalizedPrice = price.trim();
const normalizedMarket = market.trim();
const hasValidPrice = /^\d+(?:\.\d{1,12})?$/.test(normalizedPrice) && /[1-9]/.test(normalizedPrice);
const hasMarketCatalog = !!marketOptions && marketOptions.length > 0;
const selectedMarket = marketOptions?.find((option) => option.value === normalizedMarket);
const hasValidMarket = !normalizedMarket || !hasMarketCatalog || !!selectedMarket;
const requiresMarket = protocol === PmmProtocol.HumidiFi;
const hasValidMarket = requiresMarket
? !!normalizedMarket && (!hasMarketCatalog || !!selectedMarket)
: !normalizedMarket || !hasMarketCatalog || !!selectedMarket;
const canCreate = marketOptions !== null && hasValidPrice && hasValidMarket && !isCreating;
const priceLabel = priceLabelFor(selectedMarket ?? marketOptions?.[0]);

Expand All @@ -56,7 +107,12 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
onClose();
};

const handleProtocolSelect = () => {
const handleProtocolSelect = (selectedValue: string) => {
if (!isPmmProtocol(selectedValue)) return;
if (selectedValue === protocol) return;
setProtocol(selectedValue);
setMarket('');
setMarketOptions(null);
setError(null);
};

Expand All @@ -83,10 +139,12 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
setError(null);

try {
const result = await createTesseraFairValueScenario(studioUrl, normalizedMarket, normalizedPrice);
const result = await adapter.createScenario(studioUrl, normalizedMarket, normalizedPrice);
onCreated(result.id);
} catch (requestError) {
setError(requestError instanceof Error ? requestError.message : 'Failed to create Tessera fair-value scenario');
setError(
requestError instanceof Error ? requestError.message : `Failed to create ${adapter.label} fair-value scenario`
);
} finally {
setIsCreating(false);
}
Expand All @@ -98,7 +156,7 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
let cancelled = false;
setMarketOptions(null);

fetchTesseraMarkets(studioUrl).then((options) => {
adapter.fetchMarkets(studioUrl).then((options) => {
if (!cancelled) {
setMarketOptions(options);
setMarket((current) => {
Expand All @@ -111,7 +169,7 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
return () => {
cancelled = true;
};
}, [open, studioUrl]);
}, [open, studioUrl, adapter]);

return (
<Dialog open={open} onClose={handleClose} size="xl">
Expand All @@ -124,8 +182,8 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
<div className="mt-5 space-y-4">
<div>
<span className="mb-1.5 block text-sm font-medium text-zinc-300">PMM protocol</span>
<Listbox aria-label="PMM protocol" value="tessera" onChange={handleProtocolSelect} disabled={isCreating}>
<ListboxOption value="tessera">Tessera</ListboxOption>
<Listbox aria-label="PMM protocol" value={protocol} onChange={handleProtocolSelect} disabled={isCreating}>
{PMM_ADAPTERS.map(renderProtocolOption)}
</Listbox>
</div>
<div>
Expand All @@ -139,15 +197,17 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
) : (
<Input
aria-label="PMM market"
placeholder="Leave empty for the default market"
placeholder={requiresMarket ? 'Enter a market account address' : 'Leave empty for the default market'}
value={market}
onChange={handleMarketInput}
disabled={isCreating}
/>
)}
{marketOptions?.length === 0 && (
<p className="mt-1.5 text-xs text-zinc-500">
Live market list unavailable. Enter a market account address, or leave it empty to use the default.
{requiresMarket
? 'Live market list unavailable. Enter a market account address to continue.'
: 'Live market list unavailable. Enter a market account address, or leave it empty to use the default.'}
</p>
)}
</div>
Expand All @@ -164,8 +224,8 @@ export default function PmmFairValueDialog({ open, studioUrl, onClose, onCreated
disabled={isCreating}
/>
<p className="mt-1.5 text-xs text-zinc-500">
Positive decimal with up to 12 places. The backend derives both atomic ratio fields from the market&apos;s
mint decimals.
Positive decimal with up to 12 places. The backend derives the atomic ratio from the market&apos;s mint
decimals.
</p>
</div>
{!!error && <p className="text-sm text-red-400">{error}</p>}
Expand Down
2 changes: 1 addition & 1 deletion apps/studio/src/components/svm/scenario-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -368,7 +368,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', 'Pump', 'PumpSwap', 'Phoenix Eternal', 'Tessera'];
const ENABLED_PROTOCOLS = ['Pyth', 'Raydium', 'Drift', 'Pump', 'PumpSwap', 'Phoenix Eternal', 'Tessera', 'HumidiFi'];

useEffect(() => {
const fetchProtocols = async () => {
Expand Down
Loading