diff --git a/src/components/admin/OracleAccessPanel.tsx b/src/components/admin/OracleAccessPanel.tsx new file mode 100644 index 00000000..cda92956 --- /dev/null +++ b/src/components/admin/OracleAccessPanel.tsx @@ -0,0 +1,316 @@ +import { useState } from 'react'; +import { KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import Skeleton from '@/components/ui/skeleton'; +import { TruncatedText } from '@/components/ui/truncated-text'; +import InlineValidationMessage from '@/components/common/InlineValidationMessage'; +import { + normalizeStellarContractAddress, + getStellarContractAddressError, +} from '@/utils/stellarAddress.utils'; +import { + useAddOracleCaller, + useOracleCallers, + useRemoveOracleCaller, +} from '@/hooks/useOracleCallers'; +import { cn } from '@/lib/utils'; + +const CARD_CLASS = + 'rounded-[2rem] border border-white/10 bg-white/[0.02] p-6 shadow-2xl backdrop-blur-md md:p-8'; + +export default function OracleAccessPanel() { + const { + data: callers = [], + isLoading, + isError, + refetch, + } = useOracleCallers(); + const addCaller = useAddOracleCaller(); + const removeCaller = useRemoveOracleCaller(); + + const [address, setAddress] = useState(''); + const [showValidation, setShowValidation] = useState(false); + const [pendingRemoval, setPendingRemoval] = useState(null); + + const normalizedAddress = normalizeStellarContractAddress(address); + + const validationError = (() => { + const formatError = getStellarContractAddressError(address); + if (formatError) return formatError; + + const alreadyApproved = callers.some( + caller => + normalizeStellarContractAddress(caller.address) === + normalizedAddress + ); + if (alreadyApproved) return 'This caller is already approved'; + + return null; + })(); + + const isAddressValid = validationError === null && normalizedAddress !== ''; + const canAdd = isAddressValid && !addCaller.isPending; + const showValidationError = showValidation && validationError !== null; + + const handleAdd = () => { + if (!isAddressValid) { + setShowValidation(true); + return; + } + + setShowValidation(false); + addCaller.mutate(normalizedAddress, { + onSuccess: () => setAddress(''), + }); + }; + + const handleConfirmRemove = (callerAddress: string) => { + setPendingRemoval(callerAddress); + }; + + return ( +
+
+
+

+ Oracle Access +

+

+ Control which external contracts may call the AccessLayer + price oracle. +

+
+
+ +
+ +
{ + event.preventDefault(); + handleAdd(); + }} + className="flex flex-col gap-3 sm:flex-row" + > + { + setAddress(event.target.value); + if (address.trim() !== '') setShowValidation(true); + }} + className={cn( + 'h-12 min-w-0 flex-1 rounded-xl border bg-white/[0.03] px-4 font-mono text-sm text-white placeholder:text-white/25 outline-none transition-colors focus:border-amber-500/40 focus:ring-2 focus:ring-amber-500/20', + showValidationError + ? 'border-red-500/50' + : 'border-white/10' + )} + aria-invalid={showValidationError} + aria-describedby={ + showValidationError + ? 'oracle-caller-validation-error' + : undefined + } + /> + +
+ + {showValidationError && ( +
+ +
+ )} + +

+ Must be a valid Stellar contract address (56-character address + beginning with C). +

+
+ + {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + + ))} +
+ )} + + {!isLoading && isError && ( +
+

+ We couldn't load the approved callers. Try again. +

+ +
+ )} + + {!isLoading && !isError && callers.length === 0 && ( +
+
+
+

+ No callers are approved yet. Add the first contract address + above to grant oracle access. +

+
+ )} + + {!isLoading && !isError && callers.length > 0 && ( + + )} + + { + if (!open && !removeCaller.isPending) setPendingRemoval(null); + }} + > + + + + Remove approved caller? + + + This contract will no longer be able to call the + AccessLayer price oracle. The authorization is revoked + immediately. + + + +
+

+ {pendingRemoval} +

