From f21321066de535a3442b52eab5ccc1308cae5c42 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 26 Aug 2026 12:43:22 +0300 Subject: [PATCH 1/7] Extend support for Kamino and introduce all missing sub products (#5) * Extend support for Kamino and introduce all missing sub products * Fix array nesting not shown in the override fields * Fix prod build * Fix hardcoded index 0 * Format with prettier --- .../src/components/svm/scenario-editor.tsx | 1607 ++++++++++------- .../components/svm/scenarios-bento.types.ts | 4 +- 2 files changed, 913 insertions(+), 698 deletions(-) diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 00a0477..43b50cc 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -26,6 +26,16 @@ interface Protocol { description: string; icon_url: string; actions: Action[]; + // Set when one brand ships several programs, so the panel asks which one first. + // `actions` stays flattened across all of them. + products?: Product[]; +} + +interface Product { + id: string; + title: string; + description: string; + actions: Action[]; } interface Action { @@ -77,6 +87,34 @@ interface ScenarioEditorProps { }>; } +// Protocols to show in the scenario editor (filter the full list). +// Must match the `protocol` field of each template exactly, including case. +const ENABLED_PROTOCOLS = [ + 'Pyth', + 'Raydium', + 'Drift', + // Kamino: one entry per program, since each has its own IDL and program id + 'kamino', + 'kamino-scope', + 'kamino-farms', + 'kamino-swap', + 'kamino-vault', + 'kamino-liquidity', + // Whirlpool: the Kamino liquidation-arbitrage scenario overrides its pools + 'Whirlpool', +]; + +// Kamino ships six programs. Collapse them behind one icon and let the panel +// ask which product first. Order is the order they appear in the picker. +const KAMINO_PRODUCTS: { protocol: string; title: string; description: string }[] = [ + { protocol: 'kamino', title: 'Lend & Borrow', description: 'Reserves, obligations and markets' }, + { protocol: 'kamino-vault', title: 'Earn', description: 'Yield vaults and their allocations' }, + { protocol: 'kamino-liquidity', title: 'Liquidity', description: 'Concentrated liquidity strategies' }, + { protocol: 'kamino-swap', title: 'Swap', description: 'Limit orders' }, + { protocol: 'kamino-scope', title: 'Scope Oracle', description: 'Prices every other product reads' }, + { protocol: 'kamino-farms', title: 'Farms & Rewards', description: 'Emissions and user stakes' }, +]; + export default function ScenarioEditor({ scenarioId = 'default', scenarioName = 'Scenario', @@ -89,9 +127,14 @@ export default function ScenarioEditor({ const [searchQuery, setSearchQuery] = useState(''); const [actionSearchQuery, setActionSearchQuery] = useState(''); const [selectedProtocol, setSelectedProtocol] = useState(null); + const [selectedProduct, setSelectedProduct] = useState(null); const [selectedAction, setSelectedAction] = useState(null); const [accountData, setAccountData] = useState>({}); 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 + // asset - SOL is 3 on the Main Market, USDC 13 - so the index has to be selectable. + const [arrayEntryIndex, setArrayEntryIndex] = useState>({}); const [fetchBeforeUse, setFetchBeforeUse] = useState(false); const [loadingAccountData, setLoadingAccountData] = useState(false); const [showProtocolPanel, setShowProtocolPanel] = useState(false); @@ -288,6 +331,7 @@ export default function ScenarioEditor({ // If protocol panel is open, just close it and stop propagation e.stopPropagation(); setShowProtocolPanel(false); + setSelectedProduct(null); setSelectedAction(null); setEditingAction(null); setModifiedFields(new Set()); @@ -316,9 +360,6 @@ export default function ScenarioEditor({ const [protocols, setProtocols] = useState([]); const [protocolsLoading, setProtocolsLoading] = useState(true); - // Protocols to show in the scenario editor (filter the full list) - const ENABLED_PROTOCOLS = ['Pyth', 'Raydium', 'Drift']; - useEffect(() => { const fetchProtocols = async () => { try { @@ -342,23 +383,50 @@ export default function ScenarioEditor({ protocolGroups[protocolName].push(template); }); - // Transform into Protocol objects - const transformedProtocols: Protocol[] = Object.entries(protocolGroups).map(([protocolName, templates]) => { - const protocolId = protocolName.toLowerCase().replace(/\s+/g, '-'); + const toActions = (templates: any[]): Action[] => + templates.map((template: any) => ({ + id: template.id, + title: template.name, + description: template.description, + template: template, // Store full template data including IDL + })); + + // Transform into Protocol objects, skipping the Kamino programs so they + // can be merged into a single entry below. + const transformedProtocols: Protocol[] = Object.entries(protocolGroups) + .filter(([protocolName]) => !KAMINO_PRODUCTS.some((p) => p.protocol === protocolName)) + .map(([protocolName, templates]) => { + const protocolId = protocolName.toLowerCase().replace(/\s+/g, '-'); + + return { + id: protocolId, + title: protocolName, + description: `${protocolName} protocol actions`, + icon_url: '', // No icon URL in API response + actions: toActions(templates), + }; + }); - return { - id: protocolId, - title: protocolName, - description: `${protocolName} protocol actions`, - icon_url: '', // No icon URL in API response - actions: templates.map((template: any) => ({ - id: template.id, - title: template.name, - description: template.description, - template: template, // Store full template data including IDL - })), - }; - }); + const kaminoProducts: Product[] = KAMINO_PRODUCTS.filter( + (p) => (protocolGroups[p.protocol]?.length ?? 0) > 0 + ).map((p) => ({ + id: p.protocol, + title: p.title, + description: p.description, + actions: toActions(protocolGroups[p.protocol]), + })); + + if (kaminoProducts.length > 0) { + transformedProtocols.push({ + id: 'kamino', + title: 'Kamino', + description: 'Lending, yield, liquidity and prices', + icon_url: '', + // Flattened so lookups by actionId keep working regardless of product. + actions: kaminoProducts.flatMap((p) => p.actions), + products: kaminoProducts, + }); + } setProtocols(transformedProtocols); setProtocolsLoading(false); @@ -377,6 +445,7 @@ export default function ScenarioEditor({ return ( protocol.title.toLowerCase().includes(query) || protocol.description.toLowerCase().includes(query) || + protocol.products?.some((product) => product.title.toLowerCase().includes(query)) || protocol.actions.some( (action) => action.title.toLowerCase().includes(query) || action.description.toLowerCase().includes(query) ) @@ -429,7 +498,10 @@ export default function ScenarioEditor({ }; // Helper function to get field type information - const getFieldTypeInfo = (field: any, idl: any): { type: string; isNested: boolean; nestedFields?: any[] } => { + const getFieldTypeInfo = ( + field: any, + idl: any + ): { type: string; isNested: boolean; nestedFields?: any[]; elementType?: any } => { const fieldType = field.type; logger.log('🔍 getFieldTypeInfo for field:', field.name, 'fieldType:', fieldType); @@ -439,16 +511,22 @@ export default function ScenarioEditor({ return { type: fieldType, isNested: false }; } - // Array type - if (fieldType?.array) { - const innerType = typeof fieldType.array === 'string' ? fieldType.array : 'unknown'; - return { type: `array<${innerType}>`, isNested: false }; - } - - // Vec type - if (fieldType?.vec) { - const innerType = typeof fieldType.vec === 'string' ? fieldType.vec : 'unknown'; - return { type: `vec<${innerType}>`, isNested: false }; + if (fieldType?.array || fieldType?.vec) { + const isArrayType = Boolean(fieldType.array); + const raw = isArrayType ? fieldType.array : fieldType.vec; + + const element = Array.isArray(raw) ? raw[0] : raw; + const elementName = + typeof element === 'string' + ? element + : typeof element?.defined === 'string' + ? element.defined + : (element?.defined?.name ?? 'unknown'); + return { + type: `${isArrayType ? 'array' : 'vec'}<${elementName}>`, + isNested: false, + elementType: element, + }; } // Option type @@ -576,9 +654,9 @@ export default function ScenarioEditor({ } }; - // Filter actions in protocol panel + // Filter actions in protocol panel, narrowed to the chosen product when there is one const filteredActions = - selectedProtocol?.actions.filter((action) => { + (selectedProduct ?? selectedProtocol)?.actions.filter((action) => { const query = actionSearchQuery.toLowerCase(); return action.title.toLowerCase().includes(query) || action.description.toLowerCase().includes(query); }) || []; @@ -968,6 +1046,7 @@ export default function ScenarioEditor({ setMode('read'); setSelectedSlotId(''); setShowProtocolPanel(false); + setSelectedProduct(null); setEditingAction(null); setFetchBeforeUse(false); } @@ -1122,6 +1201,12 @@ export default function ScenarioEditor({ const protocol = protocols.find((p) => p.id === action.protocolId); if (protocol) { setSelectedProtocol(protocol); + // Reopen straight into the product owning this override + setSelectedProduct( + protocol.products?.find((prod) => + prod.actions.some((a) => a.id === action.actionId) + ) ?? null + ); // Find the specific action within the protocol const foundAction = protocol.actions.find( @@ -1521,7 +1606,11 @@ export default function ScenarioEditor({ className="group cursor-pointer" onClick={() => { setSelectedProtocol(protocol); - if (protocol.actions.length > 0) { + setSelectedProduct(null); + // With products, the panel asks which one before showing overrides + if (protocol.products?.length) { + setSelectedAction(null); + } else if (protocol.actions.length > 0) { handleActionSelect(protocol.actions[0]); } else { setSelectedAction(null); @@ -1561,30 +1650,40 @@ export default function ScenarioEditor({ {/* Header */}
+ {selectedProduct && ( + + )} {selectedProtocol.title}
-

{selectedProtocol.title}

-

{selectedProtocol.description}

+

+ {selectedProtocol.title} + {selectedProduct && / {selectedProduct.title}} +

+

+ {selectedProduct?.description ?? selectedProtocol.description} +

- {/* Two Column Layout */} -
- {/* Left Column - Actions List */} -
- {/* Search Field */} -
-
-
- setActionSearchQuery(e.target.value)} - className="block h-10 w-full rounded-lg border border-zinc-700/50 bg-zinc-800/40 pl-11 pr-4 text-sm text-zinc-100 transition-all placeholder:text-zinc-500 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500" - /> -
-
- {filteredActions.map((action) => ( -
handleActionSelect(action)} + {/* Product picker, shown before the overrides of a multi-program protocol */} + {selectedProtocol.products && !selectedProduct ? ( +
+

+ Pick a product to see the accounts you can override. +

+
+ {selectedProtocol.products.map((product) => ( +
+
{product.title}
+
{product.description}
+
+ {product.actions.length} override{product.actions.length === 1 ? '' : 's'} +
+ ))}
+ ) : ( + /* Two Column Layout */ +
+ {/* Left Column - Actions List */} +
+ {/* Search Field */} +
+
+
+ setActionSearchQuery(e.target.value)} + className="block h-10 w-full rounded-lg border border-zinc-700/50 bg-zinc-800/40 pl-11 pr-4 text-sm text-zinc-100 transition-all placeholder:text-zinc-500 focus:border-zinc-500 focus:outline-none focus:ring-1 focus:ring-zinc-500" + /> +
+
+ {filteredActions.map((action) => ( +
handleActionSelect(action)} + > +
{action.title}
+

{action.description}

+
+ ))} +
+
- {/* Right Column - Account Data Editor */} -
- {selectedAction ? ( - <> -
-
-

- Account Data -

-
- - + {/* Right Column - Account Data Editor */} +
+ {selectedAction ? ( + <> +
+
+

+ Account Data +

+
+ + +
+

+ Fetch account data just before transaction execution. Useful for price feeds, oracle + updates, and dynamic balances. +

-

- Fetch account data just before transaction execution. Useful for price feeds, oracle - updates, and dynamic balances. -

-
- {loadingAccountData ? ( -
-
-
-

Loading account data...

+ {loadingAccountData ? ( +
+
+
+

Loading account data...

+
-
- ) : ( -
- {(() => { - const fields = getFieldsFromIDL(selectedAction.template); - - logger.log('🔍 Fields extracted from IDL:', fields); - logger.log('🔍 Account type:', selectedAction.template?.accountType); - logger.log('🔍 Properties:', selectedAction.template?.properties); - - if (fields.length === 0) { - return

No editable fields available

; - } - - // Get the list of properties from the template (new unified format) - // Properties are now objects with { path, kind, label, description, constant } - const rawProperties = selectedAction.template?.properties || []; - // Extract just the paths for backward compatibility with field filtering - const editableProperties = rawProperties.map((prop: any) => - typeof prop === 'string' ? prop : prop.path - ); - logger.log('🔍 Editable properties:', editableProperties); - editableProperties.forEach((prop: string) => { - logger.log(' 📌', prop); - }); - - // Get constants from template - const constants = selectedAction.template?.constants || {}; - logger.log('🔍 Properties (raw):', rawProperties); - logger.log('🔍 Constants:', constants); - - // Helper to check if a field is a constant_ref - // Note: Backend serializes PropertyKind as "type" field (not "kind") - const getConstantRefInfo = ( - fieldPath: string - ): { - isConstantRef: boolean; - constantDef?: any; - label?: string; - description?: string; - } => { - // Find the property by path in the new unified format - const prop = rawProperties.find( - (p: any) => (typeof p === 'string' ? p : p.path) === fieldPath - ); - // Check prop.type (serialized from Rust's PropertyKind via #[serde(rename = "type")]) - if ( - prop && - typeof prop !== 'string' && - prop.type === 'constant_ref' && - prop.constant && - constants[prop.constant] - ) { - return { - isConstantRef: true, - constantDef: constants[prop.constant], - label: prop.label, - description: prop.description, - }; - } - return { isConstantRef: false }; - }; - - // Helper to get property metadata (label, description) - const getPropertyMeta = ( - fieldPath: string - ): { label?: string; description?: string } => { - const prop = rawProperties.find( - (p: any) => (typeof p === 'string' ? p : p.path) === fieldPath - ); - if (prop && typeof prop !== 'string') { - return { label: prop.label, description: prop.description }; - } - return {}; - }; - - // Get the value from accountData using the path - const getValue = (path: string) => { - const keys = path.split('.'); - let value: any = accountData; - for (const key of keys) { - value = value?.[key]; - } + ) : ( +
+ {(() => { + const fields = getFieldsFromIDL(selectedAction.template); - // Handle undefined/null - if (value === undefined || value === null) { - return ''; - } + logger.log('🔍 Fields extracted from IDL:', fields); + logger.log('🔍 Account type:', selectedAction.template?.accountType); + logger.log('🔍 Properties:', selectedAction.template?.properties); - // Convert objects/arrays to JSON string for display - if (typeof value === 'object') { - return JSON.stringify(value); + if (fields.length === 0) { + return

No editable fields available

; } - return value; - }; - - // Set the value in accountData using the path - const setValue = (path: string, newValue: any) => { - const keys = path.split('.'); - const newData = { ...accountData }; - let current: any = newData; + // Get the list of properties from the template (new unified format) + // Properties are now objects with { path, kind, label, description, constant } + const rawProperties = selectedAction.template?.properties || []; + // Extract just the paths for backward compatibility with field filtering + const editableProperties = rawProperties.map((prop: any) => + typeof prop === 'string' ? prop : prop.path + ); + logger.log('🔍 Editable properties:', editableProperties); + editableProperties.forEach((prop: string) => { + logger.log(' 📌', prop); + }); + + // Get constants from template + const constants = selectedAction.template?.constants || {}; + logger.log('🔍 Properties (raw):', rawProperties); + logger.log('🔍 Constants:', constants); + + // Helper to check if a field is a constant_ref + // Note: Backend serializes PropertyKind as "type" field (not "kind") + const getConstantRefInfo = ( + fieldPath: string + ): { + isConstantRef: boolean; + constantDef?: any; + label?: string; + description?: string; + } => { + // Find the property by path in the new unified format + const prop = rawProperties.find( + (p: any) => (typeof p === 'string' ? p : p.path) === fieldPath + ); + // Check prop.type (serialized from Rust's PropertyKind via #[serde(rename = "type")]) + if ( + prop && + typeof prop !== 'string' && + prop.type === 'constant_ref' && + prop.constant && + constants[prop.constant] + ) { + return { + isConstantRef: true, + constantDef: constants[prop.constant], + label: prop.label, + description: prop.description, + }; + } + return { isConstantRef: false }; + }; - for (let i = 0; i < keys.length - 1; i++) { - if (!current[keys[i]]) { - current[keys[i]] = {}; + // Helper to get property metadata (label, description) + const getPropertyMeta = ( + fieldPath: string + ): { label?: string; description?: string } => { + const prop = rawProperties.find( + (p: any) => (typeof p === 'string' ? p : p.path) === fieldPath + ); + if (prop && typeof prop !== 'string') { + return { label: prop.label, description: prop.description }; } - current = current[keys[i]]; - } + return {}; + }; - current[keys[keys.length - 1]] = newValue; - setAccountData(newData); + // Get the value from accountData using the path + const getValue = (path: string) => { + const keys = path.split('.'); + let value: any = accountData; + for (const key of keys) { + value = value?.[key]; + } - // Mark this field as modified - setModifiedFields((prev) => new Set(prev).add(path)); - }; + // Handle undefined/null + if (value === undefined || value === null) { + return ''; + } - // Helper to check if a property is a constant_ref (rendered separately as Combobox) - const isConstantRefProperty = (fieldPath: string): boolean => { - const prop = rawProperties.find( - (p: any) => (typeof p === 'string' ? p : p.path) === fieldPath - ); - return prop && typeof prop !== 'string' && prop.type === 'constant_ref'; - }; - - // Helper to check if a field or any of its children should be rendered - const shouldRenderField = (fieldPath: string): boolean => { - // Skip constant_ref properties - they're rendered as Comboboxes in PDA Configuration - if (isConstantRefProperty(fieldPath)) { - return false; - } + // Convert objects/arrays to JSON string for display + if (typeof value === 'object') { + return JSON.stringify(value); + } - if (editableProperties.length === 0) { - // If no properties specified, show all fields - return true; - } + return value; + }; - // Check if this exact path is in properties - if (editableProperties.includes(fieldPath)) { - return true; - } + // Set the value in accountData using the path + const setValue = (path: string, newValue: any) => { + const keys = path.split('.'); + const newData = { ...accountData }; + let current: any = newData; - // Check if any property starts with this path (has children) - return editableProperties.some((prop: string) => - prop.startsWith(fieldPath + '.') - ); - }; + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) { + current[keys[i]] = {}; + } + current = current[keys[i]]; + } - const isFieldEditable = (fieldPath: string): boolean => { - if (editableProperties.length === 0) { - return true; - } - return editableProperties.includes(fieldPath); - }; - - // Recursive function to render fields - const renderField = ( - field: any, - path: string, - depth: number = 0 - ): React.ReactNode => { - const typeInfo = getFieldTypeInfo(field, selectedAction.template?.idl); - const fieldPath = path ? `${path}.${field.name}` : field.name; - - logger.log( - '🔍 Rendering field:', - fieldPath, - 'type:', - typeInfo.type, - 'isNested:', - typeInfo.isNested, - 'shouldRender:', - shouldRenderField(fieldPath), - 'isEditable:', - isFieldEditable(fieldPath) - ); + current[keys[keys.length - 1]] = newValue; + setAccountData(newData); - // Skip this field if it's not in the editable properties and has no children that are - if (!shouldRenderField(fieldPath)) { - logger.log('❌ Skipping field:', fieldPath); - return null; - } + // Mark this field as modified + setModifiedFields((prev) => new Set(prev).add(path)); + }; - // Nested struct - render recursively - if (typeInfo.isNested && typeInfo.nestedFields) { - logger.log( - '🔍 Nested struct:', - fieldPath, - 'has', - typeInfo.nestedFields.length, - 'nested fields' - ); - logger.log( - '🔍 Nested fields:', - typeInfo.nestedFields.map((f: any) => f.name) + // Helper to check if a property is a constant_ref (rendered separately as Combobox) + const isConstantRefProperty = (fieldPath: string): boolean => { + const prop = rawProperties.find( + (p: any) => (typeof p === 'string' ? p : p.path) === fieldPath ); + return prop && typeof prop !== 'string' && prop.type === 'constant_ref'; + }; + + // Helper to check if a field or any of its children should be rendered + // A declared path names one example entry of an array. Compare with + // numeric segments wildcarded so choosing a different entry stays + // editable - otherwise picking SOL (index 3) would render nothing, + // because only `prices.0.*` is declared. + const withoutIndices = (path: string) => path.replace(/\.\d+(?=\.|$)/g, '.#'); + const editablePatterns = new Set( + editableProperties.map((prop: string) => withoutIndices(prop)) + ); - const childFields = typeInfo.nestedFields - .map((nestedField: any) => renderField(nestedField, fieldPath, depth + 1)) - .filter(Boolean); // Remove null entries + const shouldRenderField = (fieldPath: string): boolean => { + // Skip constant_ref properties - they're rendered as Comboboxes in PDA Configuration + if (isConstantRefProperty(fieldPath)) { + return false; + } - logger.log('🔍 After filtering, childFields count:', childFields.length); + if (editableProperties.length === 0) { + // If no properties specified, show all fields + return true; + } - // Only render the struct if it has visible children - if (childFields.length === 0) { - logger.log('❌ No visible children for nested struct:', fieldPath); - return null; + // Check if this exact path is in properties + if (editableProperties.includes(fieldPath)) { + return true; } + const pattern = withoutIndices(fieldPath); + if (editablePatterns.has(pattern)) { + return true; + } + + // Check if any property starts with this path (has children) + return editableProperties.some( + (prop: string) => + prop.startsWith(fieldPath + '.') || + withoutIndices(prop).startsWith(pattern + '.') + ); + }; + + const isFieldEditable = (fieldPath: string): boolean => { + if (editableProperties.length === 0) { + return true; + } return ( -
-
- -
{childFields}
-
-
+ editableProperties.includes(fieldPath) || + editablePatterns.has(withoutIndices(fieldPath)) ); - } + }; - // Regular field - only render if it's editable - if (!isFieldEditable(fieldPath)) { - return null; - } + // Recursive function to render fields + const renderField = ( + field: any, + path: string, + depth: number = 0 + ): React.ReactNode => { + const typeInfo = getFieldTypeInfo(field, selectedAction.template?.idl); + const fieldPath = path ? `${path}.${field.name}` : field.name; - // Regular field - render input based on type - const typeString = String(typeInfo.type); - const inputType = - typeString.startsWith('i') || typeString.startsWith('u') - ? 'number' - : typeString === 'bool' - ? 'checkbox' - : 'text'; - - // Check if this field has been explicitly modified by the user - const isModified = modifiedFields.has(fieldPath); - - // Determine field state: OVERRIDE > STREAMED > CACHED - const fieldState = isModified - ? 'override' - : fetchBeforeUse - ? 'streamed' - : 'cached'; - - // Function to reset/clear the field - const resetField = () => { - const keys = fieldPath.split('.'); - const newData = { ...accountData }; - let current = newData; + logger.log( + '🔍 Rendering field:', + fieldPath, + 'type:', + typeInfo.type, + 'isNested:', + typeInfo.isNested, + 'shouldRender:', + shouldRenderField(fieldPath), + 'isEditable:', + isFieldEditable(fieldPath) + ); - for (let i = 0; i < keys.length - 1; i++) { - if (!current[keys[i]]) return; - current = current[keys[i]]; + // Skip this field if it's not in the editable properties and has no children that are + if (!shouldRenderField(fieldPath)) { + logger.log('❌ Skipping field:', fieldPath); + return null; } - delete current[keys[keys.length - 1]]; - setAccountData(newData); + if (typeInfo.elementType !== undefined && !isFieldEditable(fieldPath)) { + const declaredIndices: string[] = Array.from( + new Set( + editableProperties + .filter((prop: string) => prop.startsWith(fieldPath + '.')) + .map((prop: string) => prop.slice(fieldPath.length + 1).split('.')[0]) + .filter((segment: string) => /^\d+$/.test(segment)) + ) + ); - // Remove from modified fields - setModifiedFields((prev) => { - const newSet = new Set(prev); - newSet.delete(fieldPath); - return newSet; - }); - }; + const activeIndex = arrayEntryIndex[fieldPath] ?? declaredIndices[0] ?? '0'; + const indexChildren = [ + renderField( + { name: activeIndex, type: typeInfo.elementType }, + fieldPath, + depth + 1 + ), + ].filter(Boolean); + + if (indexChildren.length === 0) { + return null; + } + + return ( +
+
+ +
{indexChildren}
+
+
+ ); + } - // Get label and description from property metadata - const propertyMeta = getPropertyMeta(fieldPath); - const displayLabel = propertyMeta.label || String(field.name); - const displayDescription = propertyMeta.description; + // Nested struct - render recursively + if (typeInfo.isNested && typeInfo.nestedFields) { + logger.log( + '🔍 Nested struct:', + fieldPath, + 'has', + typeInfo.nestedFields.length, + 'nested fields' + ); + logger.log( + '🔍 Nested fields:', + typeInfo.nestedFields.map((f: any) => f.name) + ); - return ( -
-
-
- - {displayDescription && ( -

{displayDescription}

- )} -
-
- {fieldState === 'override' && ( - <> - - OVERRIDE VALUE + const childFields = typeInfo.nestedFields + .map((nestedField: any) => renderField(nestedField, fieldPath, depth + 1)) + .filter(Boolean); // Remove null entries + + logger.log('🔍 After filtering, childFields count:', childFields.length); + + // Only render the struct if it has visible children + if (childFields.length === 0) { + logger.log('❌ No visible children for nested struct:', fieldPath); + return null; + } + + return ( +
+
+ +
{childFields}
+
-
- {inputType === 'checkbox' ? ( - setValue(fieldPath, e.target.checked)} - className="h-4 w-4 rounded border-zinc-700/50 bg-zinc-800/40 text-yellow-500 focus:ring-1 focus:ring-zinc-500" - /> - ) : ( - e.currentTarget.blur() : undefined - } - value={getValue(fieldPath)} - onChange={(e) => { - let newValue: any = e.target.value; - - // Parse based on type - if (inputType === 'number') { - newValue = Number(e.target.value); - } else if ( - typeString.includes('array') || - typeString.includes('vec') || - typeString === 'object' - ) { - // Try to parse JSON for complex types - try { - newValue = e.target.value - ? JSON.parse(e.target.value) - : e.target.value; - } catch { - // Keep as string if not valid JSON - newValue = e.target.value; - } - } + ); + } - setValue(fieldPath, newValue); - }} - placeholder={`Enter ${String(field.name)}...`} - className={`block w-full rounded-lg border px-4 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 ${ - fieldState === 'override' - ? 'border-yellow-500 bg-yellow-500/5 focus:border-yellow-400 focus:ring-yellow-500/50' - : fieldState === 'streamed' - ? 'border-purple-500 bg-purple-500/5 focus:border-purple-400 focus:ring-purple-500/50' - : 'border-zinc-500 bg-zinc-500/5 focus:border-zinc-400 focus:ring-zinc-500/50' - }`} - /> - )} -
- ); - }; - - // Render constant_ref properties as comboboxes or selects - const renderConstantRefFields = () => { - // Filter for constant_ref properties from the new unified format - // Note: Backend serializes PropertyKind as "type" field - const constantRefProps = rawProperties - .filter( - (prop: any) => - typeof prop !== 'string' && - prop.type === 'constant_ref' && - prop.constant && - constants[prop.constant] - ) - .map((prop: any) => ({ - // Map to the old format for compatibility with existing rendering logic - name: prop.path, - type: 'constant_ref', - constant: prop.constant, - label: prop.label, - description: prop.description, - })); - - if (constantRefProps.length === 0) return null; - - // Token selector component using Catalyst Combobox - const TokenSelector = ({ - constantDef, - fieldPath, - currentValue, - isModified, - }: { - constantDef: any; - fieldPath: string; - 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; + // Regular field - only render if it's editable + if (!isFieldEditable(fieldPath)) { + return null; + } + + // Regular field - render input based on type + const typeString = String(typeInfo.type); + const inputType = + typeString.startsWith('i') || typeString.startsWith('u') + ? 'number' + : typeString === 'bool' + ? 'checkbox' + : 'text'; + + // Check if this field has been explicitly modified by the user + const isModified = modifiedFields.has(fieldPath); + + // Determine field state: OVERRIDE > STREAMED > CACHED + const fieldState = isModified + ? 'override' + : fetchBeforeUse + ? 'streamed' + : 'cached'; + + // Function to reset/clear the field + const resetField = () => { + const keys = fieldPath.split('.'); + const newData = { ...accountData }; + let current = newData; + + for (let i = 0; i < keys.length - 1; i++) { + if (!current[keys[i]]) return; + current = current[keys[i]]; + } + + delete current[keys[keys.length - 1]]; + setAccountData(newData); + + // Remove from modified fields + setModifiedFields((prev) => { + const newSet = new Set(prev); + newSet.delete(fieldPath); + return newSet; + }); + }; + + // Get label and description from property metadata + const propertyMeta = getPropertyMeta(fieldPath); + const displayLabel = propertyMeta.label || String(field.name); + const displayDescription = propertyMeta.description; return ( - { - if (option) { - setValue(fieldPath, option.value); - } - }} - options={constantDef.options} - displayValue={(option: any) => { - if (!option) return ''; - // Display symbol from metadata if available - const symbol = option.metadata?.symbol || option.id?.toUpperCase(); - return symbol; - }} - filter={(option: any, query: string) => { - const q = query.toLowerCase(); - 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); - }} - placeholder={`Search ${constantDef.label.toLowerCase()}...`} - aria-label={constantDef.label} - className={isModified ? '[&_[data-slot=control]]:border-yellow-500' : ''} +
- {(option: any) => ( - -
- {/* Token logo if available */} - {option.metadata?.logo_uri && ( - {option.metadata?.symbol { - // Hide broken images - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - )} - - - {option.metadata?.symbol || option.id?.toUpperCase()} +
+
+ + {displayDescription && ( +

{displayDescription}

+ )} +
+
+ {fieldState === 'override' && ( + <> + + OVERRIDE VALUE - {option.description && ( - {option.description} - )} - -
- + + + )} + {fieldState === 'streamed' && ( + + FETCH BEFORE USE + + )} + {fieldState === 'cached' && ( + + USE CACHED VALUE + + )} +
+
+ {inputType === 'checkbox' ? ( + setValue(fieldPath, e.target.checked)} + className="h-4 w-4 rounded border-zinc-700/50 bg-zinc-800/40 text-yellow-500 focus:ring-1 focus:ring-zinc-500" + /> + ) : ( + e.currentTarget.blur() : undefined + } + value={getValue(fieldPath)} + onChange={(e) => { + let newValue: any = e.target.value; + + // Parse based on type + if (inputType === 'number') { + newValue = Number(e.target.value); + } else if ( + typeString.includes('array') || + typeString.includes('vec') || + typeString === 'object' + ) { + // Try to parse JSON for complex types + try { + newValue = e.target.value + ? JSON.parse(e.target.value) + : e.target.value; + } catch { + // Keep as string if not valid JSON + newValue = e.target.value; + } + } + + setValue(fieldPath, newValue); + }} + placeholder={`Enter ${String(field.name)}...`} + className={`block w-full rounded-lg border px-4 py-2 text-sm text-zinc-100 placeholder:text-zinc-500 focus:outline-none focus:ring-2 ${ + fieldState === 'override' + ? 'border-yellow-500 bg-yellow-500/5 focus:border-yellow-400 focus:ring-yellow-500/50' + : fieldState === 'streamed' + ? 'border-purple-500 bg-purple-500/5 focus:border-purple-400 focus:ring-purple-500/50' + : 'border-zinc-500 bg-zinc-500/5 focus:border-zinc-400 focus:ring-zinc-500/50' + }`} + /> )} - +
); }; - return ( -
-
- PDA Configuration -
- {constantRefProps.map((prop: any) => { - const constantDef = constants[prop.constant]; - const fieldPath = prop.name; - const rawValue = getValue(fieldPath); - // Convert to string for comparison (handles numbers like config_index) - const currentValue = rawValue != null ? String(rawValue) : ''; - const isModified = modifiedFields.has(fieldPath); - - // Use searchable Combobox for constants with many options (e.g., verified tokens) - const useCombobox = constantDef.options.length > 20; - - return ( -
-
-
+ + )} + + ); + }; - {useCombobox ? ( - - ) : ( - + opt.value?.toLowerCase() === currentValue.toLowerCase() + )?.value || + currentValue || + '' + : currentValue || '' + } + onChange={(e) => { + setValue(fieldPath, e.target.value); + }} + className={ + isModified ? '!border-yellow-500 !bg-yellow-500/5' : '' + } + > + - ))} - - )} + {constantDef.options.map((option: any) => ( + + ))} + + )} - {/* Show selected token details */} - {currentValue && - (() => { - // Use case-insensitive comparison for hex values (like Pyth feed IDs) - const selectedOption = constantDef.options.find((opt: any) => - currentValue.startsWith('0x') - ? opt.value?.toLowerCase() === currentValue.toLowerCase() - : opt.value === currentValue - ); - if (!selectedOption) return null; - - return ( -
- {selectedOption.metadata?.logo_uri && ( - {selectedOption.metadata?.symbol { - (e.target as HTMLImageElement).style.display = 'none'; - }} - /> - )} -
-
- - {selectedOption.metadata?.symbol || - selectedOption.id?.toUpperCase()} - - {selectedOption.metadata?.decimals !== undefined && ( - - ({selectedOption.metadata.decimals} decimals) + {/* Show selected token details */} + {currentValue && + (() => { + // Use case-insensitive comparison for hex values (like Pyth feed IDs) + const selectedOption = constantDef.options.find((opt: any) => + currentValue.startsWith('0x') + ? opt.value?.toLowerCase() === currentValue.toLowerCase() + : opt.value === currentValue + ); + if (!selectedOption) return null; + + return ( +
+ {selectedOption.metadata?.logo_uri && ( + {selectedOption.metadata?.symbol { + (e.target as HTMLImageElement).style.display = 'none'; + }} + /> + )} +
+
+ + {selectedOption.metadata?.symbol || + selectedOption.id?.toUpperCase()} - )} -
-
- {selectedOption.value} + {selectedOption.metadata?.decimals !== undefined && ( + + ({selectedOption.metadata.decimals} decimals) + + )} +
+
+ {selectedOption.value} +
-
- ); - })()} -
- ); - })} -
- ); - }; - - return ( - <> - {renderConstantRefFields()} - {fields.map((field: any) => renderField(field, '', 0))} - - ); - })()} -
- )} + ); + })()} +
+ ); + })} +
+ ); + }; - {/* Add/Update Action Button - Right Aligned */} - {!loadingAccountData && ( -
-
+ )} + + {/* Add/Update Action Button - Right Aligned */} + {!loadingAccountData && ( +
+ -
- )} - - ) : ( -
- Select an override to edit account data -
- )} + }} + disabled={!selectedSlotId || !selectedAction} + className="w-[300px] rounded-lg bg-yellow-500 px-6 py-3 font-semibold text-zinc-900 transition-all hover:bg-yellow-400 disabled:cursor-not-allowed disabled:opacity-50" + > + {editingAction + ? 'Update Action' + : selectedSlotId + ? 'Add to Selected Slot' + : 'Select a slot first'} + +
+ )} + + ) : ( +
+ Select an override to edit account data +
+ )} +
-
+ )}
)} diff --git a/apps/studio/src/components/svm/scenarios-bento.types.ts b/apps/studio/src/components/svm/scenarios-bento.types.ts index 6670900..1214634 100644 --- a/apps/studio/src/components/svm/scenarios-bento.types.ts +++ b/apps/studio/src/components/svm/scenarios-bento.types.ts @@ -51,9 +51,9 @@ export const exampleScenarios: ExampleScenario[] = [ { label: 'Liquidation Arbitrage', prompt: - 'A Kamino obligation worth ~$300k became unhealthy. Whirlpool and Raydium AMM pools are set up to create a profitable liquidation arbitrage path', + 'Crash a Kamino collateral price in the Scope oracle so an obligation becomes unhealthy, while Whirlpool pools keep their real price - the gap makes liquidating it profitable', icon: '💰', - protocols: ['kamino', 'whirlpool', 'raydium'], + protocols: ['kamino', 'whirlpool'], }, { label: 'Triangular Arbitrage', From 2b475fd9f15ff68b21fae67cb35b85a1827a5f2f Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 2 Sep 2026 11:11:53 +0300 Subject: [PATCH 2/7] fix(studio): keep the array entry picker on the saved entry --- .../src/components/svm/scenario-editor.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 43b50cc..fb0f681 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -335,6 +335,7 @@ export default function ScenarioEditor({ setSelectedAction(null); setEditingAction(null); setModifiedFields(new Set()); + setArrayEntryIndex({}); setFetchBeforeUse(false); } else { // Check if the selected slot has more than 1 override @@ -591,6 +592,7 @@ export default function ScenarioEditor({ setSelectedAction(action); setAccountData({}); setModifiedFields(new Set()); // Clear modified fields when loading new action + setArrayEntryIndex({}); setFetchBeforeUse(false); // Reset fetch before use toggle if (!action.template?.idl || !action.template?.address) { @@ -1657,6 +1659,7 @@ export default function ScenarioEditor({ setSelectedAction(null); setActionSearchQuery(''); setModifiedFields(new Set()); + setArrayEntryIndex({}); setFetchBeforeUse(false); }} title="Back to Kamino products" @@ -1687,6 +1690,7 @@ export default function ScenarioEditor({ setSelectedAction(null); setEditingAction(null); setModifiedFields(new Set()); + setArrayEntryIndex({}); setFetchBeforeUse(false); }} className="flex h-8 w-8 items-center justify-center rounded-full text-zinc-400 transition-colors hover:bg-zinc-800 hover:text-zinc-100" @@ -1998,7 +2002,16 @@ export default function ScenarioEditor({ ) ); - const activeIndex = arrayEntryIndex[fieldPath] ?? declaredIndices[0] ?? '0'; + const indexFromValues: string | undefined = [ + ...modifiedFields, + ...Object.keys(accountData), + ] + .filter((prop: string) => prop.startsWith(fieldPath + '.')) + .map((prop: string) => prop.slice(fieldPath.length + 1).split('.')[0]) + .find((segment: string) => /^\d+$/.test(segment)); + + const activeIndex = + arrayEntryIndex[fieldPath] ?? indexFromValues ?? declaredIndices[0] ?? '0'; const indexChildren = [ renderField( { name: activeIndex, type: typeInfo.elementType }, @@ -2497,6 +2510,7 @@ export default function ScenarioEditor({ setSelectedAction(null); setAccountData({}); setModifiedFields(new Set()); + setArrayEntryIndex({}); setFetchBeforeUse(false); } }} From 18251d94e50eaa9aecdf9ea960d659e50da04415 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Wed, 9 Sep 2026 16:46:23 +0300 Subject: [PATCH 3/7] feat(studio): render raw-layout override fields --- .../components/svm/raw-layout-fields.test.ts | 32 +++++++++++++++++++ .../src/components/svm/raw-layout-fields.ts | 21 ++++++++++++ .../src/components/svm/scenario-editor.tsx | 5 +-- 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 apps/studio/src/components/svm/raw-layout-fields.test.ts create mode 100644 apps/studio/src/components/svm/raw-layout-fields.ts 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..148cdb2 --- /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: { accountSize: 32 }, + 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: { accountSize: 64 }, + 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..9ce8610 --- /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?: unknown; + 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..b5c86d4 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -27,6 +27,7 @@ import { Combobox, ComboboxLabel, ComboboxOption, Select, Switch } from '@surfpo import { AnimatePresence, motion } from 'framer-motion'; import { LosslessNumber } from 'lossless-json'; import React, { useEffect, useRef, useState } from 'react'; +import { getFieldsFromRawLayout } from './raw-layout-fields'; import { resolveTokenSelectorOptions } from './token-selector-options'; import TransactionInspector from './transaction-inspector'; @@ -470,7 +471,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 +502,7 @@ export default function ScenarioEditor({ } } - return []; + return getFieldsFromRawLayout(template); }; // Helper function to look up a type definition in the IDL From faa42106545022c3da604c01704b91ce7bdf1ae5 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Mon, 14 Sep 2026 15:11:30 +0300 Subject: [PATCH 4/7] feat(studio): add SolFi scenario support --- apps/studio/public/assets/solfi-badged.png | Bin 0 -> 6997 bytes .../src/components/svm/ai-header.test.tsx | 17 ++++ .../src/components/svm/scenario-editor.tsx | 85 +++++++++++++++--- .../components/svm/scenarios-bento.types.ts | 7 ++ .../svm/token-selector-options.test.ts | 17 +++- .../components/svm/token-selector-options.ts | 3 + apps/studio/src/lib/protocol-icons.test.ts | 3 + apps/studio/src/lib/protocol-icons.ts | 1 + apps/studio/src/lib/scenarios-api.test.ts | 63 +++++++++++-- apps/studio/src/lib/scenarios-api.ts | 36 ++++++++ 10 files changed, 208 insertions(+), 24 deletions(-) create mode 100644 apps/studio/public/assets/solfi-badged.png diff --git a/apps/studio/public/assets/solfi-badged.png b/apps/studio/public/assets/solfi-badged.png new file mode 100644 index 0000000000000000000000000000000000000000..f4b69da9d48aab337fc0a5a91891dfd4d306e16d GIT binary patch literal 6997 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 { 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/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index b5c86d4..58a4f4b 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, @@ -28,7 +30,7 @@ import { AnimatePresence, motion } from 'framer-motion'; import { LosslessNumber } from 'lossless-json'; import React, { useEffect, useRef, useState } from 'react'; import { getFieldsFromRawLayout } from './raw-layout-fields'; -import { resolveTokenSelectorOptions } from './token-selector-options'; +import { resolveTokenSelectorOptions, shouldUseConstantCombobox } from './token-selector-options'; import TransactionInspector from './transaction-inspector'; interface Protocol { @@ -106,6 +108,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', @@ -143,6 +146,7 @@ export default function ScenarioEditor({ const [selectedProduct, setSelectedProduct] = useState(null); const [selectedAction, setSelectedAction] = useState(null); const [accountData, setAccountData] = useState>({}); + const [selectedAccountPubkey, setSelectedAccountPubkey] = useState(''); 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 @@ -156,6 +160,8 @@ 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 isAddressSelectionMissing = Boolean(directAccountConstantName && selectedAccountPubkey.trim() === ''); const [currentPlaybackSlot, setCurrentPlaybackSlot] = useState(0); const [isExecuting, setIsExecuting] = useState(false); const [downloadError, setDownloadError] = useState(null); @@ -607,6 +613,8 @@ export default function ScenarioEditor({ const handleActionSelect = async (action: Action) => { setSelectedAction(action); setAccountData({}); + const marketConstantName = getDirectAccountMarketConstantName(action.template); + setSelectedAccountPubkey(marketConstantName ? (action.template?.address?.pubkey ?? '') : ''); setModifiedFields(new Set()); // Clear modified fields when loading new action setArrayEntryIndex({}); setFetchBeforeUse(false); // Reset fetch before use toggle @@ -751,7 +759,11 @@ export default function ScenarioEditor({ overrides: accountData, modifiedFields: Array.from(modifiedFields), fetchBeforeUse: fetchBeforeUse, - account: action.template?.address, + account: resolveTemplateAccount( + action.template?.address, + getDirectAccountMarketConstantName(action.template), + selectedAccountPubkey + ), }, ], }; @@ -791,7 +803,11 @@ export default function ScenarioEditor({ overrides: accountData, modifiedFields: Array.from(modifiedFields), fetchBeforeUse: fetchBeforeUse, - account: action.template?.address, + account: resolveTemplateAccount( + action.template?.address, + getDirectAccountMarketConstantName(action.template), + selectedAccountPubkey + ), } : existingAction ), @@ -1262,6 +1278,9 @@ export default function ScenarioEditor({ setSelectedAction(foundAction); // Fetch account data for this action await handleActionSelect(foundAction); + if (getDirectAccountMarketConstantName(foundAction.template)) { + setSelectedAccountPubkey(action.account?.pubkey ?? ''); + } // Restore the overrides and modified fields after loading default data // Start with overrides data @@ -2302,7 +2321,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' && @@ -2318,6 +2337,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; @@ -2327,11 +2362,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, @@ -2343,14 +2380,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) => { @@ -2391,7 +2429,9 @@ export default function ScenarioEditor({ )} - {option.metadata?.symbol || option.id?.toUpperCase()} + {option.metadata?.symbol || + option.label || + option.id?.toUpperCase()} {option.description && ( {option.description} @@ -2407,18 +2447,28 @@ 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 (
@@ -2442,6 +2492,7 @@ export default function ScenarioEditor({ fieldPath={fieldPath} currentValue={currentValue} isModified={isModified} + onValueChange={onValueChange} /> ) : ( )} + {solfiCustomAccountKind && ( +
+
+ + Or enter a {solfiCustomAccountKind} address + +
+ onValueChange(event.target.value.trim())} + placeholder={`Enter SolFi ${solfiCustomAccountKind} address...`} + className={ + isModified ? '!border-yellow-500 !bg-yellow-500/5' : '' + } + /> +
+ )} + {/* Show selected token details */} {currentValue && (() => { diff --git a/apps/studio/src/components/svm/token-selector-options.test.ts b/apps/studio/src/components/svm/token-selector-options.test.ts index 9cbc6ca..db77a3c 100644 --- a/apps/studio/src/components/svm/token-selector-options.test.ts +++ b/apps/studio/src/components/svm/token-selector-options.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getSolFiCustomAccountKind, resolveTokenSelectorOptions, shouldUseConstantCombobox, type TokenSelectorOption, @@ -39,6 +40,21 @@ describe('resolveTokenSelectorOptions', () => { }); }); +describe('getSolFiCustomAccountKind', () => { + it('names the concrete account expected by each SolFi template', () => { + expect(getSolFiCustomAccountKind('SolFi', 'solfi-spread')).toBe('market'); + expect(getSolFiCustomAccountKind('SolFi', 'solfi-size-impact')).toBe('market'); + expect(getSolFiCustomAccountKind('SolFi', 'solfi-price')).toBe('oracle'); + expect(getSolFiCustomAccountKind('SolFi', 'solfi-freshness')).toBe('oracle'); + expect(getSolFiCustomAccountKind('SolFi', 'solfi-vault-balance')).toBe('vault'); + }); + + it('does not add a custom-address control to other protocols', () => { + expect(getSolFiCustomAccountKind('Raydium', 'solfi-spread')).toBeUndefined(); + expect(getSolFiCustomAccountKind('SolFi', 'unknown-template')).toBeUndefined(); + }); +}); + describe('shouldUseConstantCombobox', () => { it('uses the shared searchable protocol selector for account choices of any size', () => { expect(shouldUseConstantCombobox(2, true)).toBe(true); diff --git a/apps/studio/src/components/svm/token-selector-options.ts b/apps/studio/src/components/svm/token-selector-options.ts index 72910b0..2647385 100644 --- a/apps/studio/src/components/svm/token-selector-options.ts +++ b/apps/studio/src/components/svm/token-selector-options.ts @@ -12,6 +12,14 @@ export type TokenSelectorOption = { export const shouldUseConstantCombobox = (optionCount: number, isAccountSelector: boolean) => isAccountSelector || optionCount > 20; +export const getSolFiCustomAccountKind = (protocol: unknown, templateId: unknown) => { + if (protocol !== 'SolFi' || typeof templateId !== 'string') return undefined; + if (templateId === 'solfi-price' || templateId === 'solfi-freshness') return 'oracle'; + if (templateId === 'solfi-vault-balance') return 'vault'; + if (templateId === 'solfi-spread' || templateId === 'solfi-size-impact') return 'market'; + return undefined; +}; + export const resolveTokenSelectorOptions = ( catalogOptions: TokenSelectorOption[], currentValue: string | number | undefined diff --git a/apps/studio/src/lib/scenarios-api.test.ts b/apps/studio/src/lib/scenarios-api.test.ts index df038ca..6fe3e4f 100644 --- a/apps/studio/src/lib/scenarios-api.test.ts +++ b/apps/studio/src/lib/scenarios-api.test.ts @@ -563,6 +563,21 @@ describe('resolveTemplateAccount', () => { const pda = { pda: { programId: 'program', seeds: [] } }; expect(resolveTemplateAccount(pda, undefined, '')).toBe(pda); }); + + it('carries a user-entered account through the scenario registration RPC payload', () => { + const customAddress = '8BrwYAr1K11sG8GvM8vUFAw45Mm1mLCuTd4ynhNMYjRC'; + const account = resolveTemplateAccount({ pubkey: 'catalog-default' }, 'market', customAddress); + const payload = serializeScenarioJson({ + method: 'surfnet_registerScenario', + params: [{ overrides: [{ templateId: 'solfi-spread', account }] }], + }); + const parsed = parseScenariosJson(payload) as { + params: Array<{ overrides: Array<{ account: { pubkey: string } }> }>; + }; + + expect(parsed.params[0].overrides[0].account.pubkey).toBe(customAddress); + expect(payload).not.toContain('catalog-default'); + }); }); describe('getDirectAccountMarketConstantName', () => { From 7a0d407cbbf92b4eb504dc33ec76d3f4e90cb228 Mon Sep 17 00:00:00 2001 From: bakasura980 Date: Thu, 1 Oct 2026 10:33:45 +0300 Subject: [PATCH 7/7] feat(studio): add featured and custom SolFi account selectors --- .../src/components/svm/scenario-editor.tsx | 169 +++++++++++++----- .../svm/token-selector-options.test.ts | 47 +++++ .../components/svm/token-selector-options.ts | 85 +++++++++ 3 files changed, 255 insertions(+), 46 deletions(-) diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 26b049f..3f9dcb9 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -31,7 +31,9 @@ import { LosslessNumber } from 'lossless-json'; import React, { useEffect, useRef, useState } from 'react'; import { getFieldsFromRawLayout } from './raw-layout-fields'; import { + getSolFiAccountOptions, getSolFiCustomAccountKind, + resolveSolFiAccount, resolveTokenSelectorOptions, shouldUseConstantCombobox, } from './token-selector-options'; @@ -151,6 +153,7 @@ export default function ScenarioEditor({ 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 @@ -165,7 +168,10 @@ export default function ScenarioEditor({ const [hasAnimated, setHasAnimated] = useState>(new Set()); const initializedRef = useRef(false); const directAccountConstantName = getDirectAccountMarketConstantName(selectedAction?.template); - const isAddressSelectionMissing = Boolean(directAccountConstantName && selectedAccountPubkey.trim() === ''); + 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); @@ -614,11 +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); - setSelectedAccountPubkey(marketConstantName ? (action.template?.address?.pubkey ?? '') : ''); + 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 @@ -628,6 +648,8 @@ export default function ScenarioEditor({ return; } + if ((marketConstantName || actionSolFiAccountKind) && !selectedPubkey) return; + setLoadingAccountData(true); try { @@ -763,9 +785,13 @@ export default function ScenarioEditor({ overrides: accountData, modifiedFields: Array.from(modifiedFields), fetchBeforeUse: fetchBeforeUse, - account: resolveTemplateAccount( - action.template?.address, - getDirectAccountMarketConstantName(action.template), + account: resolveSolFiAccount( + action.id, + resolveTemplateAccount( + action.template?.address, + getDirectAccountMarketConstantName(action.template), + selectedAccountPubkey + ), selectedAccountPubkey ), }, @@ -807,9 +833,13 @@ export default function ScenarioEditor({ overrides: accountData, modifiedFields: Array.from(modifiedFields), fetchBeforeUse: fetchBeforeUse, - account: resolveTemplateAccount( - action.template?.address, - getDirectAccountMarketConstantName(action.template), + account: resolveSolFiAccount( + action.id, + resolveTemplateAccount( + action.template?.address, + getDirectAccountMarketConstantName(action.template), + selectedAccountPubkey + ), selectedAccountPubkey ), } @@ -1281,10 +1311,7 @@ export default function ScenarioEditor({ if (foundAction) { setSelectedAction(foundAction); // Fetch account data for this action - await handleActionSelect(foundAction); - if (getDirectAccountMarketConstantName(foundAction.template)) { - setSelectedAccountPubkey(action.account?.pubkey ?? ''); - } + await handleActionSelect(foundAction, action.account); // Restore the overrides and modified fields after loading default data // Start with overrides data @@ -1851,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 ? (
@@ -2467,20 +2576,6 @@ export default function ScenarioEditor({ const onValueChange = prop.isAccountSelector ? setSelectedAccountPubkey : (value: string) => setValue(fieldPath, value); - const solfiCustomAccountKind = prop.isAccountSelector - ? getSolFiCustomAccountKind( - selectedAction.template?.protocol, - selectedAction.template?.id - ) - : undefined; - const customAccountValue = - solfiCustomAccountKind && - !constantDef.options.some( - (option: any) => String(option.value) === currentValue - ) - ? currentValue - : ''; - // Use searchable Combobox for constants with many options (e.g., verified tokens) const useCombobox = shouldUseConstantCombobox( constantDef.options.length, @@ -2542,25 +2637,6 @@ export default function ScenarioEditor({ )} - {solfiCustomAccountKind && ( -
-
- - Or enter a {solfiCustomAccountKind} address - -
- onValueChange(event.target.value.trim())} - placeholder={`Enter SolFi ${solfiCustomAccountKind} address...`} - className={ - isModified ? '!border-yellow-500 !bg-yellow-500/5' : '' - } - /> -
- )} - {/* Show selected token details */} {currentValue && (() => { @@ -2649,6 +2725,7 @@ export default function ScenarioEditor({ setSelectedAction(null); setAccountData({}); setSelectedAccountPubkey(''); + setUsesCustomDirectAccountAddress(false); setModifiedFields(new Set()); setArrayEntryIndex({}); setFetchBeforeUse(false); diff --git a/apps/studio/src/components/svm/token-selector-options.test.ts b/apps/studio/src/components/svm/token-selector-options.test.ts index db77a3c..9fc35bd 100644 --- a/apps/studio/src/components/svm/token-selector-options.test.ts +++ b/apps/studio/src/components/svm/token-selector-options.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest'; import { + getSolFiAccountOptions, getSolFiCustomAccountKind, + resolveSolFiAccount, resolveTokenSelectorOptions, shouldUseConstantCombobox, type TokenSelectorOption, @@ -55,6 +57,51 @@ describe('getSolFiCustomAccountKind', () => { }); }); +describe('getSolFiAccountOptions', () => { + it('returns the four featured markets', () => { + const result = getSolFiAccountOptions('market'); + + expect(result).toHaveLength(4); + expect(result[0]).toMatchObject({ + label: 'SOL / USDC', + value: '65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc', + }); + }); + + it('returns the matching oracle accounts', () => { + const result = getSolFiAccountOptions('oracle'); + + expect(result).toHaveLength(4); + expect(result[0]).toMatchObject({ + label: 'SOL / USDC', + value: '2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou', + }); + }); + + it('returns both payout vaults for every featured market', () => { + const result = getSolFiAccountOptions('vault'); + + expect(result).toHaveLength(8); + expect(result.map((option) => option.value)).toContain('CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A'); + expect(result.map((option) => option.value)).toContain('GhFfLFSprPpfoRaWakPMmJTMJBHuz6C694jYwxy2dAic'); + }); +}); + +describe('resolveSolFiAccount', () => { + it('uses the account selected in Studio for a SolFi override', () => { + expect(resolveSolFiAccount('solfi-spread', { pubkey: '' }, ' CustomMarket111 ')).toEqual({ + pubkey: 'CustomMarket111', + }); + }); + + it('leaves other protocols unchanged and rejects an empty SolFi address', () => { + const address = { pubkey: 'ExistingAccount111' }; + + expect(resolveSolFiAccount('bisonfi-spread', address, 'IgnoredAccount111')).toBe(address); + expect(resolveSolFiAccount('solfi-spread', { pubkey: '' }, ' ')).toBeUndefined(); + }); +}); + describe('shouldUseConstantCombobox', () => { it('uses the shared searchable protocol selector for account choices of any size', () => { expect(shouldUseConstantCombobox(2, true)).toBe(true); diff --git a/apps/studio/src/components/svm/token-selector-options.ts b/apps/studio/src/components/svm/token-selector-options.ts index 2647385..53ae536 100644 --- a/apps/studio/src/components/svm/token-selector-options.ts +++ b/apps/studio/src/components/svm/token-selector-options.ts @@ -20,6 +20,91 @@ export const getSolFiCustomAccountKind = (protocol: unknown, templateId: unknown return undefined; }; +export const isSolFiTemplate = (templateId: unknown) => + typeof templateId === 'string' && templateId.startsWith('solfi-'); + +const SOLFI_FEATURED_MARKETS = [ + { + id: 'sol-usdc', + label: 'SOL / USDC', + market: '65ZHSArs5XxPseKQbB1B4r16vDxMWnCxHMzogDAqiDUc', + oracle: '2ny7eGyZCoeEVTkNLf5HcnJFBKkyA4p4gcrtb3b8y8ou', + baseSymbol: 'SOL', + baseVault: 'CRo8DBwrmd97DJfAnvCv96tZPL5Mktf2NZy2ZnhDer1A', + quoteSymbol: 'USDC', + quoteVault: 'GhFfLFSprPpfoRaWakPMmJTMJBHuz6C694jYwxy2dAic', + }, + { + id: 'usdt-usdc', + label: 'USDT / USDC', + market: 'FkEB6uvyzuoaGpgs4yRtFtxC4WJxhejNFbUkj5R6wR32', + oracle: 'CyCUgmaCYUZxbux3J2svDzxSryVFMtZNPrnMKS41nc4G', + baseSymbol: 'USDT', + baseVault: '5bHD9xdEzJdkVuhs54mGPC9BZgUshqgMg4tqmTwhWggc', + quoteSymbol: 'USDC', + quoteVault: 'ARWaajRJyF6PKQryJ4HLzLBfTWM2qmVQUQVtBjk6PgPc', + }, + { + id: 'hype-usdc', + label: 'HYPE / USDC', + market: '2e25gRiddjn968aXrLt1oZw3BZ4fYD5D8mCv7uKxu1yL', + oracle: '9WDWUT9tx9Z2DKgiuzSsPS3dEBTRnPrzpuWrMHaRgACU', + baseSymbol: 'HYPE', + baseVault: '7DEGUxRtaphAUcdwvZTjVUM2yUM7r3EVWu16FxRZrYjP', + quoteSymbol: 'USDC', + quoteVault: '7RhQGDQgSx3YrQHBjG2BVdZpm8c54o6uSW9p6ckyb8uQ', + }, + { + id: 'pump-usdc', + label: 'PUMP / USDC', + market: '2kfQuYG2FVZL2RqqKEttcdadbPWP4c7b6AFQztNcBWyV', + oracle: '5VTjvi4XhRCXUSitDGJXVp5iZpU2sypNAT5EvEvaXR6j', + baseSymbol: 'PUMP', + baseVault: '8PbjFCfVNHH5PwP7xJj4JnGom397ybK3mLzw9164nZ4K', + quoteSymbol: 'USDC', + quoteVault: 'CMghWj6TEDfGTN5CSzo9p27kPMb73fsDPM22LqTe2C9y', + }, +] as const; + +export const getSolFiAccountOptions = (accountKind: 'market' | 'oracle' | 'vault'): TokenSelectorOption[] => { + if (accountKind === 'market') { + return SOLFI_FEATURED_MARKETS.map((market) => ({ + id: market.id, + label: market.label, + value: market.market, + description: 'Market account', + })); + } + if (accountKind === 'oracle') { + return SOLFI_FEATURED_MARKETS.map((market) => ({ + id: market.id, + label: market.label, + value: market.oracle, + description: 'Oracle account', + })); + } + return SOLFI_FEATURED_MARKETS.flatMap((market) => [ + { + id: `${market.id}-base`, + label: `${market.label} (${market.baseSymbol} vault)`, + value: market.baseVault, + description: 'Base payout vault', + }, + { + id: `${market.id}-quote`, + label: `${market.label} (${market.quoteSymbol} vault)`, + value: market.quoteVault, + description: 'Quote payout vault', + }, + ]); +}; + +export const resolveSolFiAccount = (templateId: unknown, templateAddress: unknown, selectedPubkey: string) => { + if (!isSolFiTemplate(templateId)) return templateAddress; + const pubkey = selectedPubkey.trim(); + return pubkey ? { pubkey } : undefined; +}; + export const resolveTokenSelectorOptions = ( catalogOptions: TokenSelectorOption[], currentValue: string | number | undefined