diff --git a/apps/studio/public/assets/solfi-badged.png b/apps/studio/public/assets/solfi-badged.png new file mode 100644 index 0000000..f4b69da Binary files /dev/null and b/apps/studio/public/assets/solfi-badged.png differ diff --git a/apps/studio/src/components/svm/ai-header.test.tsx b/apps/studio/src/components/svm/ai-header.test.tsx index 32e82e5..a38469c 100644 --- a/apps/studio/src/components/svm/ai-header.test.tsx +++ b/apps/studio/src/components/svm/ai-header.test.tsx @@ -88,6 +88,7 @@ describe('AIHeader', () => { expect(screen.getByText('Pump Graduation')).toBeInTheDocument(); expect(screen.getByText('PumpSwap Pool')).toBeInTheDocument(); expect(screen.getByText('PumpSwap Price Shock')).toBeInTheDocument(); + expect(screen.getByText('SolFi Risk-Off')).toBeInTheDocument(); }); it('renders example scenarios in a two-row scroller without a native scrollbar', () => { @@ -125,6 +126,22 @@ describe('AIHeader', () => { expect(prompt).toContain('do not build or execute a swap'); }); + it('loads the deployed-program-verified SolFi PMM risk-off scenario', () => { + renderWithConfig(); + + fireEvent.click(screen.getByText('SolFi Risk-Off')); + + const prompt = (screen.getByPlaceholderText('Describe a scenario to simulate...') as HTMLTextAreaElement).value; + expect(prompt).toContain('SOL crashes to $50'); + expect(prompt).toContain( + 'the PMM becomes cautious about accumulating more SOL without completely leaving the market' + ); + expect(prompt).toContain('buy SOL at a 1% discount'); + expect(prompt).toContain('spend no more than 25 USDC'); + expect(prompt).toContain('continue selling SOL at its normal 0.1% spread'); + expect(prompt).toContain('To achieve this'); + }); + it('renders the model selector button', () => { renderWithConfig(); expect(screen.getByLabelText('Select AI model')).toBeInTheDocument(); diff --git a/apps/studio/src/components/svm/raw-layout-fields.test.ts b/apps/studio/src/components/svm/raw-layout-fields.test.ts new file mode 100644 index 0000000..4bc1f21 --- /dev/null +++ b/apps/studio/src/components/svm/raw-layout-fields.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { getFieldsFromRawLayout } from './raw-layout-fields'; + +describe('getFieldsFromRawLayout', () => { + it('returns no fields for an IDL-only template', () => { + expect(getFieldsFromRawLayout({ properties: [{ path: 'value', encoding: 'u64' }] })).toEqual([]); + }); + + it('turns scalar encodings into editable fields', () => { + expect( + getFieldsFromRawLayout({ + rawLayout: true, + properties: [ + { path: 'amount', encoding: 'u64' }, + { path: 'owner', encoding: 'bytes32' }, + ], + }) + ).toEqual([ + { name: 'amount', type: 'u64' }, + { name: 'owner', type: 'bytes32' }, + ]); + }); + + it('uses the scalar type behind a strided encoding', () => { + expect( + getFieldsFromRawLayout({ + rawLayout: true, + properties: [{ path: 'levels', encoding: { i32_strided: { count: 3, stride: 16 } } }], + }) + ).toEqual([{ name: 'levels', type: 'i32' }]); + }); +}); diff --git a/apps/studio/src/components/svm/raw-layout-fields.ts b/apps/studio/src/components/svm/raw-layout-fields.ts new file mode 100644 index 0000000..128d230 --- /dev/null +++ b/apps/studio/src/components/svm/raw-layout-fields.ts @@ -0,0 +1,21 @@ +type RawLayoutProperty = { + path: string; + encoding?: string | Record; +}; + +type RawLayoutTemplate = { + rawLayout?: boolean; + properties?: RawLayoutProperty[]; +}; + +export function getFieldsFromRawLayout(template: RawLayoutTemplate | null | undefined) { + if (!template?.rawLayout || !Array.isArray(template.properties)) return []; + + return template.properties.map((property) => ({ + name: property.path, + type: + typeof property.encoding === 'string' + ? property.encoding + : Object.keys(property.encoding ?? {})[0]?.replace(/_strided$/, '') || 'u64', + })); +} diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 145c66f..3f9dcb9 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -4,7 +4,9 @@ import { useAppConfig } from '@/hooks/use-app-config'; import { getProtocolIcon } from '@/lib/protocol-icons'; import { flattenOverrideValues, + getDirectAccountMarketConstantName, parseScenariosJson, + resolveTemplateAccount, scenarioDownloadFile, serializeScenarioJson, snapshotDownloadContents, @@ -23,11 +25,18 @@ import { TrashIcon, } from '@heroicons/react/24/solid'; import { logger } from '@surfpool/shared'; -import { Combobox, ComboboxLabel, ComboboxOption, Select, Switch } from '@surfpool/ui'; +import { Combobox, ComboboxLabel, ComboboxOption, Input, Select, Switch } from '@surfpool/ui'; import { AnimatePresence, motion } from 'framer-motion'; import { LosslessNumber } from 'lossless-json'; import React, { useEffect, useRef, useState } from 'react'; -import { resolveTokenSelectorOptions } from './token-selector-options'; +import { getFieldsFromRawLayout } from './raw-layout-fields'; +import { + getSolFiAccountOptions, + getSolFiCustomAccountKind, + resolveSolFiAccount, + resolveTokenSelectorOptions, + shouldUseConstantCombobox, +} from './token-selector-options'; import TransactionInspector from './transaction-inspector'; interface Protocol { @@ -105,6 +114,7 @@ const ENABLED_PROTOCOLS = [ 'Drift', 'Pump', 'PumpSwap', + 'SolFi', // Kamino: one entry per program, since each has its own IDL and program id 'kamino', 'kamino-scope', @@ -142,6 +152,8 @@ export default function ScenarioEditor({ const [selectedProduct, setSelectedProduct] = useState(null); const [selectedAction, setSelectedAction] = useState(null); const [accountData, setAccountData] = useState>({}); + const [selectedAccountPubkey, setSelectedAccountPubkey] = useState(''); + const [usesCustomDirectAccountAddress, setUsesCustomDirectAccountAddress] = useState(false); const [modifiedFields, setModifiedFields] = useState>(new Set()); // Which entry of an array field the user is editing, keyed by the array's path. Templates declare // one example index (Scope declares `prices.0.*`), but the entry you actually want differs per @@ -155,6 +167,11 @@ export default function ScenarioEditor({ const [mouseX, setMouseX] = useState(null); const [hasAnimated, setHasAnimated] = useState>(new Set()); const initializedRef = useRef(false); + const directAccountConstantName = getDirectAccountMarketConstantName(selectedAction?.template); + const solfiAccountKind = getSolFiCustomAccountKind(selectedAction?.template?.protocol, selectedAction?.template?.id); + const isAddressSelectionMissing = Boolean( + (directAccountConstantName || solfiAccountKind) && selectedAccountPubkey.trim() === '' + ); const [currentPlaybackSlot, setCurrentPlaybackSlot] = useState(0); const [isExecuting, setIsExecuting] = useState(false); const [downloadError, setDownloadError] = useState(null); @@ -470,7 +487,7 @@ export default function ScenarioEditor({ // Helper function to extract fields from IDL using accountType const getFieldsFromIDL = (template: any) => { - if (!template?.idl || !template?.accountType) return []; + if (!template?.idl || !template?.accountType) return getFieldsFromRawLayout(template); // First, try to find the account in the accounts array if (template.idl.accounts && Array.isArray(template.idl.accounts)) { @@ -501,7 +518,7 @@ export default function ScenarioEditor({ } } - return []; + return getFieldsFromRawLayout(template); }; // Helper function to look up a type definition in the IDL @@ -603,9 +620,25 @@ export default function ScenarioEditor({ }; // Register IDL and fetch account data when an action is selected - const handleActionSelect = async (action: Action) => { + const handleActionSelect = async (action: Action, savedAccount?: any) => { setSelectedAction(action); setAccountData({}); + const marketConstantName = getDirectAccountMarketConstantName(action.template); + const savedPubkey = typeof savedAccount?.pubkey === 'string' ? savedAccount.pubkey : ''; + const actionSolFiAccountKind = getSolFiCustomAccountKind(action.template?.protocol, action.template?.id); + const selectedPubkey = actionSolFiAccountKind + ? savedPubkey + : marketConstantName + ? savedPubkey || (action.template?.address?.pubkey ?? '') + : ''; + setSelectedAccountPubkey(selectedPubkey); + setUsesCustomDirectAccountAddress( + Boolean( + actionSolFiAccountKind && + savedPubkey && + !getSolFiAccountOptions(actionSolFiAccountKind).some((option) => String(option.value) === savedPubkey) + ) + ); setModifiedFields(new Set()); // Clear modified fields when loading new action setArrayEntryIndex({}); setFetchBeforeUse(false); // Reset fetch before use toggle @@ -615,6 +648,8 @@ export default function ScenarioEditor({ return; } + if ((marketConstantName || actionSolFiAccountKind) && !selectedPubkey) return; + setLoadingAccountData(true); try { @@ -750,7 +785,15 @@ export default function ScenarioEditor({ overrides: accountData, modifiedFields: Array.from(modifiedFields), fetchBeforeUse: fetchBeforeUse, - account: action.template?.address, + account: resolveSolFiAccount( + action.id, + resolveTemplateAccount( + action.template?.address, + getDirectAccountMarketConstantName(action.template), + selectedAccountPubkey + ), + selectedAccountPubkey + ), }, ], }; @@ -790,7 +833,15 @@ export default function ScenarioEditor({ overrides: accountData, modifiedFields: Array.from(modifiedFields), fetchBeforeUse: fetchBeforeUse, - account: action.template?.address, + account: resolveSolFiAccount( + action.id, + resolveTemplateAccount( + action.template?.address, + getDirectAccountMarketConstantName(action.template), + selectedAccountPubkey + ), + selectedAccountPubkey + ), } : existingAction ), @@ -1260,7 +1311,7 @@ export default function ScenarioEditor({ if (foundAction) { setSelectedAction(foundAction); // Fetch account data for this action - await handleActionSelect(foundAction); + await handleActionSelect(foundAction, action.account); // Restore the overrides and modified fields after loading default data // Start with overrides data @@ -1827,6 +1878,88 @@ export default function ScenarioEditor({ updates, and dynamic balances.

+ {solfiAccountKind && + (() => { + const accountOptions = getSolFiAccountOptions(solfiAccountKind); + const selectedOption = accountOptions.find( + (option) => String(option.value) === selectedAccountPubkey + ); + const customOption = { + id: `custom-solfi-${solfiAccountKind}`, + label: `Custom ${solfiAccountKind} address`, + value: '__custom__', + description: `Enter a SolFi ${solfiAccountKind} address`, + }; + + const changeAccount = (pubkey: string, custom = false) => { + setSelectedAccountPubkey(pubkey); + setUsesCustomDirectAccountAddress(custom); + setAccountData({}); + setModifiedFields(new Set()); + }; + + const loadAccount = (pubkey: string) => { + if (pubkey.trim()) { + void handleActionSelect(selectedAction, { pubkey: pubkey.trim() }); + } + }; + + return ( +
+
+ +

+ Choose a featured SolFi account or enter another address. +

+
+ { + if (!option) return; + const custom = option.value === '__custom__'; + const pubkey = custom ? '' : String(option.value); + changeAccount(pubkey, custom); + if (!custom) loadAccount(pubkey); + }} + options={[customOption, ...accountOptions]} + displayValue={(option: any) => option?.label || ''} + filter={(option: any, query: string) => + [option.label, option.description, option.value] + .filter(Boolean) + .join(' ') + .toLowerCase() + .includes(query.toLowerCase()) + } + placeholder={`Search SolFi ${solfiAccountKind}s...`} + > + {(option: any) => ( + + + {option.label} + {option.description && ( + {option.description} + )} + + + )} + + {usesCustomDirectAccountAddress && ( + changeAccount(event.target.value.trim(), true)} + onBlur={() => loadAccount(selectedAccountPubkey)} + placeholder={`Enter a SolFi ${solfiAccountKind} address...`} + /> + )} +
+ ); + })()} {loadingAccountData ? (
@@ -2301,7 +2434,7 @@ export default function ScenarioEditor({ const renderConstantRefFields = () => { // Filter for constant_ref properties from the new unified format // Note: Backend serializes PropertyKind as "type" field - const constantRefProps = rawProperties + const propertyConstantRefs = rawProperties .filter( (prop: any) => typeof prop !== 'string' && @@ -2317,6 +2450,22 @@ export default function ScenarioEditor({ label: prop.label, description: prop.description, })); + const directMarketConstant = getDirectAccountMarketConstantName( + selectedAction.template + ); + const constantRefProps = [ + ...(directMarketConstant && constants[directMarketConstant] + ? [ + { + name: '__account__', + type: 'constant_ref', + constant: directMarketConstant, + isAccountSelector: true, + }, + ] + : []), + ...propertyConstantRefs, + ]; if (constantRefProps.length === 0) return null; @@ -2326,11 +2475,13 @@ export default function ScenarioEditor({ fieldPath, currentValue, isModified, + onValueChange, }: { constantDef: any; fieldPath: string; currentValue: string | number | undefined; isModified: boolean; + onValueChange: (value: string) => void; }) => { const { options, selectedOption } = resolveTokenSelectorOptions( constantDef.options, @@ -2342,14 +2493,15 @@ export default function ScenarioEditor({ value={selectedOption} onChange={(option: any) => { if (option) { - setValue(fieldPath, option.value); + onValueChange(String(option.value)); } }} options={options} displayValue={(option: any) => { if (!option) return ''; // Display symbol from metadata if available - const symbol = option.metadata?.symbol || option.id?.toUpperCase(); + const symbol = + option.metadata?.symbol || option.label || option.id?.toUpperCase(); return symbol; }} filter={(option: any, query: string) => { @@ -2390,7 +2542,9 @@ export default function ScenarioEditor({ )} - {option.metadata?.symbol || option.id?.toUpperCase()} + {option.metadata?.symbol || + option.label || + option.id?.toUpperCase()} {option.description && ( {option.description} @@ -2406,18 +2560,27 @@ export default function ScenarioEditor({ return (
- PDA Configuration + {directMarketConstant ? 'Account selection' : 'PDA Configuration'}
{constantRefProps.map((prop: any) => { const constantDef = constants[prop.constant]; const fieldPath = prop.name; - const rawValue = getValue(fieldPath); + const rawValue = prop.isAccountSelector + ? selectedAccountPubkey + : getValue(fieldPath); // Convert to string for comparison (handles numbers like config_index) const currentValue = rawValue != null ? String(rawValue) : ''; - const isModified = modifiedFields.has(fieldPath); - + const isModified = prop.isAccountSelector + ? currentValue !== '' + : modifiedFields.has(fieldPath); + const onValueChange = prop.isAccountSelector + ? setSelectedAccountPubkey + : (value: string) => setValue(fieldPath, value); // Use searchable Combobox for constants with many options (e.g., verified tokens) - const useCombobox = constantDef.options.length > 20; + const useCombobox = shouldUseConstantCombobox( + constantDef.options.length, + Boolean(prop.isAccountSelector) + ); return (
@@ -2441,6 +2604,7 @@ export default function ScenarioEditor({ fieldPath={fieldPath} currentValue={currentValue} isModified={isModified} + onValueChange={onValueChange} /> ) : (