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}
/>
) : (