diff --git a/.env.example b/.env.example index 9930db070..3fea73e65 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ -# Fill these out with a custom RPC url to test the explorer locally without getting rate-limited +## Configuration for RPC enpoints +### Fill these out with a custom RPC url to test the explorer locally without getting rate-limited NEXT_PUBLIC_MAINNET_RPC_URL= NEXT_PUBLIC_DEVNET_RPC_URL= NEXT_PUBLIC_TESTNET_RPC_URL= @@ -13,12 +14,15 @@ NEXT_PUBLIC_METADATA_USER_AGENT="Solana Explorer" NEXT_PUBLIC_PMP_IDL_ENABLED=true ## Configuration for "security.txt" feature enabled NEXT_PUBLIC_PMP_SECURITY_TXT_ENABLED=true +## Configuration for "interactive IDL" feature enabled +NEXT_PUBLIC_INTERACTIVE_IDL_ENABLED=true NEXT_PUBLIC_BAD_TOKENS= ## Configuration for "View CNFT's Original Resource" NEXT_PUBLIC_VIEW_ORIGINAL_DISPLAY_ENABLED= ## Configuration for Sentry ### Flag whether to catch exceptions with the boundary on the client or not NEXT_PUBLIC_ENABLE_CATCH_EXCEPTIONS=1 +### Flag that allows to not collect errors globally (allows to track for errors that we need at the moment SENTRY_SUPPRESS_GLOBAL_ERROR_HANDLER_FILE_WARNING=1 SENTRY_ORG= SENTRY_DSN= diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 19f9085e5..d3ac0efcd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,7 +22,7 @@ jobs: node-version: ${{ steps.nvmrc.outputs.NODE_VERSION }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: sparse-checkout: | .nvmrc @@ -43,7 +43,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install package manager uses: pnpm/action-setup@v4 diff --git a/.github/workflows/update-feature-gates.yml b/.github/workflows/update-feature-gates.yml index 7397ad819..1b563194a 100644 --- a/.github/workflows/update-feature-gates.yml +++ b/.github/workflows/update-feature-gates.yml @@ -12,7 +12,7 @@ jobs: update: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Set up Python uses: actions/setup-python@v4 diff --git a/app/entities/cluster/model/use-explorer-link.ts b/app/entities/cluster/model/use-explorer-link.ts index 0f316184a..8eb6aa0c6 100644 --- a/app/entities/cluster/model/use-explorer-link.ts +++ b/app/entities/cluster/model/use-explorer-link.ts @@ -46,7 +46,13 @@ export function useExplorerLink(path: string) { // Add query parameters if any const queryString = params.toString(); if (queryString) { - url += `?${queryString}`; + if (url.indexOf('?') === -1) { + url += `?${queryString}`; + } else { + // change order for additional params as having ?message at the url and placing it first, breaks input at the Inspector + const [path, qs] = url.split('?'); + url = `${path}?${queryString}&${qs}`; + } } return { link: url }; diff --git a/app/entities/idl/index.ts b/app/entities/idl/index.ts index ef63a71cf..2fbccfca5 100644 --- a/app/entities/idl/index.ts +++ b/app/entities/idl/index.ts @@ -8,7 +8,8 @@ export type { InstructionData, NestedInstructionAccountsData, } from './model/formatters/formatted-idl'; -export { getIdlVersion } from './model/idl-version'; +export { getIdlSpec, getIdlVersion, type AnchorIdl, type CodamaIdl, type SupportedIdl } from './model/idl-version'; +export { isInteractiveIdlSupported } from './model/interactive-idl'; export { getIdlSpecType as getDisplayIdlSpecType } from './model/converters/convert-display-idl'; export { formatDisplayIdl, formatSerdeIdl, getFormattedIdl } from './model/formatters/format'; diff --git a/app/entities/idl/model/__tests__/idl-version.spec.ts b/app/entities/idl/model/__tests__/idl-version.spec.ts new file mode 100644 index 000000000..c7f006a90 --- /dev/null +++ b/app/entities/idl/model/__tests__/idl-version.spec.ts @@ -0,0 +1,70 @@ +import { getIdlVersion, type SupportedIdl } from '../idl-version'; +import { isInteractiveIdlSupported } from '../interactive-idl'; + +const createMockIdl = (type: 'legacy' | 'anchor' | 'codama', specVersion?: string): SupportedIdl => { + if (type === 'codama') { + return { + standard: 'codama', + version: specVersion || '1.0.0', + } as unknown as SupportedIdl; + } + + if (type === 'legacy') { + return { + instructions: [], + name: 'test', + version: '0.0.0', + } as unknown as SupportedIdl; + } + + // Modern Anchor IDL with metadata.spec + return { + address: 'test', + instructions: [], + metadata: { name: 'test', spec: specVersion || '0.1.0', version: '0.1.0' }, + } as unknown as SupportedIdl; +}; + +describe('getIdlVersion', () => { + it('should return "Legacy" for legacy IDL', () => { + const idl = createMockIdl('legacy'); + expect(getIdlVersion(idl)).toBe('Legacy'); + }); + + it('should return "0.30.1" for modern Anchor IDL', () => { + const idl = createMockIdl('anchor', '0.30.1'); + expect(getIdlVersion(idl)).toBe('0.30.1'); + }); + + it('should return codama version for Codama IDL', () => { + const idl = createMockIdl('codama', '1.2.3'); + expect(getIdlVersion(idl)).toBe('1.2.3'); + }); +}); + +describe('isInteractiveIdlSupported', () => { + it('should return false for legacy IDL', () => { + const idl = createMockIdl('legacy'); + expect(isInteractiveIdlSupported(idl)).toBe(false); + }); + + it('should return false for Codama IDL', () => { + const idl = createMockIdl('codama'); + expect(isInteractiveIdlSupported(idl)).toBe(false); + }); + + it('should return false for Anchor with spec < 0.1.0', () => { + expect(isInteractiveIdlSupported(createMockIdl('anchor', '0.0.9'))).toBe(false); + }); + + it('should return true for Anchor with spec 0.1.0', () => { + const idl = createMockIdl('anchor', '0.1.0'); + expect(isInteractiveIdlSupported(idl)).toBe(true); + }); + + it('should return true for Anchor with spec > 0.1.0', () => { + expect(isInteractiveIdlSupported(createMockIdl('anchor', '0.1.1'))).toBe(true); + expect(isInteractiveIdlSupported(createMockIdl('anchor', '0.2.0'))).toBe(true); + expect(isInteractiveIdlSupported(createMockIdl('anchor', '1.0.0'))).toBe(true); + }); +}); diff --git a/app/entities/idl/model/idl-version.ts b/app/entities/idl/model/idl-version.ts index b65cc452c..5f0d3f125 100644 --- a/app/entities/idl/model/idl-version.ts +++ b/app/entities/idl/model/idl-version.ts @@ -4,8 +4,23 @@ import type { RootNode } from 'codama'; import { getIdlSpecType as getSerdeIdlSpecType } from './converters/convert-legacy-idl'; export type IdlVersion = 'Legacy' | '0.30.1' | RootNode['version']; +export type CodamaIdl = RootNode; +export type AnchorIdl = Idl; +export type SupportedIdl = CodamaIdl | AnchorIdl; -export function getIdlVersion(idl: RootNode | Idl): IdlVersion { +/** + * Wildcard label used for all modern Anchor IDL versions (>= 0.30.1). + * This is a label representing the modern Anchor IDL standard, not a specific version. + */ +export const MODERN_ANCHOR_IDL_WILDCARD = '0.30.1'; + +/** + * Returns the IDL specification identifier. + * + * Note: '0.30.1' is used as a label for modern Anchor IDL specification (version >= 0.30.1). + * It represents the modern Anchor IDL specification, not a specific version number. + */ +export function getIdlVersion(idl: SupportedIdl): IdlVersion { const spec = getSerdeIdlSpecType(idl); switch (spec) { case 'legacy': @@ -13,6 +28,16 @@ export function getIdlVersion(idl: RootNode | Idl): IdlVersion { case 'codama': return (idl as RootNode).version; default: - return '0.30.1'; + return MODERN_ANCHOR_IDL_WILDCARD; } } + +/** + * Returns the IDL spec from metadata.spec for Anchor IDLs. + * Returns null for legacy or codama IDLs. + */ +export function getIdlSpec(idl: SupportedIdl): string | null { + const spec = getSerdeIdlSpecType(idl); + if (spec === 'legacy' || spec === 'codama') return null; + return spec; +} diff --git a/app/entities/idl/model/interactive-idl.ts b/app/entities/idl/model/interactive-idl.ts new file mode 100644 index 000000000..ffdff41e7 --- /dev/null +++ b/app/entities/idl/model/interactive-idl.ts @@ -0,0 +1,26 @@ +import { getIdlSpecType as getSerdeIdlSpecType } from './converters/convert-legacy-idl'; +import { getIdlVersion, MODERN_ANCHOR_IDL_WILDCARD, type SupportedIdl } from './idl-version'; + +/** + * Checks if the IDL version is supported for interactive features. + * Supports modern Anchor IDL with specVersion '0.30.1' and spec >= '0.1.0' + */ +export function isInteractiveIdlSupported(idl: SupportedIdl): boolean { + const specVersion = getIdlVersion(idl); + + // Only modern Anchor IDL (specVersion '0.30.1') is supported + if (specVersion !== MODERN_ANCHOR_IDL_WILDCARD) return false; + + // Check if spec is >= 0.1.0 + const spec = getSerdeIdlSpecType(idl); + const match = spec.match(/^(\d+)\.(\d+)\.(\d+)/); + if (!match) return false; + + const [, major, minor, patch] = match.map(Number); + // >= 0.1.0 + if (major > 0) return true; + if (major === 0 && minor > 1) return true; + if (major === 0 && minor === 1 && patch >= 0) return true; + + return false; +} diff --git a/app/features/idl/formatted-idl/lib/invariant.ts b/app/features/idl/formatted-idl/lib/invariant.ts index 16eb1329b..493168ddb 100644 --- a/app/features/idl/formatted-idl/lib/invariant.ts +++ b/app/features/idl/formatted-idl/lib/invariant.ts @@ -1,3 +1,3 @@ -export function invariant(cond: any, message?: string): asserts cond is NonNullable { +export function invariant(cond: unknown, message?: string): asserts cond is NonNullable { if (cond === undefined) throw new Error(message ?? 'invariant violated'); } diff --git a/app/features/idl/formatted-idl/ui/AnchorFormattedIdl.tsx b/app/features/idl/formatted-idl/ui/AnchorFormattedIdl.tsx index 85fa57cea..58a38c144 100644 --- a/app/features/idl/formatted-idl/ui/AnchorFormattedIdl.tsx +++ b/app/features/idl/formatted-idl/ui/AnchorFormattedIdl.tsx @@ -1,4 +1,4 @@ -import { Idl } from '@coral-xyz/anchor'; +import type { AnchorIdl } from '@entities/idl'; import { formatDisplayIdl, getFormattedIdl, useFormatAnchorIdl } from '@entities/idl'; import { invariant } from '../lib/invariant'; @@ -6,7 +6,7 @@ import { useSearchIdl } from '../model/search'; import { BaseFormattedIdl } from './BaseFormattedIdl'; import type { StandardFormattedIdlProps } from './types'; -export function AnchorFormattedIdl({ idl, programId, searchStr = '' }: StandardFormattedIdlProps) { +export function AnchorFormattedIdl({ idl, programId, searchStr = '' }: StandardFormattedIdlProps) { invariant(idl, 'IDL is absent'); const formattedIdl = getFormattedIdl(formatDisplayIdl, idl, programId); const anchorFormattedIdl = useFormatAnchorIdl(idl ? formattedIdl : idl); diff --git a/app/features/idl/formatted-idl/ui/BaseFormattedIdl.tsx b/app/features/idl/formatted-idl/ui/BaseFormattedIdl.tsx index 9547f4fe2..398095275 100644 --- a/app/features/idl/formatted-idl/ui/BaseFormattedIdl.tsx +++ b/app/features/idl/formatted-idl/ui/BaseFormattedIdl.tsx @@ -1,17 +1,21 @@ 'use client'; -import { cn } from '@components/shared/utils'; import type { Idl } from '@coral-xyz/anchor'; +import { cn } from '@shared/utils'; import type { RootNode } from 'codama'; import { useEffect, useState } from 'react'; import { useTabs } from '../../model/use-tabs'; import { SearchHighlightProvider } from './SearchHighlightContext'; -import { FormattedIdlViewProps } from './types'; +import type { FormattedIdlViewProps } from './types'; -export function BaseFormattedIdl({ idl, searchStr }: FormattedIdlViewProps | FormattedIdlViewProps) { +export function BaseFormattedIdl({ + idl, + originalIdl, + searchStr, +}: FormattedIdlViewProps | FormattedIdlViewProps) { const [activeTabIndex, setActiveTabIndex] = useState(null); - const tabs = useTabs(idl, searchStr); + const tabs = useTabs(idl, originalIdl, searchStr); useEffect(() => { if (typeof activeTabIndex === 'number') return; @@ -40,7 +44,7 @@ export function BaseFormattedIdl({ idl, searchStr }: FormattedIdlViewProps ))} -
+
diff --git a/app/features/idl/formatted-idl/ui/CodamaFormattedIdl.tsx b/app/features/idl/formatted-idl/ui/CodamaFormattedIdl.tsx index 07874e37f..0a9f6b5a7 100644 --- a/app/features/idl/formatted-idl/ui/CodamaFormattedIdl.tsx +++ b/app/features/idl/formatted-idl/ui/CodamaFormattedIdl.tsx @@ -1,13 +1,14 @@ +import type { CodamaIdl } from '@entities/idl'; import { useFormatCodamaIdl } from '@entities/idl'; -import type { RootNode } from 'codama'; import { invariant } from '../lib/invariant'; import { useSearchIdl } from '../model/search'; import { BaseFormattedIdl } from './BaseFormattedIdl'; +import type { StandardFormattedIdlProps } from './types'; -export function CodamaFormattedIdl({ idl, searchStr = '' }: { idl?: RootNode; searchStr?: string }) { +export function CodamaFormattedIdl({ idl, searchStr = '' }: StandardFormattedIdlProps) { invariant(idl, 'IDL is absent'); const formattedIdl = useFormatCodamaIdl(idl); const searchResults = useSearchIdl(formattedIdl, searchStr); - return ; + return ; } diff --git a/app/features/idl/formatted-idl/ui/stories/CodamaFormattedIdl.stories.tsx b/app/features/idl/formatted-idl/ui/stories/CodamaFormattedIdl.stories.tsx index 198658d09..51c3c8b68 100644 --- a/app/features/idl/formatted-idl/ui/stories/CodamaFormattedIdl.stories.tsx +++ b/app/features/idl/formatted-idl/ui/stories/CodamaFormattedIdl.stories.tsx @@ -1,5 +1,6 @@ import codamaIdlMock from '@entities/idl/mocks/codama/codama-1.0.0-ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S.json'; import convertedFromAnchorIdlMock from '@entities/idl/mocks/codama/whirlpool@0.30.1.json'; +import { Keypair } from '@solana/web3.js'; import type { Meta, StoryObj } from '@storybook/react'; import type { RootNode } from 'codama'; @@ -27,11 +28,13 @@ type Story = StoryObj; export const DisplayCodamaIdl: Story = { args: { idl: codamaIdlMock as unknown as RootNode, + programId: Keypair.generate().publicKey.toBase58(), }, }; export const DisplayConvertedAnchorIdl: Story = { args: { idl: convertedFromAnchorIdlMock as unknown as RootNode, + programId: Keypair.generate().publicKey.toBase58(), }, }; diff --git a/app/features/idl/formatted-idl/ui/types.d.ts b/app/features/idl/formatted-idl/ui/types.d.ts index 33b7fb1c6..f079762c4 100644 --- a/app/features/idl/formatted-idl/ui/types.d.ts +++ b/app/features/idl/formatted-idl/ui/types.d.ts @@ -8,7 +8,7 @@ export type StandardFormattedIdlProps = { export type FormattedIdlViewProps = { idl: FormattedIdl | null; - originalIdl?: T; + originalIdl: T; searchStr?: string; }; diff --git a/app/features/idl/interactive-idl/model/anchor/anchor-interpreter.spec.ts b/app/features/idl/interactive-idl/model/anchor/anchor-interpreter.spec.ts new file mode 100644 index 000000000..1d4e7deac --- /dev/null +++ b/app/features/idl/interactive-idl/model/anchor/anchor-interpreter.spec.ts @@ -0,0 +1,431 @@ +import { PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; +import { describe, expect, it, vi } from 'vitest'; + +import { AnchorInterpreter } from './anchor-interpreter'; +import { AnchorUnifiedProgram } from './anchor-program'; + +describe('AnchorInterpreter', () => { + const interpreter = new AnchorInterpreter(); + + describe('interpreter name', () => { + it('should have the correct name', () => { + expect(interpreter.name).toBe('anchor'); + }); + }); + + describe('canHandle', () => { + it.each([ + [ + { + address: 'TestProgram11111111111111111111111111111111', + instructions: [], + metadata: { + name: 'test-program', + version: '0.1.0', + }, + }, + true, + 'modern Anchor IDL with metadata.version', + ], + [ + { + address: 'TestProgram11111111111111111111111111111111', + instructions: [], + metadata: { + name: 'test-program', + spec: '0.1.0', + }, + }, + true, + 'modern Anchor IDL with metadata.spec', + ], + [ + { + address: 'TestProgram11111111111111111111111111111111', + instructions: [], + metadata: { + name: 'test-program', + spec: '0.1.0', + version: '0.1.0', + }, + }, + true, + 'modern Anchor IDL with both version and spec', + ], + [ + { + instructions: [], + name: 'test-program', + version: '1.0.0', + }, + false, + 'legacy Anchor IDL', + ], + [null, false, 'null'], + [undefined, false, 'undefined'], + ])('should identify whether can handle %s IDL (%s)', (anchorIdl: any, result, _name: string) => { + expect(interpreter.canHandle(anchorIdl)).toBe(result); + }); + }); + + describe('createInstruction', () => { + it('should convert string arguments to proper types based on IDL', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + accounts: [{ name: 'payer' }, { name: 'tokenAccount' }], + args: [ + { name: 'amount', type: 'u64' }, + { name: 'flag', type: 'bool' }, + { name: 'message', type: 'string' }, + { name: 'authority', type: 'pubkey' }, + ], + name: 'testInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = { + payer: '11111111111111111111111111111111', + tokenAccount: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }; + + const args = ['1000', 'true', 'Hello World', 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v']; + + await interpreter.createInstruction(mockProgram, 'testInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith( + 'testInstruction', + { + payer: new PublicKey('11111111111111111111111111111111'), + tokenAccount: new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + }, + [new BN('1000'), true, 'Hello World', new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v')] + ); + }); + + it('should handle empty string accounts as null', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + accounts: [ + { name: 'payer' }, + { name: 'optionalAccount', optional: true }, + { name: 'anotherOptional', optional: true }, + ], + args: [], + name: 'testInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = { + anotherOptional: ' ', + optionalAccount: '', + payer: '11111111111111111111111111111111', + }; + + const args: any[] = []; + + await interpreter.createInstruction(mockProgram, 'testInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith( + 'testInstruction', + { + anotherOptional: null, + optionalAccount: null, + payer: new PublicKey('11111111111111111111111111111111'), + }, + [] + ); + }); + + it('should handle vector and option types', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'amounts', type: { vec: 'u64' } }, + { name: 'optionalValue', type: { option: 'u32' } }, + { name: 'emptyOption', type: { option: 'string' } }, + ], + name: 'complexInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const args = ['[100, 200, 300]', '42', '']; + + await interpreter.createInstruction(mockProgram, 'complexInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('complexInstruction', {}, [ + [new BN('100'), new BN('200'), new BN('300')], + new BN('42'), + null, + ]); + }); + + it('should handle array types', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'fixedArray', type: { array: ['bool', 3] } }, + { name: 'pubkeyArray', type: { array: ['pubkey', 2] } }, + ], + name: 'arrayInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const args = [ + '["true", "false", "true"]', + '["11111111111111111111111111111111", "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"]', + ]; + + await interpreter.createInstruction(mockProgram, 'arrayInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('arrayInstruction', {}, [ + [true, false, true], + [ + new PublicKey('11111111111111111111111111111111'), + new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'), + ], + ]); + }); + + it('should handle all numeric types', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'u8Val', type: 'u8' }, + { name: 'u16Val', type: 'u16' }, + { name: 'u32Val', type: 'u32' }, + { name: 'u64Val', type: 'u64' }, + { name: 'u128Val', type: 'u128' }, + { name: 'i8Val', type: 'i8' }, + { name: 'i16Val', type: 'i16' }, + { name: 'i32Val', type: 'i32' }, + { name: 'i64Val', type: 'i64' }, + { name: 'i128Val', type: 'i128' }, + ], + name: 'numericInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const args = [ + '255', + '65535', + '4294967295', + '18446744073709551615', + '340282366920938463463374607431768211455', + '-128', + '-32768', + '-2147483648', + '-9223372036854775808', + '-170141183460469231731687303715884105728', + ]; + + await interpreter.createInstruction(mockProgram, 'numericInstruction', accounts, args); + + const expectedArgs = args.map(arg => new BN(arg)); + expect(mockBuildInstruction).toHaveBeenCalledWith('numericInstruction', {}, expectedArgs); + }); + + it('should handle bytes primitive type', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'data', type: 'bytes' }, + { name: 'message', type: 'string' }, + ], + name: 'bytesInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const testData = 'Hello, World!'; + const args = [testData, 'test message']; + + await interpreter.createInstruction(mockProgram, 'bytesInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('bytesInstruction', {}, [ + Buffer.from(testData), + 'test message', + ]); + }); + + it('should handle null and empty arguments', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'optionalString', type: { option: 'string' } }, + { name: 'optionalNumber', type: { option: 'u64' } }, + { name: 'requiredString', type: 'string' }, + ], + name: 'nullableInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const args = [null, '', 'required']; + + await interpreter.createInstruction(mockProgram, 'nullableInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('nullableInstruction', {}, [null, null, 'required']); + }); + + it('should throw error if instruction not found in IDL', async () => { + const mockProgram = { + buildInstruction: vi.fn(), + getIdl: () => ({ + instructions: [ + { + args: [], + name: 'existingInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + await expect(interpreter.createInstruction(mockProgram, 'nonExistentInstruction', {}, [])).rejects.toThrow( + 'Instruction definition not found for "nonExistentInstruction"' + ); + }); + + it('should throw error if argument count does not match IDL definition', async () => { + const mockProgram = { + buildInstruction: vi.fn(), + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'arg1', type: 'u64' }, + { name: 'arg2', type: 'string' }, + ], + name: 'testInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + await expect( + interpreter.createInstruction(mockProgram, 'testInstruction', {}, ['100', '200', 'extra']) + ).rejects.toThrow('Argument at index 2 not found in instruction definition'); + }); + + it('should handle defined types as-is', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [{ name: 'customType', type: { defined: 'CustomStruct' } }], + name: 'definedTypeInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const customData = Buffer.from('encoded transaction data'); + const args = [customData]; + + await interpreter.createInstruction(mockProgram, 'definedTypeInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('definedTypeInstruction', {}, [customData]); + }); + + it('should handle nested vector types', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [{ name: 'matrix', type: { vec: { vec: 'u32' } } }], + name: 'nestedVectorInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const args = ['[[1, 2, 3], [4, 5, 6]]']; + + await interpreter.createInstruction(mockProgram, 'nestedVectorInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('nestedVectorInstruction', {}, [ + [ + [new BN('1'), new BN('2'), new BN('3')], + [new BN('4'), new BN('5'), new BN('6')], + ], + ]); + }); + + it('should handle boolean strings case-insensitively', async () => { + const mockBuildInstruction = vi.fn().mockResolvedValue({}); + const mockProgram = { + buildInstruction: mockBuildInstruction, + getIdl: () => ({ + instructions: [ + { + args: [ + { name: 'bool1', type: 'bool' }, + { name: 'bool2', type: 'bool' }, + { name: 'bool3', type: 'bool' }, + ], + name: 'boolInstruction', + }, + ], + }), + } as unknown as AnchorUnifiedProgram; + + const accounts = {}; + const args = ['true', true, 'false']; + + await interpreter.createInstruction(mockProgram, 'boolInstruction', accounts, args); + + expect(mockBuildInstruction).toHaveBeenCalledWith('boolInstruction', {}, [true, true, false]); + }); + }); +}); diff --git a/app/features/idl/interactive-idl/model/anchor/anchor-interpreter.ts b/app/features/idl/interactive-idl/model/anchor/anchor-interpreter.ts new file mode 100644 index 000000000..b07724669 --- /dev/null +++ b/app/features/idl/interactive-idl/model/anchor/anchor-interpreter.ts @@ -0,0 +1,221 @@ +import { AnchorProvider, type Idl as AnchorIdl, Program as AnchorProgram, type Wallet } from '@coral-xyz/anchor'; +import type { IdlInstruction } from '@coral-xyz/anchor/dist/esm/idl'; +import { formatSerdeIdl, getFormattedIdl } from '@entities/idl'; +import { type Connection, PublicKey } from '@solana/web3.js'; +import BN from 'bn.js'; + +import type { IdlInterpreter } from '../idl-interpreter.d'; +import { AnchorUnifiedProgram } from './anchor-program'; +import { parseArrayInput } from './array-parser'; + +/** + * Anchor IDL interpreter + */ +export class AnchorInterpreter implements IdlInterpreter { + static readonly NAME = 'anchor' as const; + name = AnchorInterpreter.NAME; + + canHandle(idl: any): boolean { + // Check for Anchor-specific fields + if (!idl || typeof idl !== 'object') { + return false; + } + + const isString = (value: unknown): value is string => typeof value === 'string'; + + // Modern Anchor IDL (>= 0.30.0): has address at root, metadata (version or spec), and instructions + return ( + isString(idl.address) && + (isString(idl.metadata?.version) || isString(idl.metadata?.spec)) && + Array.isArray(idl.instructions) + ); + } + + async createProgram( + connection: Connection, + wallet: Wallet, + programId: PublicKey | string, + idl: AnchorIdl + ): Promise { + const publicKey = typeof programId === 'string' ? new PublicKey(programId) : programId; + + // Create provider + const provider = new AnchorProvider(connection, wallet); + + const pubkey = publicKey.toBase58(); + + const properIdl = getFormattedIdl(formatSerdeIdl, idl, pubkey); + + // Create Anchor program + let anchorProgram: AnchorProgram; + try { + anchorProgram = new AnchorProgram(properIdl, provider); + } catch (error) { + throw new Error( + `Failed to create Anchor program: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return new AnchorUnifiedProgram(publicKey, idl, anchorProgram); + } + + async createInstruction( + program: T, + instructionName: Parameters[0], + accounts: Record, + args: Parameters[2] + ) { + // Find instruction definition in IDL + const ixDef = this.findInstructionTypeDefs(program, instructionName); + + // Convert arguments based on IDL type definitions + const convertedArguments = this.convertArguments(instructionName, ixDef.args, args); + + // Convert accounts from strings to PublicKey objects + const convertedAccounts = this.convertAccounts(instructionName, ixDef.accounts, accounts); + + return program.buildInstruction(instructionName, convertedAccounts, convertedArguments); + } + + private findInstructionTypeDefs(unifiedProgram: T, instructionName: string) { + // Find instruction definition in IDL + const ixDef = unifiedProgram.getIdl().instructions.find((ix: any) => ix.name === instructionName); + if (!ixDef) { + throw new Error(`Instruction definition not found for "${instructionName}"`); + } + return ixDef; + } + + /** + * Convert string argument to proper type based on IDL type definition + */ + private convertArgument(value: any, type: any): any { + if (value === undefined || value === null || value === '') { + return null; + } + + // Handle primitive types + if (typeof type === 'string') { + switch (type) { + case 'u8': + case 'u16': + case 'u32': + case 'u64': + case 'u128': + case 'i8': + case 'i16': + case 'i32': + case 'i64': + case 'i128': + return new BN(value); + case 'bool': + return value === 'true' || value === true; + case 'string': + return String(value); + case 'bytes': + return Buffer.from(value); + case 'pubkey': + case 'publicKey': + return new PublicKey(value); + default: + return value; + } + } + + // Handle complex types + if (typeof type === 'object') { + if ('vec' in type) { + // Parse JSON array or comma-separated values + const arr = parseArrayInput(value); + return arr.map((item: any) => this.convertArgument(item, type.vec)); + } + if ('option' in type) { + return value === '' || value === null ? null : this.convertArgument(value, type.option); + } + if ('array' in type) { + const arr = parseArrayInput(value); + return arr.map((item: any) => this.convertArgument(item, type.array[0])); + } + if ('defined' in type) { + // For defined types, use Buffer + return Buffer.from(value); + } + } + + return value; + } + + /** + * Convert arguments to proper type + */ + private convertArguments( + instructionName: string, + argumentsMeta: Readonly>, + args: any[] + ) { + const convertedArguments = args.map((arg, index) => { + const argDef = argumentsMeta[index]; + if (!argDef) { + throw new Error(`Argument at index ${index} not found in instruction definition`); + } + try { + return this.convertArgument(arg, argDef.type); + } catch { + throw new Error(`Could not convert "${argDef.name}" argument for "${instructionName}"`); + } + }); + + return convertedArguments; + } + + /** + * Convert account strings to PublicKey objects + */ + private convertAccounts( + instructionName: string, + accountsMeta: Readonly>, + accounts: Record + ): Record { + const converted: ReturnType = {}; + + function findAccountMeta(name: string, metas = accountsMeta) { + const accountIndex = metas.findIndex(meta => meta.name === name); + if (accountIndex !== -1) { + const accountMeta = metas[accountIndex]; + + if ('accounts' in accountMeta) { + // current limitation: we do not parse nested accounts + return undefined; + } else { + return accountMeta; + } + } + + return undefined; + } + + for (const [key, value] of Object.entries(accounts)) { + const accountMeta = findAccountMeta(key); + + if (!accountMeta) { + throw new Error(`Account with key ${key} not found in instruction definition`); + } + + try { + if (!value || value === '') { + if (accountMeta?.optional) converted[key] = null; + } else if (typeof value === 'string') { + if (value.trim() !== '') { + converted[key] = new PublicKey(value); + } else { + if (accountMeta?.optional) converted[key] = null; + } + } + } catch { + throw new Error(`Could not convert "${accountMeta.name}" argument for "${instructionName}"`); + } + } + + return converted; + } +} diff --git a/app/features/idl/interactive-idl/model/anchor/anchor-program.spec.ts b/app/features/idl/interactive-idl/model/anchor/anchor-program.spec.ts new file mode 100644 index 000000000..9e1f3f09a --- /dev/null +++ b/app/features/idl/interactive-idl/model/anchor/anchor-program.spec.ts @@ -0,0 +1,194 @@ +import type { Idl as AnchorIdl } from '@coral-xyz/anchor'; +import type NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'; +import { Connection, Keypair, PublicKey } from '@solana/web3.js'; +import { describe, expect, it } from 'vitest'; + +import { AnchorInterpreter } from './anchor-interpreter'; + +describe('AnchorUnifiedProgram', () => { + describe('getIdl', () => { + const programId = new PublicKey('11111111111111111111111111111111'); + const connection = new Connection('https://mainnet.rpc.address'); + const wallet = { + publicKey: Keypair.generate().publicKey, + signAllTransactions: async () => { + throw new Error('Not implemented'); + }, + signTransaction: async () => { + throw new Error('Not implemented'); + }, + } as unknown as NodeWallet; + + const interpreter = new AnchorInterpreter(); + + // Simple pre-0.30 IDL with camelCase instruction names + const pre030Idl = { + instructions: [ + { + accounts: [ + { isMut: true, isSigner: true, name: 'payer' } as any, + { isMut: true, isSigner: false, name: 'account' }, + { isMut: false, isSigner: false, name: 'systemProgram' }, + ], + args: [{ name: 'accountData', type: 'u64' }], + name: 'createAccount', + }, + { + accounts: [ + { isMut: false, isSigner: true, name: 'authority' } as any, + { isMut: true, isSigner: false, name: 'account' }, + ], + args: [{ name: 'newData', type: 'u64' }], + name: 'updateAccount', + }, + ], + name: 'test_program', + types: [], + version: '0.1.0', + } as any; + + // Simple 0.30+ IDL with snake_case instruction names + const v030Idl: AnchorIdl = { + accounts: [ + { + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + name: 'MyAccount', + }, + ], + address: programId.toBase58(), + instructions: [ + { + accounts: [ + { name: 'payer', signer: true, writable: true }, + { name: 'account', signer: false, writable: true }, + { address: '11111111111111111111111111111111', name: 'system_program' }, + ], + args: [{ name: 'account_data', type: 'u64' }], + discriminator: [1, 2, 3, 4, 5, 6, 7, 8], + name: 'create_account', + }, + { + accounts: [ + { name: 'authority', signer: true, writable: false }, + { name: 'account', signer: false, writable: true }, + ], + args: [{ name: 'new_data', type: 'u64' }], + discriminator: [8, 7, 6, 5, 4, 3, 2, 1], + name: 'update_account', + }, + ], + metadata: { + name: 'test_program', + spec: '0.1.0', + version: '0.1.0', + }, + types: [ + { + name: 'MyAccount', + type: { + fields: [ + { name: 'data', type: 'u64' }, + { name: 'authority', type: 'pubkey' }, + ], + kind: 'struct', + }, + }, + ], + }; + + it('should return the IDL from the Anchor program instance', async () => { + const program = await interpreter.createProgram(connection, wallet, programId, pre030Idl); + const returnedIdl = program.getIdl(); + + // Verify that getIdl() returns the program's IDL + // @ts-expect-error expect to access private property + expect(returnedIdl).toBe(program.program.idl); + }); + + it('should preserve the original IDL in the program instance', async () => { + const program = await interpreter.createProgram(connection, wallet, programId, pre030Idl); + + // Verify that the original IDL is preserved + expect(program.idl).toBe(pre030Idl); + expect(program.idl.instructions[0].name).toBe('createAccount'); + expect(program.idl.instructions[1].name).toBe('updateAccount'); + }); + + it('should have the program ID set correctly', async () => { + const program = await interpreter.createProgram(connection, wallet, programId, pre030Idl); + + // Verify the program ID is set + expect(program.programId.toBase58()).toBe(programId.toBase58()); + }); + + it('should handle pre-0.30 IDL format with interpreter', async () => { + const program = await interpreter.createProgram(connection, wallet, programId, pre030Idl); + + // Verify the program was created successfully + expect(program).toBeDefined(); + expect(program.programId.toBase58()).toBe(programId.toBase58()); + + // The returned IDL should have the expected structure + const idl = program.getIdl(); + expect(idl).toBeDefined(); + expect(idl.instructions).toBeDefined(); + expect(idl.instructions.length).toBe(2); + }); + + it('should handle 0.30+ IDL format with interpreter', async () => { + const program = await interpreter.createProgram(connection, wallet, programId, v030Idl); + + // Verify the program was created successfully + expect(program).toBeDefined(); + expect(program.programId.toBase58()).toBe(programId.toBase58()); + + // The returned IDL should have the expected structure + const idl = program.getIdl(); + expect(idl).toBeDefined(); + expect(idl.instructions).toBeDefined(); + expect(idl.instructions.length).toBe(2); + }); + + it('should maintain consistency between original and program IDL', async () => { + const program = await interpreter.createProgram(connection, wallet, programId, pre030Idl); + + // Original IDL should be unchanged + expect(program.idl).toBe(pre030Idl); + + // Program's IDL is what getIdl() returns + const returnedIdl = program.getIdl(); + + // Both should have instructions + expect(program.idl.instructions).toBeDefined(); + expect(returnedIdl.instructions).toBeDefined(); + }); + + it('should verify actual conversion behavior for pre-0.30 IDL', async () => { + // Create program and check what getIdl() returns + const program = await interpreter.createProgram(connection, wallet, programId, pre030Idl); + const returnedIdl = program.getIdl(); + + // The actual behavior: getIdl() returns the original camelCase names + const instructionNames = returnedIdl.instructions.map((ix: any) => ix.name); + expect(instructionNames).toEqual(['createAccount', 'updateAccount']); + + // Arguments also keep their original camelCase names + expect(returnedIdl.instructions[0].args[0].name).toBe('accountData'); + expect(returnedIdl.instructions[1].args[0].name).toBe('newData'); + }); + + it('should verify actual conversion behavior for 0.30 IDL', async () => { + // Create program and check what getIdl() returns + const program = await interpreter.createProgram(connection, wallet, programId, v030Idl); + const returnedIdl = program.getIdl(); + + // The actual behavior: getIdl() also returns camelCase names for 0.30 IDL + const instructionNames = returnedIdl.instructions.map((ix: any) => ix.name); + expect(instructionNames).toEqual(['createAccount', 'updateAccount']); + + // Arguments are also converted to camelCase + expect(returnedIdl.instructions[0].args[0].name).toBe('accountData'); + expect(returnedIdl.instructions[1].args[0].name).toBe('newData'); + }); + }); +}); diff --git a/app/features/idl/interactive-idl/model/anchor/anchor-program.ts b/app/features/idl/interactive-idl/model/anchor/anchor-program.ts new file mode 100644 index 000000000..f6266434b --- /dev/null +++ b/app/features/idl/interactive-idl/model/anchor/anchor-program.ts @@ -0,0 +1,53 @@ +import type { Idl as AnchorIdl, Program as AnchorProgram } from '@coral-xyz/anchor'; +import type { PublicKey, TransactionInstruction } from '@solana/web3.js'; + +import type { UnifiedAccounts, UnifiedArguments, UnifiedProgram } from '../unified-program.d'; + +/** + * Unified program implementation for Anchor + */ +export class AnchorUnifiedProgram implements UnifiedProgram { + constructor(public programId: PublicKey, public idl: AnchorIdl, private program: AnchorProgram) {} + + // Build the instruction using Anchor's methods + async _buildInstruction( + instructionName: string, + accounts: UnifiedAccounts, + args: UnifiedArguments + ): Promise { + try { + const instruction = this.program.methods[instructionName]; + + return instruction(...args) + .accounts(accounts as Record) // keep null for optional accounts as opposit breaks account resolution + .instruction(); + } catch (error) { + throw new Error( + `Failed to build instruction "${instructionName}": ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + + async buildInstruction( + instructionName: string, + ixAccounts: UnifiedAccounts, + ixArguments: Array // define a type that satisfies Anchor's arguments type + ): Promise { + if (!(instructionName in this.program.methods)) { + throw new Error( + `Instruction "${instructionName}" not found. Available: ${Object.keys(this.program.methods).join(', ')}` + ); + } + + return this._buildInstruction(instructionName, ixAccounts, ixArguments); + } + + /** + * Allow to access the Idl that have standartized naming for methods + */ + getIdl() { + return this.program.idl; + } +} diff --git a/app/features/idl/interactive-idl/model/anchor/array-parser.test.ts b/app/features/idl/interactive-idl/model/anchor/array-parser.test.ts new file mode 100644 index 000000000..959711d00 --- /dev/null +++ b/app/features/idl/interactive-idl/model/anchor/array-parser.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { parseArrayInput } from './array-parser'; + +describe('parseArrayInput', () => { + it('should return array as-is when input is already an array', () => { + expect(parseArrayInput(['item1', 'item2'])).toEqual(['item1', 'item2']); + expect(parseArrayInput([1, 2, 3])).toEqual([1, 2, 3]); + expect(parseArrayInput([])).toEqual([]); + }); + + it('should parse valid JSON arrays', () => { + expect(parseArrayInput('["item1", "item2"]')).toEqual(['item1', 'item2']); + expect(parseArrayInput('[1, 2, 3]')).toEqual([1, 2, 3]); + expect(parseArrayInput('[true, false, null]')).toEqual([true, false, null]); + expect(parseArrayInput('["mixed", 123, true]')).toEqual(['mixed', 123, true]); + }); + + it('should throw error for invalid JSON when string starts with bracket', () => { + expect(() => parseArrayInput('[item1, item2]')).toThrow('Invalid JSON array'); + expect(() => parseArrayInput('[1, 2, 3')).toThrow('Invalid JSON array'); + }); + + it('should parse comma-separated values as strings', () => { + expect(parseArrayInput('item1,item2')).toEqual(['item1', 'item2']); + expect(parseArrayInput('item1, item2')).toEqual(['item1', 'item2']); + expect(parseArrayInput('item1 , item2 , item3')).toEqual(['item1', 'item2', 'item3']); + }); + + it('should keep quotes in comma-separated values as-is', () => { + expect(parseArrayInput('"item1", "item2"')).toEqual(['"item1"', '"item2"']); + expect(parseArrayInput("'item1', 'item2'")).toEqual(["'item1'", "'item2'"]); + }); + + it('should keep numbers as strings in comma-separated lists', () => { + expect(parseArrayInput('1,2,3')).toEqual(['1', '2', '3']); + expect(parseArrayInput('1.5, 2.7, 3.9')).toEqual(['1.5', '2.7', '3.9']); + }); + + it('should keep booleans and null as strings in comma-separated lists', () => { + expect(parseArrayInput('true,false')).toEqual(['true', 'false']); + expect(parseArrayInput('true, false, null')).toEqual(['true', 'false', 'null']); + }); + + it('should handle single values by wrapping them in array', () => { + expect(parseArrayInput('item')).toEqual(['item']); + expect(parseArrayInput('123')).toEqual(['123']); + expect(parseArrayInput('true')).toEqual(['true']); + expect(parseArrayInput(123)).toEqual([123]); + expect(parseArrayInput(true)).toEqual([true]); + }); + + it('should handle empty inputs', () => { + expect(parseArrayInput('')).toEqual([]); + }); + + it('should handle whitespace correctly', () => { + expect(parseArrayInput(' item1 , item2 ')).toEqual(['item1', 'item2']); + expect(parseArrayInput(' ')).toEqual([]); + }); + + it('should preserve large numeric strings (public keys)', () => { + const pubkey1 = '11111111111111111111111111111111'; + const pubkey2 = '22222222222222222222222222222222'; + + expect(parseArrayInput(`${pubkey1},${pubkey2}`)).toEqual([pubkey1, pubkey2]); + expect(parseArrayInput(`["${pubkey1}", "${pubkey2}"]`)).toEqual([pubkey1, pubkey2]); + }); + + it('should wrap non-string, non-array values in array', () => { + expect(parseArrayInput(null)).toEqual([null]); + expect(parseArrayInput(undefined)).toEqual([undefined]); + expect(parseArrayInput({ key: 'value' })).toEqual([{ key: 'value' }]); + }); +}); diff --git a/app/features/idl/interactive-idl/model/anchor/array-parser.ts b/app/features/idl/interactive-idl/model/anchor/array-parser.ts new file mode 100644 index 000000000..73586d353 --- /dev/null +++ b/app/features/idl/interactive-idl/model/anchor/array-parser.ts @@ -0,0 +1,40 @@ +import { array, coerce, create, string, unknown } from 'superstruct'; + +const CommaSeparatedArray = coerce(array(string()), string(), value => value.split(',').map(v => v.trim())); + +/** + * Superstruct schema for parsing array inputs + * Handles JSON arrays and comma-separated strings + */ +export const ArrayInputSchema = coerce(array(unknown()), unknown(), value => { + // If already an array, return as-is + if (Array.isArray(value)) return value; + + // If not a string, wrap in array + if (typeof value !== 'string') return [value]; + + const trimmed = value.trim(); + + // Handle empty string + if (trimmed === '') return []; + + // If it starts with '[', treat as JSON + if (trimmed.startsWith('[')) { + try { + const parsed = JSON.parse(trimmed); + return Array.isArray(parsed) ? parsed : [parsed]; + } catch { + throw new Error(`Invalid JSON array: ${trimmed}`); + } + } + + // Use CommaSeparatedArray for comma-separated values + return create(trimmed, CommaSeparatedArray); +}); + +/** + * Parse array input using the superstruct schema + */ +export function parseArrayInput(value: any): any[] { + return create(value, ArrayInputSchema); +} diff --git a/app/features/idl/interactive-idl/model/codama/codama-interpreter.spec.ts b/app/features/idl/interactive-idl/model/codama/codama-interpreter.spec.ts new file mode 100644 index 000000000..6e7d0fcc6 --- /dev/null +++ b/app/features/idl/interactive-idl/model/codama/codama-interpreter.spec.ts @@ -0,0 +1,107 @@ +import { Connection, PublicKey, SystemProgram } from '@solana/web3.js'; +import { describe, expect, it, vi } from 'vitest'; + +import type { UnifiedWallet } from '../unified-program.d'; +import { CodamaInterpreter } from './codama-interpreter'; + +describe('CodamaInterpreter', () => { + const interpreter = new CodamaInterpreter(); + + const mockWallet: UnifiedWallet = { + publicKey: PublicKey.default, + signAllTransactions: vi.fn(), + signTransaction: vi.fn(), + }; + + const mockConnection = new Connection('http://mainnet.rpc.address'); + const mockProgramId = SystemProgram.programId; + + describe('interpreter name', () => { + it('should have the correct name', () => { + expect(interpreter.name).toBe('codama'); + }); + }); + + describe('canHandle', () => { + it.each([ + [ + { + name: 'test-program', + nodes: [], + standard: 'codama', + version: '1.0.0', + }, + true, + 'Codama', + ], + [ + { + accounts: [], + instructions: [], + name: 'test-program', + version: '0.1.0', + }, + false, + 'Anchor', + ], + [ + { + instructions: [], + metadata: { + spec: 'legacy', + }, + name: 'test-program', + }, + false, + 'Other', + ], + [ + { + name: 'test-program', + version: '1.0.0', + }, + false, + 'standardless', + ], + [ + { + name: 'test-program', + standard: 'anchor', + version: '1.0.0', + }, + false, + 'different standard', + ], + [null, false, 'null'], + [undefined, false, 'undefined'], + ])('should identify whether can handle $2 IDL with Codama', (codamaIdl: any, result, _name: string) => { + expect(interpreter.canHandle(codamaIdl)).toBe(result); + }); + }); + + describe('createProgram', () => { + it('should throw an error when attempting to create a program', async () => { + const codamaIdl = { + name: 'test-program', + standard: 'codama', + version: '1.0.0', + }; + + await expect( + interpreter.createProgram(mockConnection, mockWallet, mockProgramId, codamaIdl) + ).rejects.toThrow('Codama IDL format is not yet supported for interactive features'); + }); + }); + + describe('createInstruction', () => { + it('should throw an error when attempting to create an instruction', async () => { + const mockProgram = {} as any; + const mockAccounts = {}; + const mockArgs: any[] = []; + + await expect( + interpreter.createInstruction(mockProgram, 'testInstruction', mockAccounts, mockArgs) + ).rejects.toThrow('Codama IDL format is not yet supported for interactive features'); + }); + }); +}); diff --git a/app/features/idl/interactive-idl/model/codama/codama-interpreter.ts b/app/features/idl/interactive-idl/model/codama/codama-interpreter.ts new file mode 100644 index 000000000..0eb7d0c3b --- /dev/null +++ b/app/features/idl/interactive-idl/model/codama/codama-interpreter.ts @@ -0,0 +1,36 @@ +import { getIdlSpecType } from '@entities/idl/model/converters/convert-legacy-idl'; +import type { Connection, PublicKey, TransactionInstruction, VersionedMessage } from '@solana/web3.js'; + +import type { IdlInterpreter } from '../idl-interpreter.d'; +import type { UnifiedAccounts, UnifiedArguments, UnifiedProgram, UnifiedWallet } from '../unified-program.d'; + +/** + * Codama IDL interpreter (stub implementation) + * Currently not supported, but properly identifies Codama IDLs to prevent infinite retries + */ +export class CodamaInterpreter implements IdlInterpreter { + static readonly NAME = 'codama' as const; + name = CodamaInterpreter.NAME; + + canHandle(idl: any): boolean { + return getIdlSpecType(idl) === CodamaInterpreter.NAME; + } + + async createProgram( + _connection: Connection, + _wallet: UnifiedWallet, + _programId: PublicKey | string, + _idl: any + ): Promise { + throw new Error('Codama IDL format is not yet supported for interactive features.'); + } + + async createInstruction( + _program: UnifiedProgram, + _instructionName: string, + _accounts: UnifiedAccounts, + _args: UnifiedArguments + ): Promise { + throw new Error('Codama IDL format is not yet supported for interactive features.'); + } +} diff --git a/app/features/idl/interactive-idl/model/idl-executor.d.ts b/app/features/idl/interactive-idl/model/idl-executor.d.ts index 6e96c53a7..23ee8b242 100644 --- a/app/features/idl/interactive-idl/model/idl-executor.d.ts +++ b/app/features/idl/interactive-idl/model/idl-executor.d.ts @@ -26,7 +26,7 @@ interface IdlExecutorSpec { args: UnifiedArguments, idl: T, interpreterName: string - ): TransactionInstruction | VersionedMessage; + ): Promise; setConnection(connection: Connection): void; } diff --git a/app/features/idl/interactive-idl/model/idl-executor.ts b/app/features/idl/interactive-idl/model/idl-executor.ts new file mode 100644 index 000000000..308ca878f --- /dev/null +++ b/app/features/idl/interactive-idl/model/idl-executor.ts @@ -0,0 +1,160 @@ +import type { Connection, PublicKey } from '@solana/web3.js'; + +import { AnchorInterpreter } from './anchor/anchor-interpreter'; +import { CodamaInterpreter } from './codama/codama-interpreter'; +import type { IdlExecutorConfig, IdlExecutorSpec } from './idl-executor.d'; +import type { IdlInterpreter } from './idl-interpreter.d'; +import type { BaseIdl, UnifiedAccounts, UnifiedArguments, UnifiedProgram, UnifiedWallet } from './unified-program.d'; + +export class IdlExecutor implements IdlExecutorSpec { + private interpreters: Map; + private connection: Connection; + + constructor(config: IdlExecutorConfig) { + this.connection = config.connection; + this.interpreters = new Map(); + + // Register default interpreters + // Order matters: Codama is checked first since it has a specific standard field + const defaultInterpreters = config.interpreters || [ + new CodamaInterpreter(), + new AnchorInterpreter(), + // add future default interpreters here + ]; + + for (const interpreter of defaultInterpreters) { + this.registerInterpreter(interpreter as IdlInterpreter); + } + } + + /** + * Register a new interpreter + */ + registerInterpreter(interpreter: IdlInterpreter): void { + this.interpreters.set(interpreter.name, interpreter); + } + + /** + * Get a registered interpreter by name + */ + getInterpreter(name: string): IdlInterpreter | undefined { + return this.interpreters.get(name); + } + + /** + * Automatically detect and use the appropriate interpreter for an IDL + */ + detectInterpreter(idl: unknown): IdlInterpreter | null { + for (const interpreter of this.interpreters.values()) { + if (interpreter.canHandle(idl)) { + return interpreter; + } + } + return null; + } + + protected selectInterpreter(idl: unknown, interpreterName?: string) { + let interpreter: IdlInterpreter | null; + + if (interpreterName) { + interpreter = this.interpreters.get(interpreterName) || null; + if (!interpreter) { + throw new Error(`Interpreter "${interpreterName}" not found`); + } + } else { + interpreter = this.detectInterpreter(idl); + if (!interpreter) { + throw new Error('No suitable interpreter found for the provided IDL'); + } + } + + return interpreter; + } + + /** + * Initialize a unified program from an IDL using a specific interpreter + */ + async initializeProgram( + idl: T, + programId: PublicKey | string, + wallet?: UnifiedWallet, + interpreterName?: string + ) { + const interpreter = this.selectInterpreter(idl, interpreterName); + + if (!wallet) throw new Error('Wallet is not provided'); + + const program = await interpreter.createProgram(this.connection, wallet, programId, idl); + + return program; + } + + /** + * Get instruction instance + */ + async getInstruction( + program: UnifiedProgram, + instructionName: string, + accs: Record | UnifiedAccounts, + args: UnifiedArguments, + idl: T, + interpreterName: string + ) { + const interpreter = this.selectInterpreter(idl, interpreterName); + const instructionArguments = normalizeArguments(args, interpreterName); + const instruction = await interpreter.createInstruction(program, instructionName, accs, instructionArguments); + + return instruction; + } + + /** + * Update connection + */ + setConnection(connection: Connection): void { + this.connection = connection; + } +} + +/** + * Populates accounts into format that satisfies the UnifiedAccounts + */ +export function populateAccounts( + accounts: Record, + instructionName: string +): Record | UnifiedAccounts { + return Object.keys(accounts).reduce((acc, k) => { + const { field, value } = populateValue(accounts, k, instructionName); + acc[field] = String(value); + return acc; + }, {} as Record); +} + +/** + * Populate arguments into format that satisfies the UnifiedArguments + */ +export function populateArguments(args: Record, instructionName: string) { + return Object.keys(args).reduce((acc, k) => { + const { value } = populateValue(args, k, instructionName); + acc.push(value); + return acc; + }, [] as UnifiedArguments); +} + +/** + * Extract "key:value" for from the "name.key:value" by provided name + */ +function populateValue(data: Record, key: string, instructionName: string) { + const [name, field] = key.split('.'); + if (name !== instructionName) throw new Error(`Could not populate data for ${instructionName}`); + + return { field, value: data[key] }; +} + +/** + * Perform basic normalization for arguments. + * Needed to address complex leaf types + * In-depth transformations should be done on the interpreter level + */ +function normalizeArguments(args: UnifiedArguments, _interpreterName: string) { + return args; +} diff --git a/app/features/idl/interactive-idl/model/use-instruction.ts b/app/features/idl/interactive-idl/model/use-instruction.ts new file mode 100644 index 000000000..f3bf2e536 --- /dev/null +++ b/app/features/idl/interactive-idl/model/use-instruction.ts @@ -0,0 +1,481 @@ +'use client'; + +import type { InstructionData } from '@entities/idl'; +import { useParsedLogs } from '@entities/program-logs'; +import { useWallet } from '@solana/wallet-adapter-react'; +import { + type Commitment, + Connection, + type Finality, + PublicKey, + type RpcResponseAndContext, + SendTransactionError, + type SimulatedTransactionResponse, + Transaction, + type TransactionError, + TransactionInstruction, + VersionedTransaction, +} from '@solana/web3.js'; +import { useAtom } from 'jotai'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useCluster } from '@/app/providers/cluster'; +import { clusterUrl } from '@/app/utils/cluster'; + +import { programAtom } from '../model/state-atoms'; +import { AnchorInterpreter } from './anchor/anchor-interpreter'; +import { IdlExecutor, populateAccounts, populateArguments } from './idl-executor'; +import type { UnifiedWallet } from './unified-program'; +import { BaseIdl } from './unified-program'; + +interface UseInstructionOptions { + programId?: string; + cluster?: string; + idl?: BaseIdl; + enabled?: boolean; + interpreterName?: typeof AnchorInterpreter.NAME; + commitment?: Finality; + /** Commitment level for transaction simulation. Defaults to 'processed'. */ + simulationCommitment?: Commitment; +} + +interface UseInstructionReturn { + // Execution + invokeInstruction: ( + instructionName: string, + instruction: InstructionData, + params: { + accounts: any; + arguments: Record; + } + ) => Promise; + + // Validation helpers + validateInstruction: ( + instructionName: string, + instruction: InstructionData + ) => { isValid: boolean; errors: string[] }; + + // Status + isExecuting: boolean; + preInvocationError: string | null; + lastResult: InstructionInvocationResult; + parseLogs: ReturnType['parseLogs']; + initializeProgram: () => void; + isProgramLoading: boolean; + program: any; + initializationError: string | null; +} + +export function useInstruction({ + programId: pid, + cluster, + idl, + enabled = true, + interpreterName = AnchorInterpreter.NAME, + commitment = 'confirmed', + simulationCommitment = 'processed', +}: UseInstructionOptions): UseInstructionReturn { + const { connected, publicKey, ...wallet } = useWallet(); + const { cluster: currentCluster, customUrl } = useCluster(); + + const [preInvocationError, setPreInvocationError] = useState(null); + const { + isExecuting, + lastResult, + handleTxStart, + handleTxSuccess, + handleTxError, + handleTxEnd, + handleSimulatedTxResult, + parseLogs, + } = useInvocationState(); + const [initializationError, setInitializationError] = useState(null); + const [isProgramLoading, setIsProgramLoading] = useState(false); + const [program, setProgram] = useAtom(programAtom); + + const programId = useMemo(() => (pid ? new PublicKey(pid) : undefined), [pid]); + + // Get connection for the specified cluster + const connection = useMemo(() => { + const endpoint = cluster || clusterUrl(currentCluster, customUrl); + return new Connection(endpoint); + }, [cluster, currentCluster, customUrl]); + + /// Allow to create Executor instance and update cluster-dependent connection + const executorRef = useRef(); + const executor = useMemo(() => { + if (!executorRef.current) { + executorRef.current = new IdlExecutor({ connection }); + } + return executorRef.current; + }, [connection]); + + const unifiedWallet = useMemo(() => { + if (!publicKey) return undefined; + return { + publicKey, + signAllTransactions: + wallet.signAllTransactions || + (async () => { + throw new Error('Wallet not connected'); + }), + signTransaction: + wallet.signTransaction || + (async () => { + throw new Error('Wallet not connected'); + }), + }; + }, [publicKey, wallet.signAllTransactions, wallet.signTransaction]); + + const initializeProgram = useCallback(async () => { + // Don't throw if wallet is missing, just skip initialization + // It will be initialized when wallet becomes available + if (!enabled || !idl || !programId || !unifiedWallet) { + return; + } + + setIsProgramLoading(true); + setInitializationError(null); + + try { + const p = await executor.initializeProgram(idl, programId, unifiedWallet, interpreterName); + setProgram(p); + setInitializationError(null); + } catch (error) { + const errorMessage = handleInitializeError(error); + + console.error('Program initialization failed:', errorMessage); + setInitializationError(errorMessage); + setProgram(undefined); + } finally { + setIsProgramLoading(false); + } + }, [enabled, idl, programId, executor, unifiedWallet, interpreterName, setProgram]); + + // Track initialization key to prevent re-runs + const initKeyRef = useRef(''); + // Single effect to handle initialization + useEffect(() => { + const initKey = `${enabled}-${!!idl}-${programId?.toString()}-${publicKey?.toString()}`; + + if (!enabled) { + // Clear when disabled + if (program) { + setProgram(undefined); + setInitializationError(null); + setIsProgramLoading(false); + } + initKeyRef.current = ''; + return; + } + + // Check if we should initialize + // Initialize when wallet becomes available and we haven't tried with this key yet + const shouldInit = enabled && idl && programId && unifiedWallet && !program && !isProgramLoading; + + if (shouldInit) { + // Only initialize if the key has changed (prevents re-runs) + if (initKeyRef.current !== initKey) { + initKeyRef.current = initKey; + initializeProgram(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, idl, programId, publicKey, unifiedWallet, program, isProgramLoading, setProgram]); + + // Clear program when key dependencies change to ensure fresh initialization + useEffect(() => { + if (program) { + setProgram(undefined); + setInitializationError(null); + initKeyRef.current = ''; + } + }, [idl, programId?.toString()]); // eslint-disable-line react-hooks/exhaustive-deps + + // Validation helper to check if an instruction is ready to execute + const validateInstruction = useCallback((_instructionName: string, _instruction: InstructionData) => { + const errors: string[] = []; + + return { + errors, + isValid: errors.length === 0, + }; + }, []); + + // Main function to invoke an instruction + const invokeInstruction = useCallback( + async ( + instructionName: string, + instruction: InstructionData, + params: { + accounts: any; + arguments: Record; + } + ): Promise => { + if (!connected || !publicKey || !wallet.signTransaction) { + setPreInvocationError('Wallet not connected'); + return; + } + setPreInvocationError(null); + handleTxStart(); + + let transaction: Transaction | undefined; + + try { + if (!idl) throw new Error('Idl is absent'); + if (!program) throw new Error('Program is not initialized'); + if (!wallet) throw new Error('Wallet is not initialized'); + + const ix = await executor.getInstruction( + program, + instructionName, + populateAccounts(params.accounts, instructionName), + populateArguments(params.arguments, instructionName), + idl, + interpreterName + ); + + if (ix instanceof TransactionInstruction) { + // Create and sign transaction + transaction = new Transaction().add(ix); + } else { + throw new Error('Unsuported instruction format'); + } + + // Get recent blockhash + const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = publicKey; + + // Simulate the transaction + const simulatedTx = await connection.simulateTransaction( + new VersionedTransaction(transaction.compileMessage()), + { + commitment: simulationCommitment, + } + ); + handleSimulatedTxResult(simulatedTx); + + // Sign the transaction + const signedTransaction = await wallet.signTransaction(transaction); + + const signature = await connection.sendRawTransaction(signedTransaction.serialize(), { + skipPreflight: false, + }); + + const confirmed = await connection.confirmTransaction( + { + blockhash, + lastValidBlockHeight, + signature, + }, + commitment + ); + + if (confirmed.value?.err) { + throw new Error('Transaction was not confirmed'); + } + + const publishedTransaction = await connection.getTransaction(signature, { + commitment, + maxSupportedTransactionVersion: 0, + }); + + handleTxSuccess(signature, publishedTransaction?.meta?.logMessages); + } catch (error) { + handleTxError(error, transaction); + } finally { + handleTxEnd(); + } + }, + [ + connected, + publicKey, + wallet, + connection, + idl, + executor, + program, + interpreterName, + commitment, + simulationCommitment, + handleTxStart, + handleTxSuccess, + handleTxError, + handleTxEnd, + handleSimulatedTxResult, + ] + ); + + return { + initializationError, + initializeProgram, + // Instruction + invokeInstruction, + isExecuting, + isProgramLoading, + lastResult, + parseLogs, + preInvocationError, + + program, + validateInstruction, + }; +} + +export const isEnabled = ({ + idl, + programId, + publicKey, + connected, +}: { + idl: any; + programId?: PublicKey | string | null; + publicKey: PublicKey | null; + connected: boolean; +}): boolean => { + return Boolean(idl && programId && publicKey && connected === true); +}; + +function handleInitializeError(error: unknown | Error, message = 'Failed to initialize program') { + let errorMessage = message; + if (error instanceof Error) { + // Provide more specific error messages for common issues + if (error.message.toLowerCase().includes('wallet')) { + errorMessage = 'Wallet connection required for program initialization'; + } else if (error.message.toLowerCase().includes('idl')) { + errorMessage = `IDL error: ${error.message}`; + } else if (error.message.toLowerCase().includes('program')) { + errorMessage = `Program error: ${error.message}`; + } else { + errorMessage = error.message; + } + } + return errorMessage; +} + +/** + * Result of invoking an instruction. + * - `{ status: 'success', signature: string, logs: string[], finishedAt: Date }` - Transaction succeeded with signature + * - `{ status: 'error', message: string | null, logs: string[], serializedTxMessage: string | null, finishedAt: Date }` - Transaction failed; message contains base64-encoded serialized transaction, or null if serialization failed + * - `null` - No transaction was executed yet + */ +export type InstructionInvocationResult = + | { status: 'success'; signature: string; logs: string[]; finishedAt: Date } + | { status: 'error'; message: string; logs: string[]; serializedTxMessage: string | null; finishedAt: Date } + | null; + +function useInvocationState() { + const [transactionError, setTransactionError] = useState(null); + const { parseLogs } = useParsedLogs(transactionError); + const [serializedTxMessage, setSerializedTxMessage] = useState(null); + + const [logs, setLogs] = useState([]); + const [isExecuting, setIsExecuting] = useState(false); + const [lastError, setLastError] = useState<{ finishedAt: Date; message: string } | null>(null); + const [lastSuccess, setLastSuccess] = useState<{ finishedAt: Date; signature: string } | null>(null); + + const handleLogsChange = (logs: string[] | null | undefined) => { + if (!logs) return; + setLogs(logs); + }; + + const handleTxStart = () => { + setIsExecuting(true); + setLastError(null); + setLastSuccess(null); + setLogs([]); + setTransactionError(null); + setSerializedTxMessage(null); + }; + + const handleTxSuccess = (signature: string, logs: string[] | null | undefined) => { + setLastSuccess({ finishedAt: new Date(), signature }); + handleLogsChange(logs); + }; + + const handleTxError = (error: unknown | Error, transaction: Transaction | undefined) => { + console.error('Instruction execution failed:', { error, transaction }); + const errorMessage = handleInvokeError(error); + setLastError({ finishedAt: new Date(), message: errorMessage }); + if (error instanceof SendTransactionError) { + setLogs(error.logs ?? []); + setTransactionError(error); + } + setSerializedTxMessage(serializeTransactionMessage(transaction)); + }; + + const handleTxEnd = () => { + setIsExecuting(false); + }; + + const handleSimulatedTxResult = (simulatedTx: RpcResponseAndContext) => { + if (simulatedTx.value.err !== null) { + handleLogsChange(simulatedTx.value.logs); + let errorMessage; + if (typeof simulatedTx.value.err === 'string') { + errorMessage = simulatedTx.value.err; + } + throw new Error(errorMessage ?? 'Could not simulate transaction'); + } + }; + + const lastResult: InstructionInvocationResult = (() => { + if (lastSuccess) { + return { + finishedAt: lastSuccess.finishedAt, + logs: logs, + signature: lastSuccess.signature, + status: 'success', + }; + } + if (lastError) { + return { + finishedAt: lastError.finishedAt, + logs: logs, + message: lastError.message, + serializedTxMessage, + status: 'error', + }; + } + return null; + })(); + + return { + handleSimulatedTxResult, + + handleTxEnd, + handleTxError, + handleTxStart, + handleTxSuccess, + + isExecuting, + + lastResult, + + parseLogs, + }; +} + +function handleInvokeError(error: unknown | Error, message = 'Failed to invoke instruction') { + let errorMessage = message; + if (error instanceof Error) { + if (error.message.toLowerCase().includes('simulation failed')) { + errorMessage = 'Simulation failed. See logs for details.'; + } else { + errorMessage = error.message; + } + } + return errorMessage; +} + +function serializeTransactionMessage(transaction: Transaction | undefined): string | null { + if (!transaction) return null; + try { + return Buffer.from(transaction.serializeMessage()).toString('base64'); + } catch (error) { + console.warn('Failed to serialize transaction message:', error); + return null; + } +} diff --git a/app/features/idl/interactive-idl/model/use-mainnet-confirmation.test.ts b/app/features/idl/interactive-idl/model/use-mainnet-confirmation.test.ts index 62f0dbd15..478b95f68 100644 --- a/app/features/idl/interactive-idl/model/use-mainnet-confirmation.test.ts +++ b/app/features/idl/interactive-idl/model/use-mainnet-confirmation.test.ts @@ -254,7 +254,7 @@ function setup(cluster: Cluster = Cluster.MainnetBeta) { clusterInfo: undefined, customUrl: '', name: config.name, - status: 'connected' as any, + status: 'connected', url: config.url, }); } diff --git a/app/features/idl/interactive-idl/ui/InstructionActivity.tsx b/app/features/idl/interactive-idl/ui/InstructionActivity.tsx new file mode 100644 index 000000000..85538f700 --- /dev/null +++ b/app/features/idl/interactive-idl/ui/InstructionActivity.tsx @@ -0,0 +1,75 @@ +import { useExplorerLink } from '@entities/cluster'; +import { ProgramLogs, TxErrorStatus, TxSuccessStatus } from '@entities/program-logs'; +import { Card } from '@shared/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@shared/ui/tabs'; +import { ReactNode } from 'react'; + +import type { InstructionLogs } from '@/app/utils/program-logs'; + +import type { InstructionInvocationResult } from '../model/use-instruction'; + +type InstructionActivityProps = { + lastResult?: InstructionInvocationResult; + logs: string[]; + parseLogs: (logs: string[]) => InstructionLogs[]; +}; +export function InstructionActivity({ lastResult, logs, parseLogs }: InstructionActivityProps) { + const tabs = [ + { + component: ( + } + logs={logs} + parseLogs={parseLogs} + /> + ), + id: 'program-logs', + title: 'Program logs', + }, + ]; + return ; +} + +function CardWithTabs({ tabs }: { tabs: { id: string; title: string; component: ReactNode }[] }) { + return ( + + +
+ + {tabs.map(tab => ( + + {tab.title} + + ))} + +
+ {tabs.map(tab => ( + + {tab.component} + + ))} +
+
+ ); +} + +function TxStatusHeader({ lastResult }: { lastResult: NonNullable }) { + const { link } = useExplorerLink( + lastResult.status === 'success' + ? `/tx/${lastResult.signature}` + : `/tx/inspector?message=${encodeURIComponent(lastResult.serializedTxMessage ?? '')}` + ); + return lastResult.status === 'success' ? ( + + ) : ( + + ); +} diff --git a/app/features/idl/interactive-idl/ui/InteractWithIdl.tsx b/app/features/idl/interactive-idl/ui/InteractWithIdl.tsx new file mode 100644 index 000000000..b8c004719 --- /dev/null +++ b/app/features/idl/interactive-idl/ui/InteractWithIdl.tsx @@ -0,0 +1,116 @@ +import { LoadingCard } from '@components/shared/LoadingCard'; +import type { InstructionData, SupportedIdl } from '@entities/idl'; +import { useToast } from '@shared/ui/sonner/use-toast'; +import { useWallet } from '@solana/wallet-adapter-react'; +import { useAtomValue } from 'jotai'; +import { useCallback } from 'react'; + +import { ExplorerLink } from '@/app/entities/cluster'; + +import { originalIdlAtom, programIdAtom } from '../model/state-atoms'; +import { isEnabled, useInstruction } from '../model/use-instruction'; +import type { InstructionCallParams } from '../model/use-instruction-form'; +import { useMainnetConfirmation } from '../model/use-mainnet-confirmation'; +import { BaseWarningCard } from './BaseWarningCard'; +import { InteractWithIdlView } from './InteractWithIdlView'; +import { MainnetWarningDialog } from './MainnetWarningDialog'; + +export function InteractWithIdl({ + data: instructions, +}: { + data?: InstructionData[]; + onClusterSelect?: () => void; + onWalletConnect?: () => void; + onSendTransaction?: (instruction: string, data: unknown) => void; +}) { + const toast = useToast(); + const idl = useAtomValue(originalIdlAtom); + const progId = useAtomValue(programIdAtom); + const { connected, publicKey } = useWallet(); + const { invokeInstruction, initializationError, isExecuting, lastResult, parseLogs, preInvocationError } = + useInstruction({ + enabled: isEnabled({ connected, idl, programId: progId, publicKey }), + idl, + programId: progId?.toString(), + }); + + const { requireConfirmation, confirm, cancel, isOpen, hasPendingAction } = useMainnetConfirmation<{ + data: InstructionData; + params: InstructionCallParams; + }>(); + + const handleExecuteInstruction = useCallback( + async (data: InstructionData, params: InstructionCallParams) => { + await requireConfirmation( + async () => { + await invokeInstruction(data.name, data, params); + }, + { data, params } + ); + }, + [invokeInstruction, requireConfirmation] + ); + + const handleTransactionSuccess = useCallback( + (txSignature: string) => { + toast.custom({ + description: ( + + ), + title: 'Transaction is sent', + type: 'success', + }); + }, + [toast] + ); + + const handleTransactionError = useCallback( + (error: string) => { + toast.custom({ description: error, title: 'Transaction Failed', type: 'error' }); + }, + [toast] + ); + + if (initializationError) { + return ( + + ); + } + + return !(idl && progId) ? ( + + ) : ( + <> + + {hasPendingAction && ( + { + if (!open) { + cancel(); + } + }} + onConfirm={confirm} + onCancel={cancel} + /> + )} + + ); +} diff --git a/app/features/idl/interactive-idl/ui/InteractWithIdlView.tsx b/app/features/idl/interactive-idl/ui/InteractWithIdlView.tsx new file mode 100644 index 000000000..176ee4dca --- /dev/null +++ b/app/features/idl/interactive-idl/ui/InteractWithIdlView.tsx @@ -0,0 +1,117 @@ +import { getIdlSpec, getIdlVersion, type InstructionData, type SupportedIdl } from '@entities/idl'; +import { useLayoutEffect, useState } from 'react'; + +import { Label } from '@/app/components/shared/ui/label'; +import { Switch } from '@/app/components/shared/ui/switch'; +import type { InstructionLogs } from '@/app/utils/program-logs'; + +import type { InstructionInvocationResult } from '../model/use-instruction'; +import type { InstructionCallParams } from '../model/use-instruction-form'; +import { ClusterSelector } from './ClusterSelector'; +import { ConnectWallet } from './ConnectWallet'; +import { InstructionActivity } from './InstructionActivity'; +import { InteractInstructions } from './InteractInstructions'; + +export function InteractWithIdlView({ + instructions, + idl, + onExecuteInstruction, + onTransactionSuccess, + onTransactionError, + preInvocationError, + parseLogs, + isExecuting, + lastResult, +}: { + instructions: InstructionData[]; + idl: SupportedIdl | undefined; + onExecuteInstruction: (data: InstructionData, params: InstructionCallParams) => Promise; + onTransactionSuccess?: (txSignature: string) => void; + onTransactionError?: (error: string) => void; + parseLogs: (logs: string[]) => InstructionLogs[]; + isExecuting?: boolean; + lastResult: InstructionInvocationResult; + preInvocationError: string | null; +}) { + const [expandedSections, setExpandedSections] = useState([]); + + const allInstructionNames = instructions.map(instruction => instruction.name); + + const areAllExpanded = + expandedSections.length === allInstructionNames.length && + allInstructionNames.every(name => expandedSections.includes(name)); + + // Handle success state + useLayoutEffect(() => { + if (lastResult?.status === 'success' && !isExecuting) { + onTransactionSuccess?.(lastResult.signature); + } + }, [lastResult, isExecuting, onTransactionSuccess]); + + // Handle error state + useLayoutEffect(() => { + if (lastResult?.status === 'error' && !isExecuting) { + onTransactionError?.(lastResult.message); + } + }, [lastResult, isExecuting, onTransactionError]); + + useLayoutEffect(() => { + if (preInvocationError && !isExecuting) { + onTransactionError?.(preInvocationError); + } + }, [preInvocationError, isExecuting, onTransactionError]); + + const handleExpandAllToggle = (checked: boolean) => { + const sections = checked ? allInstructionNames : []; + setExpandedSections(sections); + }; + + return ( +
+ {/* Main Grid Layout - responsive */} +
+ {/* Interact Header */} +
+

+ Anchor{idl ? `: ${getIdlVersion(idl)}` : ''} + {idl && getIdlSpec(idl) ? ` (spec: ${getIdlSpec(idl)})` : ''} +

+
+ + +
+
+ + {/* Left Column - Instructions */} +
+ +
+ + {/* Right Column - Controls & Logs */} +
+
+
+ + + + + +
+
+
+
+
+ ); +} diff --git a/app/features/idl/model/use-tabs.tsx b/app/features/idl/model/use-tabs.tsx index 21c482e14..d799d3fa7 100644 --- a/app/features/idl/model/use-tabs.tsx +++ b/app/features/idl/model/use-tabs.tsx @@ -1,7 +1,11 @@ 'use client'; -import type { FormattedIdl } from '@entities/idl'; +import { FormattedIdl, getIdlSpec, isInteractiveIdlSupported, type SupportedIdl } from '@entities/idl'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@shared/ui/tooltip'; +import { cn } from '@shared/utils'; +import { isEnvEnabled } from '@utils/env'; import React, { useMemo } from 'react'; +import { PlayCircle, XCircle } from 'react-feather'; import { BaseIdlAccounts } from '../formatted-idl/ui/BaseIdlAccounts'; import { BaseIdlConstants } from '../formatted-idl/ui/BaseIdlConstants'; @@ -11,6 +15,10 @@ import { BaseIdlInstructions } from '../formatted-idl/ui/BaseIdlInstructions'; import { BaseIdlPdas } from '../formatted-idl/ui/BaseIdlPdas'; import { BaseIdlTypes } from '../formatted-idl/ui/BaseIdlTypes'; import type { FormattedIdlDataView, IdlDataKeys } from '../formatted-idl/ui/types'; +import { BaseWarningCard } from '../interactive-idl/ui/BaseWarningCard'; +import { InteractWithIdl } from '../interactive-idl/ui/InteractWithIdl'; + +const IS_INTERACTIVE_IDL_ENABLED = isEnvEnabled(process.env.NEXT_PUBLIC_INTERACTIVE_IDL_ENABLED); type TabId = 'instructions' | 'accounts' | 'types' | 'errors' | 'constants' | 'events' | 'pdas'; @@ -21,9 +29,16 @@ export type DataTab = { render: () => React.ReactElement>; }; -type Tab = DataTab; +export type InteractTab = { + id: 'interact'; + title: string | React.ReactNode; + disabled: boolean; + render: () => ReturnType; +}; + +type Tab = DataTab | InteractTab; -export function useTabs(idl: FormattedIdl | null, searchStr?: string) { +export function useTabs(idl: FormattedIdl | null, originalIdl: SupportedIdl, searchStr?: string) { const tabs: Tab[] = useMemo(() => { if (!idl) return []; @@ -31,14 +46,14 @@ export function useTabs(idl: FormattedIdl | null, searchStr?: string) { const createTabRenderer = ( Component: React.ComponentType>, - data: unknown[] | undefined, + data: FormattedIdl[K] | undefined, tabName: string ) => { const TabRenderer = () => { if (hasSearch && (!data || data.length === 0)) { return ; } - return ; + return ; }; TabRenderer.displayName = `TabRenderer(${tabName})`; return TabRenderer; @@ -89,8 +104,25 @@ export function useTabs(idl: FormattedIdl | null, searchStr?: string) { }, ]; + // Only show interactive tab for Anchor IDLs (getIdlSpec returns null for legacy and codama) + if (originalIdl && getIdlSpec(originalIdl) !== null && IS_INTERACTIVE_IDL_ENABLED) { + const isInteractDisabled = !isInteractiveIdlSupported(originalIdl); + + tabItems.push({ + disabled: !idl.instructions?.length, + id: 'interact', + render: () => + isInteractDisabled ? ( + + ) : ( + + ), + title: , + } as InteractTab); + } + return tabItems; - }, [idl, searchStr]); + }, [idl, originalIdl, searchStr]); return tabs; } @@ -121,3 +153,33 @@ function NoSearchResultsPlaceholder({ tabName }: { tabName: string }) { ); } + +function InteractWithIdlTabName({ isInteractDisabled }: { isInteractDisabled: boolean }) { + const tab = ( +
+ {isInteractDisabled ? : } + Interact +
+ ); + + return ( + + +
+ {tab} +
+
+ +
+ {isInteractDisabled + ? 'Currently we support only modern Anchor IDL >= 0.30.1' + : "Launch Anchor's instructions"} +
+
+
+ ); +} diff --git a/app/features/idl/ui/IdlCard.tsx b/app/features/idl/ui/IdlCard.tsx index 460568df2..1e2b50bd7 100644 --- a/app/features/idl/ui/IdlCard.tsx +++ b/app/features/idl/ui/IdlCard.tsx @@ -1,4 +1,5 @@ -import { getIdlVersion, useAnchorProgram } from '@entities/idl'; +'use client'; +import { getIdlVersion, type SupportedIdl, useAnchorProgram } from '@entities/idl'; import { useProgramMetadataIdl } from '@entities/program-metadata'; import { useCluster } from '@providers/cluster'; import { Badge } from '@shared/ui/badge'; @@ -10,7 +11,7 @@ import { IdlSection } from './IdlSection'; type IdlTab = { id: IdlVariant; - idl: any; + idl: SupportedIdl; title: string; badge: string; }; diff --git a/app/features/idl/ui/IdlRenderer.tsx b/app/features/idl/ui/IdlRenderer.tsx index 83fb41e3a..7906403da 100644 --- a/app/features/idl/ui/IdlRenderer.tsx +++ b/app/features/idl/ui/IdlRenderer.tsx @@ -1,10 +1,14 @@ -import { getDisplayIdlSpecType } from '@entities/idl'; -import { memo } from 'react'; +import { type AnchorIdl, CodamaIdl, getDisplayIdlSpecType, type SupportedIdl } from '@entities/idl'; +import { PublicKey } from '@solana/web3.js'; +import { useSetAtom } from 'jotai'; +import { memo, useEffect } from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import ReactJson from 'react-json-view'; import { AnchorFormattedIdl } from '../formatted-idl/ui/AnchorFormattedIdl'; import { CodamaFormattedIdl } from '../formatted-idl/ui/CodamaFormattedIdl'; +import { originalIdlAtom, programIdAtom } from '../interactive-idl/model/state-atoms'; +import type { BaseIdl } from '../interactive-idl/model/unified-program'; export function IdlRenderer({ idl, @@ -13,12 +17,20 @@ export function IdlRenderer({ searchStr = '', programId, }: { - idl: any; + idl: SupportedIdl; collapsed: boolean | number; raw: boolean; searchStr: string; programId: string; }) { + const setOriginalIdl = useSetAtom(originalIdlAtom); + const setProgramId = useSetAtom(programIdAtom); + + useEffect(() => { + setOriginalIdl(idl as BaseIdl); + setProgramId(new PublicKey(programId)); + }, [idl, programId, setOriginalIdl, setProgramId]); + if (raw) { return ; } @@ -28,7 +40,7 @@ export function IdlRenderer({ case 'codama': return ( }> - + ); default: @@ -38,7 +50,7 @@ export function IdlRenderer({
{`Note: Shank IDLs are not fully supported. Unused types may be absent from detailed view.`}
) : null} - + ); } @@ -53,7 +65,7 @@ function IdlErrorFallback({ message, ...props }: { message: string }) { ); } -const IdlJson = memo(({ idl, collapsed }: { idl: any; collapsed: boolean | number }) => { +const IdlJson = memo(({ idl, collapsed }: { idl: SupportedIdl; collapsed: boolean | number }) => { return ( { - onSearchChange(str); - }; - const idlBase64 = useMemo(() => { return Buffer.from(JSON.stringify(idl, null, 2)).toString('base64'); }, [idl]); @@ -57,7 +53,7 @@ export function IdlSection({ variant="dark" className="e-pl-9" value={searchStr} - onChange={e => onSearchIdl(e.target.value)} + onChange={e => onSearchChange(e.target.value)} /> )} @@ -80,13 +76,15 @@ export function IdlSection({
- + + +
); diff --git a/app/features/idl/ui/__tests__/IdlCard.spec.tsx b/app/features/idl/ui/__tests__/IdlCard.spec.tsx index 9e3511d6d..4f2980898 100644 --- a/app/features/idl/ui/__tests__/IdlCard.spec.tsx +++ b/app/features/idl/ui/__tests__/IdlCard.spec.tsx @@ -17,6 +17,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('@solana/kit', () => ({ + address: vi.fn((addr: string) => addr), createSolanaRpc: vi.fn(() => ({ getEpochInfo: vi.fn(() => ({ send: vi.fn().mockResolvedValue({ diff --git a/app/layout.tsx b/app/layout.tsx index be817b348..3ee05567c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { MessageBanner } from '@components/MessageBanner'; import { Navbar } from '@components/Navbar'; import { ClusterProvider } from '@providers/cluster'; import { ScrollAnchorProvider } from '@providers/scroll-anchor'; +import { Toaster } from '@shared/ui/sonner/toaster'; import type { Viewport } from 'next'; import dynamic from 'next/dynamic'; import { Rubik } from 'next/font/google'; @@ -64,6 +65,7 @@ export default function RootLayout({ {children} + {analytics} diff --git a/app/providers/__tests__/wallet-provider.spec.tsx b/app/providers/__tests__/wallet-provider.spec.tsx index c94c077f7..faeb16a09 100644 --- a/app/providers/__tests__/wallet-provider.spec.tsx +++ b/app/providers/__tests__/wallet-provider.spec.tsx @@ -35,9 +35,15 @@ vi.mock('@solana/wallet-adapter-react-ui', () => ({ describe('WalletProvider', () => { beforeEach(() => { vi.clearAllMocks(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'error').mockImplementation(() => {}); capturedOnError = undefined; }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should show toast when skipToast is false (default)', () => { render( @@ -45,8 +51,11 @@ describe('WalletProvider', () => { ); - capturedOnError?.(new WalletError('Test error message')); + const error = new WalletError('Test error message'); + capturedOnError?.(error); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith(error); expect(mockToastCustom).toHaveBeenCalledWith({ description: 'Test error message', title: 'Wallet Error', @@ -61,8 +70,11 @@ describe('WalletProvider', () => { ); - capturedOnError?.(new WalletError('Test error message')); + const error = new WalletError('Test error message'); + capturedOnError?.(error); + expect(console.error).toHaveBeenCalledTimes(1); + expect(console.error).toHaveBeenCalledWith(error); expect(mockToastCustom).not.toHaveBeenCalled(); }); }); diff --git a/app/utils/__tests__/anchor.test.tsx b/app/utils/__tests__/anchor.test.tsx index 8d7e7bc29..367824057 100644 --- a/app/utils/__tests__/anchor.test.tsx +++ b/app/utils/__tests__/anchor.test.tsx @@ -166,6 +166,15 @@ describe('anchor utilities - number overflow handling', () => { }); describe('error handling with proper table structure', () => { + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/no-empty-function + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should render proper table structure on error in mapIxArgsToRows', () => { const ixType: IdlInstruction = { accounts: [], @@ -186,6 +195,13 @@ describe('anchor utilities - number overflow handling', () => { ); + // Should have logged the error + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + 'Error while displaying IDL-based account data', + expect.any(Error) + ); + // Should have proper 3-column structure const cells = screen.getAllByRole('cell'); expect(cells.length).toBe(3); @@ -221,6 +237,13 @@ describe('anchor utilities - number overflow handling', () => { ); + // Should have logged the error + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + 'Error while displaying IDL-based account data', + expect.any(Error) + ); + // Should have proper 3-column structure const cells = screen.getAllByRole('cell'); expect(cells.length).toBe(3); @@ -256,10 +279,16 @@ describe('anchor utilities - number overflow handling', () => { ); + // Should have logged the error + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + 'Error while displaying IDL-based account data', + expect.any(Error) + ); + // Should render JSON viewer for non-struct types expect(screen.getByTestId('json-viewer')).toBeInTheDocument(); - // Should show error message in console (tested via the error being caught) const cells = screen.getAllByRole('cell'); expect(cells.length).toBe(3); expect(cells[0]).toHaveTextContent('someField'); @@ -286,6 +315,13 @@ describe('anchor utilities - number overflow handling', () => { ); + // Should have logged the error + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith( + 'Error while displaying IDL-based account data', + expect.any(Error) + ); + // Should render JSON viewer for type alias expect(screen.getByTestId('json-viewer')).toBeInTheDocument(); diff --git a/bench/BUILD.md b/bench/BUILD.md index 7c36c5ae4..5130b2999 100644 --- a/bench/BUILD.md +++ b/bench/BUILD.md @@ -1,48 +1,48 @@ -| Type | Route | Size | First Load JS | -| ------- | --------------------------------------------- | ------- | ------------- | -| Static | `/` | 77.6 kB | 1 MB | -| Static | `/_not-found` | 330 B | 156 kB | -| Dynamic | `/address/[address]` | 6.12 kB | 284 kB | -| Dynamic | `/address/[address]/anchor-account` | 6.35 kB | 985 kB | -| Dynamic | `/address/[address]/anchor-program` | 330 B | 156 kB | -| Dynamic | `/address/[address]/attestation` | 5.78 kB | 967 kB | -| Dynamic | `/address/[address]/attributes` | 2.49 kB | 926 kB | -| Dynamic | `/address/[address]/blockhashes` | 1.88 kB | 921 kB | -| Dynamic | `/address/[address]/compression` | 4.05 kB | 951 kB | -| Dynamic | `/address/[address]/concurrent-merkle-tree` | 2.93 kB | 945 kB | -| Dynamic | `/address/[address]/domains` | 13.6 kB | 926 kB | -| Dynamic | `/address/[address]/entries` | 2.88 kB | 932 kB | -| Dynamic | `/address/[address]/feature-gate` | 330 B | 156 kB | -| Dynamic | `/address/[address]/idl` | 52.3 kB | 465 kB | -| Dynamic | `/address/[address]/instructions` | 2.12 kB | 1.02 MB | -| Dynamic | `/address/[address]/metadata` | 3.85 kB | 940 kB | -| Dynamic | `/address/[address]/nftoken-collection-nfts` | 5.76 kB | 965 kB | -| Dynamic | `/address/[address]/program-multisig` | 3.56 kB | 987 kB | -| Dynamic | `/address/[address]/rewards` | 3.47 kB | 925 kB | -| Dynamic | `/address/[address]/security` | 8.06 kB | 1 MB | -| Dynamic | `/address/[address]/slot-hashes` | 3.38 kB | 925 kB | -| Dynamic | `/address/[address]/stake-history` | 3.51 kB | 925 kB | -| Dynamic | `/address/[address]/token-extensions` | 9.79 kB | 982 kB | -| Dynamic | `/address/[address]/tokens` | 8.18 kB | 1.12 MB | -| Dynamic | `/address/[address]/transfers` | 4.01 kB | 1.05 MB | -| Dynamic | `/address/[address]/verified-build` | 6.08 kB | 990 kB | -| Dynamic | `/address/[address]/vote-history` | 3.39 kB | 925 kB | -| Dynamic | `/api/anchor` | 0 B | 0 B | -| Dynamic | `/api/domain-info/[domain]` | 0 B | 0 B | -| Dynamic | `/api/metadata/proxy` | 0 B | 0 B | -| Dynamic | `/api/ping/[network]` | 0 B | 0 B | -| Dynamic | `/api/programMetadataIdl` | 0 B | 0 B | -| Dynamic | `/api/verified-programs/list/[page]` | 0 B | 0 B | -| Dynamic | `/api/verified-programs/metadata/[programId]` | 0 B | 0 B | -| Dynamic | `/block/[slot]` | 9.99 kB | 936 kB | -| Dynamic | `/block/[slot]/accounts` | 4.36 kB | 916 kB | -| Dynamic | `/block/[slot]/programs` | 4.95 kB | 917 kB | -| Dynamic | `/block/[slot]/rewards` | 4.88 kB | 922 kB | -| Dynamic | `/epoch/[epoch]` | 6.69 kB | 257 kB | -| Static | `/feature-gates` | 3.25 kB | 922 kB | -| Static | `/opengraph-image.png` | 0 B | 0 B | -| Static | `/supply` | 6.39 kB | 923 kB | -| Dynamic | `/tx/[signature]` | 37.6 kB | 1.31 MB | -| Dynamic | `/tx/[signature]/inspect` | 399 B | 1.11 MB | -| Static | `/tx/inspector` | 399 B | 1.11 MB | -| Static | `/verified-programs` | 6.02 kB | 165 kB | +| Type | Route | Size | First Load JS | +|------|-------|------|---------------| +| Static | `/` | 15.6 kB | 1.01 MB | +| Static | `/_not-found` | 324 B | 157 kB | +| Dynamic | `/address/[address]` | 6.44 kB | 284 kB | +| Dynamic | `/address/[address]/anchor-account` | 5.76 kB | 987 kB | +| Dynamic | `/address/[address]/anchor-program` | 324 B | 157 kB | +| Dynamic | `/address/[address]/attestation` | 5.78 kB | 968 kB | +| Dynamic | `/address/[address]/attributes` | 2.49 kB | 927 kB | +| Dynamic | `/address/[address]/blockhashes` | 1.87 kB | 921 kB | +| Dynamic | `/address/[address]/compression` | 4.74 kB | 954 kB | +| Dynamic | `/address/[address]/concurrent-merkle-tree` | 3.63 kB | 948 kB | +| Dynamic | `/address/[address]/domains` | 13.7 kB | 928 kB | +| Dynamic | `/address/[address]/entries` | 2.97 kB | 934 kB | +| Dynamic | `/address/[address]/feature-gate` | 325 B | 157 kB | +| Dynamic | `/address/[address]/idl` | 120 kB | 567 kB | +| Dynamic | `/address/[address]/instructions` | 2.11 kB | 1.03 MB | +| Dynamic | `/address/[address]/metadata` | 3.89 kB | 941 kB | +| Dynamic | `/address/[address]/nftoken-collection-nfts` | 5.85 kB | 967 kB | +| Dynamic | `/address/[address]/program-multisig` | 3.39 kB | 990 kB | +| Dynamic | `/address/[address]/rewards` | 3.47 kB | 925 kB | +| Dynamic | `/address/[address]/security` | 8.12 kB | 1.01 MB | +| Dynamic | `/address/[address]/slot-hashes` | 3.37 kB | 925 kB | +| Dynamic | `/address/[address]/stake-history` | 3.52 kB | 925 kB | +| Dynamic | `/address/[address]/token-extensions` | 8.47 kB | 986 kB | +| Dynamic | `/address/[address]/tokens` | 7.92 kB | 1.12 MB | +| Dynamic | `/address/[address]/transfers` | 3.48 kB | 1.06 MB | +| Dynamic | `/address/[address]/verified-build` | 5.94 kB | 992 kB | +| Dynamic | `/address/[address]/vote-history` | 3.39 kB | 925 kB | +| Dynamic | `/api/anchor` | 0 B | 0 B | +| Dynamic | `/api/domain-info/[domain]` | 0 B | 0 B | +| Dynamic | `/api/metadata/proxy` | 0 B | 0 B | +| Dynamic | `/api/ping/[network]` | 0 B | 0 B | +| Dynamic | `/api/programMetadataIdl` | 0 B | 0 B | +| Dynamic | `/api/verified-programs/list/[page]` | 0 B | 0 B | +| Dynamic | `/api/verified-programs/metadata/[programId]` | 0 B | 0 B | +| Dynamic | `/block/[slot]` | 10.3 kB | 938 kB | +| Dynamic | `/block/[slot]/accounts` | 4.45 kB | 918 kB | +| Dynamic | `/block/[slot]/programs` | 5.04 kB | 919 kB | +| Dynamic | `/block/[slot]/rewards` | 4.97 kB | 924 kB | +| Dynamic | `/epoch/[epoch]` | 6.78 kB | 257 kB | +| Static | `/feature-gates` | 3.34 kB | 924 kB | +| Static | `/opengraph-image.png` | 0 B | 0 B | +| Static | `/supply` | 6.47 kB | 925 kB | +| Dynamic | `/tx/[signature]` | 37.4 kB | 1.39 MB | +| Dynamic | `/tx/[signature]/inspect` | 408 B | 1.19 MB | +| Static | `/tx/inspector` | 408 B | 1.19 MB | +| Static | `/verified-programs` | 6.1 kB | 165 kB | \ No newline at end of file diff --git a/package.json b/package.json index 8b8a39d83..505bc708c 100644 --- a/package.json +++ b/package.json @@ -34,15 +34,15 @@ "@onsol/tldparser": "0.6.5", "@project-serum/serum": "0.13.61", "@radix-ui/react-accordion": "1.2.3", - "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-label": "2.1.8", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-switch": "1.2.6", - "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-tooltip": "1.1.8", "@react-hook/debounce": "4.0.0", "@react-hook/previous": "1.0.1", - "@sentry/core": "^10.17.0", + "@sentry/core": "10.17.0", "@sentry/nextjs": "10", "@solana-developers/helpers": "=2.8.1", "@solana-program/compute-budget": "0.6.1", @@ -80,7 +80,7 @@ "fuse.js": "7.1.0", "humanize-duration-ts": "2.1.1", "ipaddr.js": "2.2.0", - "jotai": "^2.15.1", + "jotai": "2.15.1", "lighthouse-sdk": "2.0.1", "micromatch": "4.0.8", "moment": "2.29.4", @@ -96,7 +96,7 @@ "react-dom": "18.3.1", "react-error-boundary": "4.0.11", "react-feather": "2.0.10", - "react-hook-form": "^7.66.0", + "react-hook-form": "7.66.0", "react-json-view": "1.21.3", "react-markdown": "10.1.0", "react-moment": "1.1.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b86950b68..e9e4e7832 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,7 +90,7 @@ importers: specifier: 1.2.3 version: 1.2.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': - specifier: ^1.1.15 + specifier: 1.1.15 version: 1.1.15(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-label': specifier: 2.1.8 @@ -102,7 +102,7 @@ importers: specifier: 1.2.6 version: 1.2.6(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tabs': - specifier: ^1.1.13 + specifier: 1.1.13 version: 1.1.13(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-tooltip': specifier: 1.1.8 @@ -114,7 +114,7 @@ importers: specifier: 1.0.1 version: 1.0.1(react@18.3.1) '@sentry/core': - specifier: ^10.17.0 + specifier: 10.17.0 version: 10.17.0 '@sentry/nextjs': specifier: '10' @@ -228,7 +228,7 @@ importers: specifier: 2.2.0 version: 2.2.0 jotai: - specifier: ^2.15.1 + specifier: 2.15.1 version: 2.15.1(@babel/core@7.28.4)(@babel/template@7.27.2)(@types/react@18.3.3)(react@18.3.1) lighthouse-sdk: specifier: 2.0.1 @@ -276,7 +276,7 @@ importers: specifier: 2.0.10 version: 2.0.10(react@18.3.1) react-hook-form: - specifier: ^7.66.0 + specifier: 7.66.0 version: 7.66.0(react@18.3.1) react-json-view: specifier: 1.21.3 @@ -1766,24 +1766,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@14.2.33': resolution: {integrity: sha512-Bm+QulsAItD/x6Ih8wGIMfRJy4G73tu1HJsrccPW6AfqdZd0Sfm5Imhgkgq2+kly065rYMnCOxTBvmvFY1BKfg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@14.2.33': resolution: {integrity: sha512-FnFn+ZBgsVMbGDsTqo8zsnRzydvsGV8vfiWwUo1LD8FTmPTdV+otGSWKc4LJec0oSexFnCYVO4hX8P8qQKaSlg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@14.2.33': resolution: {integrity: sha512-345tsIWMzoXaQndUTDv1qypDRiebFxGYx9pYkhwY4hBRaOLt8UGfiWKr9FSSHs25dFIf8ZqIFaPdy5MljdoawA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@14.2.33': resolution: {integrity: sha512-nscpt0G6UCTkrT2ppnJnFsYbPDQwmum4GNXYTeoTIdsmMydSKFz9Iny2jpaRupTb+Wl298+Rh82WKzt9LCcqSQ==} @@ -3183,56 +3187,67 @@ packages: resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.50.1': resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.50.1': resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.50.1': resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.50.1': resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.50.1': resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.50.1': resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.50.1': resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.50.1': resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.50.1': resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.50.1': resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openharmony-arm64@4.50.1': resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} @@ -7454,24 +7469,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.29.2: resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.29.2: resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.29.2: resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.29.2: resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==}