+
+ + + + + +
+
+
+ ); +} diff --git a/src/components/admin/__tests__/OracleAccessPanel.test.tsx b/src/components/admin/__tests__/OracleAccessPanel.test.tsx new file mode 100644 index 00000000..acb91873 --- /dev/null +++ b/src/components/admin/__tests__/OracleAccessPanel.test.tsx @@ -0,0 +1,242 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import OracleAccessPanel from '@/components/admin/OracleAccessPanel'; +import { + useAddOracleCaller, + useOracleCallers, + useRemoveOracleCaller, +} from '@/hooks/useOracleCallers'; + +vi.mock('@/hooks/useOracleCallers', () => ({ + useOracleCallers: vi.fn(), + useAddOracleCaller: vi.fn(), + useRemoveOracleCaller: vi.fn(), +})); + +const mockUseOracleCallers = vi.mocked(useOracleCallers); +const mockUseAddOracleCaller = vi.mocked(useAddOracleCaller); +const mockUseRemoveOracleCaller = vi.mocked(useRemoveOracleCaller); + +const CALLER_A = 'CAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7DRX'; +const CALLER_B = 'CD7757P47P5PT6HX6327J47S6HYO73XN5TV6V2PI47TOLZHD4LQ6ACUD'; +// A length-56, C-prefixed string that fails the checksum check. +const INVALID_ADDRESS = `${CALLER_A.slice(0, 55)}B`; + +interface SetupOptions { + callers?: Array<{ address: string; addedAt?: string }>; + isLoading?: boolean; + isError?: boolean; +} + +function setupHooks({ + callers = [], + isLoading = false, + isError = false, +}: SetupOptions = {}) { + const addMutate = vi.fn( + (_address: string, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.(); + } + ); + const removeMutate = vi.fn( + (_address: string, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.(); + } + ); + + mockUseOracleCallers.mockReturnValue({ + data: callers, + isLoading, + isError, + refetch: vi.fn(), + } as never); + mockUseAddOracleCaller.mockReturnValue({ + isPending: false, + mutate: addMutate, + } as never); + mockUseRemoveOracleCaller.mockReturnValue({ + isPending: false, + mutate: removeMutate, + } as never); + + return { addMutate, removeMutate }; +} + +describe('OracleAccessPanel (#829)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + it('lists each approved caller with a Remove button (acceptance #1)', async () => { + setupHooks({ callers: [{ address: CALLER_A }, { address: CALLER_B }] }); + + render(); + + expect(screen.getByTestId('oracle-callers-list')).toBeInTheDocument(); + expect( + screen.getByTestId(`oracle-caller-remove-${CALLER_A}`) + ).toBeInTheDocument(); + expect( + screen.getByTestId(`oracle-caller-remove-${CALLER_B}`) + ).toBeInTheDocument(); + expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength( + 2 + ); + }); + + it('shows an empty state when no callers are approved (acceptance #5)', () => { + setupHooks({ callers: [] }); + + render(); + + expect(screen.getByTestId('oracle-callers-empty')).toBeInTheDocument(); + expect( + screen.queryByTestId('oracle-callers-list') + ).not.toBeInTheDocument(); + }); + + it('shows skeleton rows while the callers list is loading', () => { + setupHooks({ isLoading: true }); + + render(); + + expect(screen.getByTestId('oracle-callers-loading')).toBeInTheDocument(); + expect( + screen.queryByTestId('oracle-callers-empty') + ).not.toBeInTheDocument(); + }); + + it('keeps the Add button disabled while the input is empty', () => { + setupHooks(); + + render(); + + expect(screen.getByTestId('oracle-caller-add')).toBeDisabled(); + }); + + it('shows a validation error and keeps Add disabled for an invalid contract address (acceptance #2)', async () => { + const user = userEvent.setup(); + const { addMutate } = setupHooks(); + + render(); + + await user.type( + screen.getByTestId('oracle-caller-input'), + INVALID_ADDRESS + ); + + expect( + screen.getByTestId('oracle-caller-validation-error') + ).toHaveTextContent(/valid Stellar contract address/i); + expect(screen.getByTestId('oracle-caller-add')).toBeDisabled(); + expect(addMutate).not.toHaveBeenCalled(); + }); + + it('clicks Add with an invalid address and surfaces the validation error', async () => { + const user = userEvent.setup(); + const { addMutate } = setupHooks(); + + render(); + + await user.type( + screen.getByTestId('oracle-caller-input'), + 'not-an-address' + ); + await user.click(screen.getByTestId('oracle-caller-add')); + + expect( + screen.getByTestId('oracle-caller-validation-error') + ).toBeInTheDocument(); + expect(addMutate).not.toHaveBeenCalled(); + }); + + it('submits a validated address, normalizes it, clears the input, and updates the list (acceptance #3)', async () => { + const user = userEvent.setup(); + const { addMutate } = setupHooks({ callers: [] }); + + render(); + + await user.type( + screen.getByTestId('oracle-caller-input'), + CALLER_A.toLowerCase() + ); + + expect(screen.getByTestId('oracle-caller-add')).toBeEnabled(); + + await user.click(screen.getByTestId('oracle-caller-add')); + + expect(addMutate).toHaveBeenCalledWith( + CALLER_A, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + expect(screen.getByTestId('oracle-caller-input')).toHaveValue(''); + }); + + it('blocks adding a caller that is already approved', async () => { + const user = userEvent.setup(); + const { addMutate } = setupHooks({ callers: [{ address: CALLER_A }] }); + + render(); + + await user.type(screen.getByTestId('oracle-caller-input'), CALLER_A); + + expect( + screen.getByTestId('oracle-caller-validation-error') + ).toHaveTextContent(/already approved/i); + expect(screen.getByTestId('oracle-caller-add')).toBeDisabled(); + expect(addMutate).not.toHaveBeenCalled(); + }); + + it('removes a caller only after confirmation (acceptance #4)', async () => { + const user = userEvent.setup(); + const { removeMutate } = setupHooks({ + callers: [{ address: CALLER_A }, { address: CALLER_B }], + }); + + render(); + + await user.click(screen.getByTestId(`oracle-caller-remove-${CALLER_A}`)); + + const dialog = screen.getByTestId('oracle-caller-remove-dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveTextContent(CALLER_A); + + await user.click(screen.getByTestId('oracle-caller-confirm-remove')); + + expect(removeMutate).toHaveBeenCalledWith( + CALLER_A, + expect.objectContaining({ onSuccess: expect.any(Function) }) + ); + await waitFor(() => { + expect( + screen.queryByTestId('oracle-caller-remove-dialog') + ).not.toBeInTheDocument(); + }); + }); + + it('cancels the confirmation dialog without removing the caller', async () => { + const user = userEvent.setup(); + const { removeMutate } = setupHooks({ callers: [{ address: CALLER_A }] }); + + render(); + + await user.click(screen.getByTestId(`oracle-caller-remove-${CALLER_A}`)); + expect( + screen.getByTestId('oracle-caller-remove-dialog') + ).toBeInTheDocument(); + + await user.click(screen.getByTestId('oracle-caller-cancel-remove')); + + await waitFor(() => { + expect( + screen.queryByTestId('oracle-caller-remove-dialog') + ).not.toBeInTheDocument(); + }); + expect(removeMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/useOracleCallers.ts b/src/hooks/useOracleCallers.ts new file mode 100644 index 00000000..a6b4366e --- /dev/null +++ b/src/hooks/useOracleCallers.ts @@ -0,0 +1,53 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { adminService } from '@/services/admin.service'; +import showToast from '@/utils/toast.util'; + +function errorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : 'Something went wrong. Please try again.'; +} + +export function useOracleCallers() { + return useQuery({ + queryKey: queryKeys.admin.oracleCallers(), + queryFn: () => adminService.getOracleCallers(), + }); +} + +export function useAddOracleCaller() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['admin', 'oracle', 'callers', 'add'], + mutationFn: (address: string) => adminService.addOracleCaller(address), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.admin.oracleCallers(), + }); + showToast.success('Caller approved'); + }, + onError: (error: unknown) => { + showToast.error(errorMessage(error)); + }, + }); +} + +export function useRemoveOracleCaller() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['admin', 'oracle', 'callers', 'remove'], + mutationFn: (address: string) => adminService.removeOracleCaller(address), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.admin.oracleCallers(), + }); + showToast.success('Caller removed'); + }, + onError: (error: unknown) => { + showToast.error(errorMessage(error)); + }, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index 43788ba9..088b21dd 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -38,4 +38,7 @@ export const queryKeys = { all: () => ['leaderboard'] as const, volume: () => ['leaderboard', 'volume'] as const, }, + admin: { + oracleCallers: () => ['admin', 'oracle', 'callers'] as const, + }, } as const; diff --git a/src/pages/AdminDashboardPage.tsx b/src/pages/AdminDashboardPage.tsx new file mode 100644 index 00000000..afc6cf4f --- /dev/null +++ b/src/pages/AdminDashboardPage.tsx @@ -0,0 +1,23 @@ +import OracleAccessPanel from '@/components/admin/OracleAccessPanel'; +import { useNavigationTiming } from '@/hooks/useNavigationTiming'; + +export default function AdminDashboardPage() { + useNavigationTiming('admin-dashboard'); + + return ( +
+
+
+

