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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added apps/studio/public/assets/solfi-badged.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions apps/studio/src/components/svm/ai-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(<AIHeader />);

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(<AIHeader />);
expect(screen.getByLabelText('Select AI model')).toBeInTheDocument();
Expand Down
32 changes: 32 additions & 0 deletions apps/studio/src/components/svm/raw-layout-fields.test.ts
Original file line number Diff line number Diff line change
@@ -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' }]);
});
});
21 changes: 21 additions & 0 deletions apps/studio/src/components/svm/raw-layout-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
type RawLayoutProperty = {
path: string;
encoding?: string | Record<string, unknown>;
};

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',
}));
}
211 changes: 191 additions & 20 deletions apps/studio/src/components/svm/scenario-editor.tsx

Large diffs are not rendered by default.

7 changes: 7 additions & 0 deletions apps/studio/src/components/svm/scenarios-bento.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ export const exampleScenarios: ExampleScenario[] = [
icon: '💰',
protocols: ['kamino', 'whirlpool'],
},
{
label: 'SolFi Risk-Off',
prompt:
'Create an editable WSOL/USDC scenario where SOL crashes to $50, so the PMM becomes cautious about accumulating more SOL without completely leaving the market. It should keep serving small trades, buy SOL at a 1% discount, spend no more than 25 USDC on sellers, and continue selling SOL at its normal 0.1% spread. To achieve this, use the SolFi price override, keep that quote active for 200 slots, apply a directional spread, and limit the USDC vault balance.',
icon: '🛡️',
protocols: ['solfi'],
},
{
label: 'Triangular Arbitrage',
prompt:
Expand Down
80 changes: 79 additions & 1 deletion apps/studio/src/components/svm/token-selector-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { resolveTokenSelectorOptions, type TokenSelectorOption } from './token-selector-options';
import {
getSolFiAccountOptions,
getSolFiCustomAccountKind,
resolveSolFiAccount,
resolveTokenSelectorOptions,
shouldUseConstantCombobox,
type TokenSelectorOption,
} from './token-selector-options';

const catalogOptions: TokenSelectorOption[] = [
{ id: 'catalog-token', label: 'Catalog token', value: 'CatalogMintpump' },
Expand Down Expand Up @@ -34,3 +41,74 @@ describe('resolveTokenSelectorOptions', () => {
expect(result.options).toBe(catalogOptions);
});
});

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

it('preserves the existing threshold for ordinary constant fields', () => {
expect(shouldUseConstantCombobox(2, false)).toBe(false);
expect(shouldUseConstantCombobox(21, false)).toBe(true);
});
});
96 changes: 96 additions & 0 deletions apps/studio/src/components/svm/token-selector-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,102 @@ export type TokenSelectorOption = {
description?: string;
};

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 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
Expand Down
3 changes: 3 additions & 0 deletions apps/studio/src/lib/protocol-icons.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('PROTOCOLS', () => {
expect(ids).toContain('pyth');
expect(ids).toContain('jupiter');
expect(ids).toContain('raydium');
expect(ids).toContain('solfi');
});

it('each protocol has id, name, and icon', () => {
Expand All @@ -23,6 +24,7 @@ describe('PROTOCOL_ICONS', () => {
it('maps protocol IDs to icon paths', () => {
expect(PROTOCOL_ICONS['pyth']).toBe('/assets/pyth.svg');
expect(PROTOCOL_ICONS['jupiter']).toBe('/assets/jupiter.svg');
expect(PROTOCOL_ICONS['solfi']).toBe('/assets/solfi-badged.png');
});

it('has the same number of entries as PROTOCOLS', () => {
Expand All @@ -33,6 +35,7 @@ describe('PROTOCOL_ICONS', () => {
describe('getProtocolIcon', () => {
it('returns icon for known protocol', () => {
expect(getProtocolIcon('pyth')).toBe('/assets/pyth.svg');
expect(getProtocolIcon('solfi')).toBe('/assets/solfi-badged.png');
});

it('returns default fallback for unknown protocol', () => {
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/lib/protocol-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export const PROTOCOLS: Protocol[] = [
{ id: 'meteora', name: 'Meteora', icon: '/assets/meteora.svg' },
{ id: 'pump', name: 'Pump', icon: '/assets/pump.svg' },
{ id: 'pumpswap', name: 'PumpSwap', icon: '/assets/pumpswap.svg' },
{ id: 'solfi', name: 'SolFi', icon: '/assets/solfi-badged.png' },
];

// Shared protocol icon mappings (derived from PROTOCOLS for backwards compatibility)
Expand Down
Loading