+ Admin dashboard +

+

+ Manage protocol integrations and access control. +

+
+ + +
+
+ ); +} diff --git a/src/routes.tsx b/src/routes.tsx index 190100d1..395c43df 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,5 +1,6 @@ import HomePage from './pages/HomePage'; import NotFoundPage from './pages/NotFoundPage'; +import AdminDashboardPage from './pages/AdminDashboardPage'; import CreatorDetailPage from './pages/CreatorDetailPage'; import CreatorDashboardPage from './pages/CreatorDashboardPage'; import NotificationsPage from './pages/NotificationsPage'; @@ -48,6 +49,10 @@ export const routes = [ path: '/following', element: , }, + { + path: '/admin/dashboard', + element: , + }, { path: '*', element: , diff --git a/src/services/__tests__/admin.service.test.ts b/src/services/__tests__/admin.service.test.ts new file mode 100644 index 00000000..b6f4b6a8 --- /dev/null +++ b/src/services/__tests__/admin.service.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { ApiError } from '../api.service'; + +const { mockGet, mockPost, mockDelete } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), + mockDelete: vi.fn(), +})); + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + post: mockPost, + delete: mockDelete, + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + isAxiosError: (err: unknown): boolean => + err !== null && + typeof err === 'object' && + (err as Record).isAxiosError === true, + }, +})); + +import { + adminService, + createOracleCaller, + deleteOracleCaller, +} from '../admin.service'; + +const CALLER_A = 'CAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7DRX'; +const CALLER_B = 'CD7757P47P5PT6HX6327J47S6HYO73XN5TV6V2PI47TOLZHD4LQ6ACUD'; + +function fakeApiResponse(data: T) { + return { + data: { success: true, data, message: 'ok' }, + }; +} + +function fakeApiError(status: number, message: string) { + return { + isAxiosError: true as const, + response: { status, data: { success: false, message } }, + config: {}, + message, + }; +} + +describe('adminService oracle callers (#829)', () => { + beforeEach(() => { + mockGet.mockReset(); + mockPost.mockReset(); + mockDelete.mockReset(); + }); + + describe('getOracleCallers', () => { + it('returns caller objects from an array of { address } entries', async () => { + mockGet.mockResolvedValueOnce( + fakeApiResponse([ + { address: CALLER_A, addedAt: '2026-08-28T00:00:00Z' }, + { address: CALLER_B }, + ]) + ); + + const callers = await adminService.getOracleCallers(); + + expect(callers).toEqual([ + { address: CALLER_A, addedAt: '2026-08-28T00:00:00Z' }, + { address: CALLER_B }, + ]); + expect(mockGet).toHaveBeenCalledWith('/admin/oracle/callers'); + }); + + it('normalizes plain-string caller arrays', async () => { + mockGet.mockResolvedValueOnce(fakeApiResponse([CALLER_A, CALLER_B])); + + const callers = await adminService.getOracleCallers(); + + expect(callers).toEqual([ + { address: CALLER_A }, + { address: CALLER_B }, + ]); + }); + + it('reads callers nested under a callers envelope', async () => { + mockGet.mockResolvedValueOnce( + fakeApiResponse({ callers: [{ address: CALLER_A }] }) + ); + + const callers = await adminService.getOracleCallers(); + + expect(callers).toEqual([{ address: CALLER_A }]); + }); + + it('filters out invalid entries and returns an empty list for an empty payload', async () => { + mockGet.mockResolvedValueOnce( + fakeApiResponse([null, { noKey: true }]) + ); + + const callers = await adminService.getOracleCallers(); + + expect(callers).toEqual([]); + }); + + it('throws ApiError(401) when authorization is rejected', async () => { + mockGet.mockRejectedValueOnce(fakeApiError(401, 'Not authorized')); + + const err = await adminService + .getOracleCallers() + .catch((e: unknown) => e); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(401); + }); + }); + + describe('addOracleCaller', () => { + it('POSTs the contract address to /admin/oracle/callers', async () => { + mockPost.mockResolvedValueOnce(fakeApiResponse({ address: CALLER_A })); + + const caller = await createOracleCaller(CALLER_A); + + expect(mockPost).toHaveBeenCalledWith('/admin/oracle/callers', { + address: CALLER_A, + }); + expect(caller).toEqual({ address: CALLER_A }); + }); + + it('falls back to the submitted address when the response has no body data', async () => { + mockPost.mockResolvedValueOnce(fakeApiResponse(null)); + + const caller = await createOracleCaller(CALLER_A); + + expect(caller).toEqual({ address: CALLER_A }); + }); + + it('throws ApiError(400) when the server rejects the address', async () => { + mockPost.mockRejectedValueOnce( + fakeApiError(400, 'Invalid contract address') + ); + + const err = await createOracleCaller(CALLER_A).catch( + (e: unknown) => e + ); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(400); + }); + }); + + describe('removeOracleCaller', () => { + it('DELETEs the caller address with the address in the path', async () => { + mockDelete.mockResolvedValueOnce(fakeApiResponse(null)); + + await deleteOracleCaller(CALLER_A); + + expect(mockDelete).toHaveBeenCalledWith( + `/admin/oracle/callers/${encodeURIComponent(CALLER_A)}` + ); + }); + + it('throws ApiError(404) when the caller is already removed', async () => { + mockDelete.mockRejectedValueOnce( + fakeApiError(404, 'Caller not found') + ); + + const err = await deleteOracleCaller(CALLER_B).catch( + (e: unknown) => e + ); + + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(404); + }); + }); +}); diff --git a/src/services/admin.service.ts b/src/services/admin.service.ts new file mode 100644 index 00000000..bf681d55 --- /dev/null +++ b/src/services/admin.service.ts @@ -0,0 +1,118 @@ +// src/services/admin.service.ts +import { BaseApiService, type APIResponse } from './api.service'; + +/** + * A single approved contract address permitted to call the price oracle. + */ +export interface OracleCaller { + address: string; + /** ISO timestamp recorded by the server when the caller was approved. */ + addedAt?: string; +} + +/** Raw server shapes the callers endpoint may return, normalised on read. */ +type OracleCallersResponse = + OracleCaller[] | string[] | { callers?: OracleCaller[] }; + +function toOracleCallers(raw: unknown): OracleCaller[] { + if (!Array.isArray(raw)) return []; + + return raw.reduce((callers, item) => { + if (typeof item === 'string') { + const address = item.trim(); + if (address) callers.push({ address }); + return callers; + } + + if ( + item && + typeof item === 'object' && + 'address' in item && + typeof (item as OracleCaller).address === 'string' && + (item as OracleCaller).address.trim() + ) { + callers.push({ + address: (item as OracleCaller).address.trim(), + addedAt: (item as OracleCaller).addedAt, + }); + } + + return callers; + }, []); +} + +class AdminService extends BaseApiService { + /** + * List the contract addresses currently approved to call the price + * oracle - GET /admin/oracle/callers. + */ + async getOracleCallers(): Promise { + try { + const response = await this.api.get< + APIResponse + >('/admin/oracle/callers'); + + const raw = response.data.data; + const candidates = + raw && typeof raw === 'object' && !Array.isArray(raw) + ? raw.callers + : raw; + + return toOracleCallers(candidates); + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Approve a new contract address to call the price oracle - + * POST /admin/oracle/callers. + */ + async addOracleCaller(address: string): Promise { + try { + const response = await this.api.post>( + '/admin/oracle/callers', + { address } + ); + + return response.data.data ?? { address }; + } catch (error) { + throw this.handleError(error); + } + } + + /** + * Revoke a previously approved contract address - + * DELETE /admin/oracle/callers/:address. + */ + async removeOracleCaller(address: string): Promise { + try { + await this.api.delete( + `/admin/oracle/callers/${encodeURIComponent(address)}` + ); + } catch (error) { + throw this.handleError(error); + } + } +} + +export const adminService = new AdminService(); + +/** + * Convenience wrappers exposing the service calls as plain functions so they + * can be swapped via `vi.spyOn` from component tests without needing to mock + * the service class instance itself. + */ +export async function fetchOracleCallers(): Promise { + return adminService.getOracleCallers(); +} + +export async function createOracleCaller( + address: string +): Promise { + return adminService.addOracleCaller(address); +} + +export async function deleteOracleCaller(address: string): Promise { + return adminService.removeOracleCaller(address); +} diff --git a/src/utils/__tests__/stellarAddress.utils.test.ts b/src/utils/__tests__/stellarAddress.utils.test.ts new file mode 100644 index 00000000..6de80d67 --- /dev/null +++ b/src/utils/__tests__/stellarAddress.utils.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; +import { + getStellarContractAddressError, + isValidStellarContractAddress, + normalizeStellarContractAddress, +} from '../stellarAddress.utils'; + +// Valid checksum-correct `C`-prefixed contract strkeys (56 chars): +// CAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7DRX +// CD7757P47P5PT6HX6327J47S6HYO73XN5TV6V2PI47TOLZHD4LQ6ACUD +const VALID_A = 'CAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB7DRX'; +const VALID_B = 'CD7757P47P5PT6HX6327J47S6HYO73XN5TV6V2PI47TOLZHD4LQ6ACUD'; + +describe('normalizeStellarContractAddress', () => { + it('trims surrounding whitespace', () => { + expect(normalizeStellarContractAddress(` ${VALID_A} `)).toBe(VALID_A); + }); + + it('upper-cases lowercase base32 input', () => { + expect(normalizeStellarContractAddress(VALID_A.toLowerCase())).toBe( + VALID_A + ); + }); +}); + +describe('isValidStellarContractAddress', () => { + it('accepts checksum-valid contract addresses', () => { + expect(isValidStellarContractAddress(VALID_A)).toBe(true); + expect(isValidStellarContractAddress(VALID_B)).toBe(true); + }); + + it('accepts lowercase contract addresses (base32 is case-insensitive)', () => { + expect(isValidStellarContractAddress(VALID_A.toLowerCase())).toBe(true); + }); + + it('rejects an address with a tampered checksum', () => { + const tampered = VALID_A.slice(0, -1) + 'B'; + expect(tampered).not.toBe(VALID_A); + expect(isValidStellarContractAddress(tampered)).toBe(false); + }); + + it('rejects values that do not start with the C version prefix', () => { + expect( + isValidStellarContractAddress( + 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5' + ) + ).toBe(false); + }); + + it('rejects too-short addresses', () => { + expect(isValidStellarContractAddress(VALID_A.slice(0, 20))).toBe(false); + }); + + it('rejects too-long addresses', () => { + expect(isValidStellarContractAddress(`${VALID_A}A`)).toBe(false); + }); + + it('rejects addresses containing non-base32 characters', () => { + const withIllegalChar = `C${'A'.repeat(53)}10`; + expect(withIllegalChar).toHaveLength(56); + expect(isValidStellarContractAddress(withIllegalChar)).toBe(false); + }); + + it('rejects empty and whitespace-only input', () => { + expect(isValidStellarContractAddress('')).toBe(false); + expect(isValidStellarContractAddress(' ')).toBe(false); + }); +}); + +describe('getStellarContractAddressError', () => { + it('returns null for a valid contract address', () => { + expect(getStellarContractAddressError(VALID_A)).toBeNull(); + }); + + it('asks for input when the value is empty', () => { + expect(getStellarContractAddressError('')).toBe( + 'Enter a Stellar contract address' + ); + }); + + it('reports an invalid format error for malformed values', () => { + const error = getStellarContractAddressError('not-an-address'); + expect(error).toMatch(/valid Stellar contract address/i); + }); +}); diff --git a/src/utils/stellarAddress.utils.ts b/src/utils/stellarAddress.utils.ts new file mode 100644 index 00000000..2e6fb8ca --- /dev/null +++ b/src/utils/stellarAddress.utils.ts @@ -0,0 +1,109 @@ +/** + * Stellar / Soroban strkey validation utilities. + * + * Contract addresses are base32 (RFC 4648, uppercase) encoded strkeys that + * start with the `C` version prefix. A `C`-prefixed value is 56 characters + * long and decodes to exactly 35 bytes: + * - 1 version byte (`0x10` for contracts), + * - 32 contract id bytes, + * - 2 CRC16-XModem checksum bytes. + * + * The checksum is validated so that every accepted value is guaranteed to be + * a well-formed, checksum-valid contract address rather than just one that + * happens to look like the right shape. + */ + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const CONTRACT_ADDRESS_LENGTH = 56; +const DECODED_BYTE_LENGTH = 35; + +/** Mirrors the on-disk contract-address regex documented above. */ +const CONTRACT_ADDRESS_PATTERN = /^C[A-Z2-7]{55}$/; + +function crc16XModem(bytes: Uint8Array): number { + let crc = 0x0000; + + for (const byte of bytes) { + crc ^= byte << 8; + for (let bit = 0; bit < 8; bit++) { + crc = + crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + + return crc & 0xffff; +} + +function base32Decode(value: string): Uint8Array | null { + const bytes: number[] = []; + let buffer = 0; + let bitsLeft = 0; + + for (const char of value) { + const digit = BASE32_ALPHABET.indexOf(char); + if (digit === -1) return null; + + buffer = (buffer << 5) | digit; + bitsLeft += 5; + + if (bitsLeft >= 8) { + bytes.push((buffer >>> (bitsLeft - 8)) & 0xff); + bitsLeft -= 8; + } + } + + // A 56-character, unpadded strkey decodes to whole bytes (56 * 5 = 280 + // bits). Any remainder means the input was malformed. + if (bitsLeft > 0) return null; + + return new Uint8Array(bytes); +} + +/** + * Normalises a raw input value to the canonical uppercase strkey form used + * when submitting, while keeping any input the user may have pasted. + */ +export function normalizeStellarContractAddress(value: string): string { + return value.trim().toUpperCase(); +} + +/** + * Returns `true` when the value is a checksum-valid Stellar contract strkey + * (56 characters, `C` prefix, valid base32 payload with a matching + * CRC16-XModem checksum). Accepts lowercase input and normalises it. + */ +export function isValidStellarContractAddress(value: string): boolean { + const normalized = normalizeStellarContractAddress(value); + + if ( + normalized.length !== CONTRACT_ADDRESS_LENGTH || + !CONTRACT_ADDRESS_PATTERN.test(normalized) + ) { + return false; + } + + const decoded = base32Decode(normalized); + if (!decoded || decoded.length !== DECODED_BYTE_LENGTH) return false; + + const payload = decoded.slice(0, decoded.length - 2); + const checksum = + (decoded[decoded.length - 2] << 8) | decoded[decoded.length - 1]; + + return crc16XModem(payload) === checksum; +} + +/** + * Returns a user-facing validation message for a contract address input, or + * `null` when the value (after normalization) is a valid contract address. + */ +export function getStellarContractAddressError(value: string): string | null { + if (!normalizeStellarContractAddress(value)) { + return 'Enter a Stellar contract address'; + } + + if (!isValidStellarContractAddress(value)) { + return 'Enter a valid Stellar contract address (56-character address starting with C)'; + } + + return null; +}