From 31bb57fb7905212d9ac39749092ce2fee38a0197 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 10:54:06 +0200 Subject: [PATCH 1/8] feat(flashnet): add @mbga/flashnet package with API-key auth New package providing Flashnet authentication for MBGA apps: - createFlashnetClient() with bearer token API-key auth - FlashnetProvider React context + useFlashnetClient hook - verifyAuth action to check auth status - useFlashnetAuth hook with TanStack Query integration - Typed error classes (FlashnetAuthError, FlashnetRequestError, etc.) - Full test coverage (37 tests) Co-authored-by: Claude Signed-off-by: Claude --- packages/flashnet/package.json | 98 +++++++ .../flashnet/src/actions/verifyAuth.test.ts | 90 +++++++ packages/flashnet/src/actions/verifyAuth.ts | 65 +++++ packages/flashnet/src/client.test.ts | 248 ++++++++++++++++++ packages/flashnet/src/client.ts | 111 ++++++++ packages/flashnet/src/context.test.tsx | 74 ++++++ packages/flashnet/src/context.ts | 61 +++++ packages/flashnet/src/errors.test.ts | 59 +++++ packages/flashnet/src/errors.ts | 62 +++++ packages/flashnet/src/exports/actions.ts | 7 + packages/flashnet/src/exports/index.ts | 70 +++++ .../src/hooks/useFlashnetAuth.test.tsx | 124 +++++++++ .../flashnet/src/hooks/useFlashnetAuth.ts | 84 ++++++ packages/flashnet/src/types.ts | 43 +++ packages/flashnet/src/version.ts | 1 + packages/flashnet/tsconfig.build.json | 7 + packages/flashnet/tsconfig.json | 10 + packages/flashnet/tsup.config.ts | 14 + pnpm-lock.yaml | 28 ++ tsconfig.json | 3 +- tsconfig.typetest.json | 1 + vitest.config.ts | 18 ++ 22 files changed, 1277 insertions(+), 1 deletion(-) create mode 100644 packages/flashnet/package.json create mode 100644 packages/flashnet/src/actions/verifyAuth.test.ts create mode 100644 packages/flashnet/src/actions/verifyAuth.ts create mode 100644 packages/flashnet/src/client.test.ts create mode 100644 packages/flashnet/src/client.ts create mode 100644 packages/flashnet/src/context.test.tsx create mode 100644 packages/flashnet/src/context.ts create mode 100644 packages/flashnet/src/errors.test.ts create mode 100644 packages/flashnet/src/errors.ts create mode 100644 packages/flashnet/src/exports/actions.ts create mode 100644 packages/flashnet/src/exports/index.ts create mode 100644 packages/flashnet/src/hooks/useFlashnetAuth.test.tsx create mode 100644 packages/flashnet/src/hooks/useFlashnetAuth.ts create mode 100644 packages/flashnet/src/types.ts create mode 100644 packages/flashnet/src/version.ts create mode 100644 packages/flashnet/tsconfig.build.json create mode 100644 packages/flashnet/tsconfig.json create mode 100644 packages/flashnet/tsup.config.ts diff --git a/packages/flashnet/package.json b/packages/flashnet/package.json new file mode 100644 index 0000000..6a95b09 --- /dev/null +++ b/packages/flashnet/package.json @@ -0,0 +1,98 @@ +{ + "name": "@mbga/flashnet", + "description": "Flashnet integration for MBGA — authentication, swaps, and cross-chain orchestration", + "version": "0.0.1", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/quantumlyy/mbga.git", + "directory": "packages/flashnet" + }, + "scripts": { + "build": "tsup", + "check:types": "tsc --noEmit", + "check:publint": "publint", + "check:attw": "attw --pack .", + "clean": "rm -rf dist tsconfig.tsbuildinfo" + }, + "files": [ + "dist/**", + "src/**/*.ts", + "src/**/*.tsx", + "!src/**/*.test.ts", + "!src/**/*.test.tsx", + "!src/**/*.test-d.ts" + ], + "sideEffects": false, + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./actions": { + "import": { + "types": "./dist/actions.d.ts", + "default": "./dist/actions.js" + }, + "require": { + "types": "./dist/actions.d.cts", + "default": "./dist/actions.cjs" + } + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "actions": [ + "./dist/actions.d.ts" + ] + } + }, + "dependencies": { + "@mbga/core": "workspace:*" + }, + "peerDependencies": { + "@tanstack/react-query": ">=5.0.0", + "react": ">=18", + "typescript": ">=5.7.3" + }, + "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + }, + "react": { + "optional": true + }, + "typescript": { + "optional": true + } + }, + "devDependencies": { + "@tanstack/react-query": "^5.49.2", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "keywords": [ + "mbga", + "flashnet", + "spark", + "bitcoin", + "defi", + "swap", + "orchestra" + ] +} diff --git a/packages/flashnet/src/actions/verifyAuth.test.ts b/packages/flashnet/src/actions/verifyAuth.test.ts new file mode 100644 index 0000000..1b78aaa --- /dev/null +++ b/packages/flashnet/src/actions/verifyAuth.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' +import { FlashnetClientNotConfiguredError } from '../errors' +import type { FlashnetClient } from '../types' +import { verifyAuth } from './verifyAuth' + +function createMockClient( + requestImpl: FlashnetClient['request'] = vi.fn(), +): FlashnetClient { + return { + baseUrl: 'https://test.flashnet.xyz', + hasApiKey: true, + request: requestImpl, + } +} + +describe('verifyAuth', () => { + it('returns authenticated status on success', async () => { + const client = createMockClient( + vi.fn().mockResolvedValue({ + authenticated: true, + account_id: 'acct_123', + }), + ) + + const result = await verifyAuth(client) + + expect(result).toEqual({ + authenticated: true, + accountId: 'acct_123', + }) + }) + + it('calls /v1/auth/verify endpoint', async () => { + const request = vi.fn().mockResolvedValue({ authenticated: true }) + const client = createMockClient(request) + + await verifyAuth(client) + + expect(request).toHaveBeenCalledWith('/v1/auth/verify', { + signal: undefined, + }) + }) + + it('forwards abort signal', async () => { + const controller = new AbortController() + const request = vi.fn().mockResolvedValue({ authenticated: true }) + const client = createMockClient(request) + + await verifyAuth(client, { signal: controller.signal }) + + expect(request).toHaveBeenCalledWith('/v1/auth/verify', { + signal: controller.signal, + }) + }) + + it('returns authenticated: false on auth error', async () => { + const authError = new Error('Invalid token') + authError.name = 'FlashnetAuthError' + + const client = createMockClient(vi.fn().mockRejectedValue(authError)) + + const result = await verifyAuth(client) + + expect(result).toEqual({ authenticated: false }) + }) + + it('re-throws non-auth errors', async () => { + const networkError = new Error('Network failure') + const client = createMockClient(vi.fn().mockRejectedValue(networkError)) + + await expect(verifyAuth(client)).rejects.toThrow('Network failure') + }) + + it('throws if client is null', async () => { + await expect(verifyAuth(null as unknown as FlashnetClient)).rejects.toThrow( + FlashnetClientNotConfiguredError, + ) + }) + + it('defaults authenticated to true when field is missing', async () => { + const client = createMockClient( + vi.fn().mockResolvedValue({ account_id: 'acct_456' }), + ) + + const result = await verifyAuth(client) + + expect(result.authenticated).toBe(true) + expect(result.accountId).toBe('acct_456') + }) +}) diff --git a/packages/flashnet/src/actions/verifyAuth.ts b/packages/flashnet/src/actions/verifyAuth.ts new file mode 100644 index 0000000..bd73fd9 --- /dev/null +++ b/packages/flashnet/src/actions/verifyAuth.ts @@ -0,0 +1,65 @@ +import { FlashnetClientNotConfiguredError } from '../errors' +import type { FlashnetAuthStatus, FlashnetClient } from '../types' + +/** Parameters for {@link verifyAuth}. */ +export type VerifyAuthParameters = { + /** Optional abort signal. */ + signal?: AbortSignal | undefined +} + +/** Return type of {@link verifyAuth}. */ +export type VerifyAuthReturnType = FlashnetAuthStatus + +/** Error types that {@link verifyAuth} may throw. */ +export type VerifyAuthErrorType = Error + +/** + * Verifies that the Flashnet client is authenticated by making a lightweight + * request to the API. Returns the auth status without throwing on invalid credentials. + * + * @param client - The Flashnet client instance. + * @param parameters - Optional parameters (e.g. abort signal). + * @returns The authentication status. + * + * @example + * ```ts + * import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' + * + * const client = createFlashnetClient({ apiKey: 'fn_...' }) + * const status = await verifyAuth(client) + * + * if (status.authenticated) { + * console.log('Authenticated as', status.accountId) + * } + * ``` + */ +export async function verifyAuth( + client: FlashnetClient, + parameters: VerifyAuthParameters = {}, +): Promise { + if (!client) { + throw new FlashnetClientNotConfiguredError() + } + + try { + const result = await client.request<{ + authenticated?: boolean + account_id?: string + }>('/v1/auth/verify', { + signal: parameters.signal, + }) + + return { + authenticated: result.authenticated ?? true, + accountId: result.account_id, + } + } catch (error) { + // Auth errors mean credentials are invalid but we don't throw — + // we return { authenticated: false } so the caller can handle it gracefully. + if (error instanceof Error && error.name === 'FlashnetAuthError') { + return { authenticated: false } + } + + throw error + } +} diff --git a/packages/flashnet/src/client.test.ts b/packages/flashnet/src/client.test.ts new file mode 100644 index 0000000..827cfee --- /dev/null +++ b/packages/flashnet/src/client.test.ts @@ -0,0 +1,248 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createFlashnetClient } from './client' +import { FlashnetAuthError, FlashnetRequestError } from './errors' + +const mockFetch = vi.fn() + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +describe('createFlashnetClient', () => { + it('creates a client with default base URL', () => { + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + expect(client.baseUrl).toBe('https://orchestration.flashnet.xyz') + expect(client.hasApiKey).toBe(true) + }) + + it('creates a client with custom base URL', () => { + const client = createFlashnetClient({ + apiKey: 'fn_test_key', + baseUrl: 'https://custom.api.xyz', + }) + expect(client.baseUrl).toBe('https://custom.api.xyz') + }) + + it('throws if apiKey is empty', () => { + expect(() => createFlashnetClient({ apiKey: '' })).toThrow( + FlashnetAuthError, + ) + }) + + describe('request', () => { + it('sends authenticated GET request', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: 'test' }), + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + const result = await client.request('/v1/routes') + + expect(mockFetch).toHaveBeenCalledWith( + 'https://orchestration.flashnet.xyz/v1/routes', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer fn_test_key', + 'Content-Type': 'application/json', + Accept: 'application/json', + }), + }), + ) + expect(result).toEqual({ data: 'test' }) + }) + + it('sends POST request with body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ id: '123' }), + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + await client.request('/v1/orders', { + method: 'POST', + body: { amount: '1000' }, + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'https://orchestration.flashnet.xyz/v1/orders', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ amount: '1000' }), + }), + ) + }) + + it('includes idempotency key header', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + await client.request('/v1/orders', { + method: 'POST', + idempotencyKey: 'idem-123', + }) + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Idempotency-Key': 'idem-123', + }), + }), + ) + }) + + it('handles 204 No Content', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + const result = await client.request('/v1/something') + + expect(result).toBeUndefined() + }) + + it('throws FlashnetAuthError on 401', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => JSON.stringify({ message: 'Invalid token' }), + }) + + const client = createFlashnetClient({ apiKey: 'fn_bad_key' }) + await expect(client.request('/v1/routes')).rejects.toThrow( + FlashnetAuthError, + ) + }) + + it('throws FlashnetAuthError on 403', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: async () => JSON.stringify({ message: 'Forbidden' }), + }) + + const client = createFlashnetClient({ apiKey: 'fn_bad_key' }) + await expect(client.request('/v1/routes')).rejects.toThrow( + FlashnetAuthError, + ) + }) + + it('throws FlashnetRequestError on other errors', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => + JSON.stringify({ message: 'Internal error', code: 'SERVER_ERROR' }), + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + + try { + await client.request('/v1/routes') + expect.fail('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(FlashnetRequestError) + const reqError = error as InstanceType + expect(reqError.status).toBe(500) + expect(reqError.code).toBe('SERVER_ERROR') + } + }) + + it('handles non-JSON error responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 502, + text: async () => 'Bad Gateway', + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + + try { + await client.request('/v1/routes') + expect.fail('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(FlashnetRequestError) + const reqError = error as InstanceType + expect(reqError.status).toBe(502) + expect(reqError.shortMessage).toContain('Bad Gateway') + } + }) + + it('handles error responses where text() fails', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => { + throw new Error('body stream error') + }, + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + + try { + await client.request('/v1/routes') + expect.fail('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(FlashnetRequestError) + const reqError = error as InstanceType + expect(reqError.status).toBe(500) + } + }) + + it('forwards abort signal', async () => { + const controller = new AbortController() + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + await client.request('/v1/routes', { signal: controller.signal }) + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + signal: controller.signal, + }), + ) + }) + + it('merges custom headers', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }) + + const client = createFlashnetClient({ apiKey: 'fn_test_key' }) + await client.request('/v1/routes', { + headers: { 'X-Custom': 'value' }, + }) + + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer fn_test_key', + 'X-Custom': 'value', + }), + }), + ) + }) + }) +}) diff --git a/packages/flashnet/src/client.ts b/packages/flashnet/src/client.ts new file mode 100644 index 0000000..bf2a18a --- /dev/null +++ b/packages/flashnet/src/client.ts @@ -0,0 +1,111 @@ +import { FlashnetAuthError, FlashnetRequestError } from './errors' +import type { + FlashnetClient, + FlashnetClientConfig, + FlashnetRequestOptions, +} from './types' + +const DEFAULT_BASE_URL = 'https://orchestration.flashnet.xyz' + +/** + * Creates a Flashnet client configured with API-key authentication. + * The client automatically adds bearer token auth headers to all requests. + * + * @param config - Client configuration including API key and optional base URL. + * @returns A {@link FlashnetClient} instance for making authenticated API requests. + * + * @example + * ```ts + * import { createFlashnetClient } from '@mbga/flashnet' + * + * const client = createFlashnetClient({ + * apiKey: 'fn_your_api_key', + * }) + * + * // Make authenticated requests + * const routes = await client.request('/v1/routes') + * ``` + */ +export function createFlashnetClient( + config: FlashnetClientConfig, +): FlashnetClient { + const { apiKey, baseUrl = DEFAULT_BASE_URL } = config + + if (!apiKey) { + throw new FlashnetAuthError('API key is required.') + } + + async function request( + path: string, + options: FlashnetRequestOptions = {}, + ): Promise { + const { + method = 'GET', + body, + headers = {}, + signal, + idempotencyKey, + } = options + + const url = `${baseUrl}${path}` + + const requestHeaders: Record = { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, + } + + if (idempotencyKey) { + requestHeaders['X-Idempotency-Key'] = idempotencyKey + } + + const response = await fetch(url, { + method, + headers: requestHeaders, + body: body != null ? JSON.stringify(body) : undefined, + signal, + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => '') + let message = `HTTP ${response.status}` + let code: string | undefined + + try { + const parsed = JSON.parse(errorBody) as { + message?: string + error?: string + code?: string + } + message = parsed.message ?? parsed.error ?? message + code = parsed.code + } catch { + if (errorBody) message = errorBody + } + + if (response.status === 401 || response.status === 403) { + throw new FlashnetAuthError(message) + } + + throw new FlashnetRequestError({ + status: response.status, + message, + code, + }) + } + + // Handle 204 No Content + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T + } + + return { + baseUrl, + hasApiKey: true, + request, + } +} diff --git a/packages/flashnet/src/context.test.tsx b/packages/flashnet/src/context.test.tsx new file mode 100644 index 0000000..609d50b --- /dev/null +++ b/packages/flashnet/src/context.test.tsx @@ -0,0 +1,74 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it } from 'vitest' +import { FlashnetProvider, useFlashnetClient } from './context' +import { FlashnetProviderNotFoundError } from './errors' +import type { FlashnetClient } from './types' + +function createMockClient(): FlashnetClient { + return { + baseUrl: 'https://test.flashnet.xyz', + hasApiKey: true, + request: async () => ({}) as T, + } +} + +describe('FlashnetProvider', () => { + it('provides client to children via context', () => { + const mockClient = createMockClient() + + const { result } = renderHook(() => useFlashnetClient(), { + wrapper: ({ children }) => + createElement(FlashnetProvider, { + apiKey: 'fn_test', + client: mockClient, + children, + }), + }) + + expect(result.current).toBe(mockClient) + }) + + it('creates client from apiKey when no client prop', () => { + const { result } = renderHook(() => useFlashnetClient(), { + wrapper: ({ children }) => + createElement(FlashnetProvider, { + apiKey: 'fn_test_key', + children, + }), + }) + + expect(result.current.hasApiKey).toBe(true) + expect(result.current.baseUrl).toBe('https://orchestration.flashnet.xyz') + }) + + it('uses custom baseUrl', () => { + const { result } = renderHook(() => useFlashnetClient(), { + wrapper: ({ children }) => + createElement(FlashnetProvider, { + apiKey: 'fn_test_key', + baseUrl: 'https://custom.api.xyz', + children, + }), + }) + + expect(result.current.baseUrl).toBe('https://custom.api.xyz') + }) +}) + +describe('useFlashnetClient', () => { + it('throws outside provider', () => { + const queryClient = new QueryClient() + + expect(() => { + renderHook(() => useFlashnetClient(), { + wrapper: ({ children }) => + createElement(QueryClientProvider, { + client: queryClient, + children, + }), + }) + }).toThrow(FlashnetProviderNotFoundError) + }) +}) diff --git a/packages/flashnet/src/context.ts b/packages/flashnet/src/context.ts new file mode 100644 index 0000000..c2edc14 --- /dev/null +++ b/packages/flashnet/src/context.ts @@ -0,0 +1,61 @@ +'use client' + +import { createContext, createElement, useContext, useMemo } from 'react' +import { createFlashnetClient } from './client' +import { FlashnetProviderNotFoundError } from './errors' +import type { FlashnetClient } from './types' + +/** React context that holds the Flashnet client instance. */ +export const FlashnetContext = createContext( + undefined, +) + +/** Props for {@link FlashnetProvider}. */ +export type FlashnetProviderProps = { + /** Flashnet API key. */ + apiKey: string + /** Base URL for the Flashnet API. */ + baseUrl?: string | undefined + /** Pre-configured client instance. Takes precedence over apiKey/baseUrl. */ + client?: FlashnetClient | undefined +} + +/** + * Provider component that makes a Flashnet client available to all hooks. + * + * @example + * ```tsx + * import { FlashnetProvider } from '@mbga/flashnet' + * + * function App() { + * return ( + * + * + * + * ) + * } + * ``` + */ +export function FlashnetProvider( + parameters: React.PropsWithChildren, +) { + const { children, apiKey, baseUrl, client: clientProp } = parameters + + const client = useMemo(() => { + if (clientProp) return clientProp + return createFlashnetClient({ apiKey, baseUrl }) + }, [clientProp, apiKey, baseUrl]) + + return createElement(FlashnetContext.Provider, { value: client }, children) +} + +/** + * Hook to access the Flashnet client from context. + * + * @throws {FlashnetProviderNotFoundError} If used outside of FlashnetProvider. + */ +export function useFlashnetClient(): FlashnetClient { + const context = useContext(FlashnetContext) + if (!context) throw new FlashnetProviderNotFoundError() + return context +} diff --git a/packages/flashnet/src/errors.test.ts b/packages/flashnet/src/errors.test.ts new file mode 100644 index 0000000..e4f3663 --- /dev/null +++ b/packages/flashnet/src/errors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + FlashnetAuthError, + FlashnetClientNotConfiguredError, + FlashnetProviderNotFoundError, + FlashnetRequestError, +} from './errors' + +describe('FlashnetAuthError', () => { + it('has default message', () => { + const error = new FlashnetAuthError() + expect(error.name).toBe('FlashnetAuthError') + expect(error.shortMessage).toBe('Flashnet authentication failed.') + expect(error.details).toContain('API key') + }) + + it('accepts custom message', () => { + const error = new FlashnetAuthError('Custom auth error') + expect(error.shortMessage).toBe('Custom auth error') + }) +}) + +describe('FlashnetClientNotConfiguredError', () => { + it('has correct message', () => { + const error = new FlashnetClientNotConfiguredError() + expect(error.name).toBe('FlashnetClientNotConfiguredError') + expect(error.shortMessage).toContain('not configured') + }) +}) + +describe('FlashnetRequestError', () => { + it('includes status and code', () => { + const error = new FlashnetRequestError({ + status: 500, + message: 'Internal error', + code: 'SERVER_ERROR', + }) + expect(error.name).toBe('FlashnetRequestError') + expect(error.status).toBe(500) + expect(error.code).toBe('SERVER_ERROR') + expect(error.shortMessage).toContain('500') + }) + + it('works without code', () => { + const error = new FlashnetRequestError({ + status: 404, + message: 'Not found', + }) + expect(error.code).toBeUndefined() + }) +}) + +describe('FlashnetProviderNotFoundError', () => { + it('has correct message', () => { + const error = new FlashnetProviderNotFoundError() + expect(error.name).toBe('FlashnetProviderNotFoundError') + expect(error.shortMessage).toContain('FlashnetProvider') + }) +}) diff --git a/packages/flashnet/src/errors.ts b/packages/flashnet/src/errors.ts new file mode 100644 index 0000000..dbe0eb8 --- /dev/null +++ b/packages/flashnet/src/errors.ts @@ -0,0 +1,62 @@ +import { BaseError } from '@mbga/core' + +export type FlashnetAuthErrorType = typeof FlashnetAuthError + +/** Thrown when Flashnet authentication fails (invalid or missing API key). */ +export class FlashnetAuthError extends BaseError { + override name = 'FlashnetAuthError' + constructor(message?: string) { + super(message ?? 'Flashnet authentication failed.', { + details: 'Verify that your API key is valid and has not expired.', + }) + } +} + +export type FlashnetClientNotConfiguredErrorType = + typeof FlashnetClientNotConfiguredError + +/** Thrown when a Flashnet action is called without a configured client. */ +export class FlashnetClientNotConfiguredError extends BaseError { + override name = 'FlashnetClientNotConfiguredError' + constructor() { + super('Flashnet client is not configured.', { + details: + 'Call createFlashnetClient() with a valid API key, or wrap your app in .', + }) + } +} + +export type FlashnetRequestErrorType = typeof FlashnetRequestError + +/** Thrown when a Flashnet API request fails. */ +export class FlashnetRequestError extends BaseError { + override name = 'FlashnetRequestError' + + status: number + code: string | undefined + + constructor({ + status, + message, + code, + }: { + status: number + message: string + code?: string | undefined + }) { + super(`Flashnet request failed (${status}): ${message}`) + this.status = status + this.code = code + } +} + +export type FlashnetProviderNotFoundErrorType = + typeof FlashnetProviderNotFoundError + +/** Thrown when a Flashnet hook is used outside of FlashnetProvider. */ +export class FlashnetProviderNotFoundError extends BaseError { + override name = 'FlashnetProviderNotFoundError' + constructor() { + super('`useFlashnetClient` must be used within `FlashnetProvider`.') + } +} diff --git a/packages/flashnet/src/exports/actions.ts b/packages/flashnet/src/exports/actions.ts new file mode 100644 index 0000000..dbd20c1 --- /dev/null +++ b/packages/flashnet/src/exports/actions.ts @@ -0,0 +1,7 @@ +// biome-ignore lint/performance/noBarrelFile: entrypoint module +export { + type VerifyAuthErrorType, + type VerifyAuthParameters, + type VerifyAuthReturnType, + verifyAuth, +} from '../actions/verifyAuth' diff --git a/packages/flashnet/src/exports/index.ts b/packages/flashnet/src/exports/index.ts new file mode 100644 index 0000000..0831110 --- /dev/null +++ b/packages/flashnet/src/exports/index.ts @@ -0,0 +1,70 @@ +//////////////////////////////////////////////////////////////////////////////// +// Client +//////////////////////////////////////////////////////////////////////////////// + +// biome-ignore lint/performance/noBarrelFile: entrypoint module +export { createFlashnetClient } from '../client' + +//////////////////////////////////////////////////////////////////////////////// +// Context +//////////////////////////////////////////////////////////////////////////////// + +export { + FlashnetContext, + FlashnetProvider, + type FlashnetProviderProps, + useFlashnetClient, +} from '../context' + +//////////////////////////////////////////////////////////////////////////////// +// Actions +//////////////////////////////////////////////////////////////////////////////// + +export { + type VerifyAuthErrorType, + type VerifyAuthParameters, + type VerifyAuthReturnType, + verifyAuth, +} from '../actions/verifyAuth' + +//////////////////////////////////////////////////////////////////////////////// +// Hooks +//////////////////////////////////////////////////////////////////////////////// + +export { + type UseFlashnetAuthParameters, + type UseFlashnetAuthReturnType, + useFlashnetAuth, +} from '../hooks/useFlashnetAuth' + +//////////////////////////////////////////////////////////////////////////////// +// Errors +//////////////////////////////////////////////////////////////////////////////// + +export { + FlashnetAuthError, + type FlashnetAuthErrorType, + FlashnetClientNotConfiguredError, + type FlashnetClientNotConfiguredErrorType, + FlashnetProviderNotFoundError, + type FlashnetProviderNotFoundErrorType, + FlashnetRequestError, + type FlashnetRequestErrorType, +} from '../errors' + +//////////////////////////////////////////////////////////////////////////////// +// Types +//////////////////////////////////////////////////////////////////////////////// + +export type { + FlashnetAuthStatus, + FlashnetClient, + FlashnetClientConfig, + FlashnetRequestOptions, +} from '../types' + +//////////////////////////////////////////////////////////////////////////////// +// Version +//////////////////////////////////////////////////////////////////////////////// + +export { version } from '../version' diff --git a/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx b/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx new file mode 100644 index 0000000..418893f --- /dev/null +++ b/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx @@ -0,0 +1,124 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import { createElement } from 'react' +import { describe, expect, it, vi } from 'vitest' +import { FlashnetContext } from '../context' +import type { FlashnetClient } from '../types' +import { useFlashnetAuth } from './useFlashnetAuth' + +function createMockClient( + requestImpl?: FlashnetClient['request'], +): FlashnetClient { + return { + baseUrl: 'https://test.flashnet.xyz', + hasApiKey: true, + request: + requestImpl ?? + vi.fn().mockResolvedValue({ authenticated: true, account_id: 'acct_1' }), + } +} + +function createWrapper(client?: FlashnetClient) { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + + return function Wrapper({ children }: { children: React.ReactNode }) { + const inner = client + ? createElement(FlashnetContext.Provider, { value: client }, children) + : children + return createElement(QueryClientProvider, { client: queryClient }, inner) + } +} + +describe('useFlashnetAuth', () => { + it('returns authenticated status from context client', async () => { + const client = createMockClient() + + const { result } = renderHook(() => useFlashnetAuth(), { + wrapper: createWrapper(client), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.isAuthenticated).toBe(true) + expect(result.current.data?.accountId).toBe('acct_1') + }) + + it('returns authenticated status from client prop', async () => { + const client = createMockClient() + + const { result } = renderHook(() => useFlashnetAuth({ client }), { + wrapper: createWrapper(), // no context client + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.isAuthenticated).toBe(true) + }) + + it('returns not authenticated when auth fails', async () => { + const authError = new Error('Invalid token') + authError.name = 'FlashnetAuthError' + + const client = createMockClient(vi.fn().mockRejectedValue(authError)) + + const { result } = renderHook(() => useFlashnetAuth({ client }), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + + expect(result.current.isAuthenticated).toBe(false) + }) + + it('reports error for non-auth failures', async () => { + const networkError = new Error('Network failure') + const client = createMockClient(vi.fn().mockRejectedValue(networkError)) + + const { result } = renderHook(() => useFlashnetAuth({ client }), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + }) + + expect(result.current.error?.message).toBe('Network failure') + expect(result.current.isAuthenticated).toBe(false) + }) + + it('does not fetch when disabled', async () => { + const request = vi.fn() + const client = createMockClient(request) + + renderHook(() => useFlashnetAuth({ client, enabled: false }), { + wrapper: createWrapper(), + }) + + // Wait a tick to ensure no fetch happens + await new Promise((r) => setTimeout(r, 50)) + expect(request).not.toHaveBeenCalled() + }) + + it('is pending initially', () => { + const client = createMockClient( + vi.fn().mockReturnValue(new Promise(() => {})), // never resolves + ) + + const { result } = renderHook(() => useFlashnetAuth({ client }), { + wrapper: createWrapper(), + }) + + expect(result.current.isPending).toBe(true) + expect(result.current.isAuthenticated).toBe(false) + }) +}) diff --git a/packages/flashnet/src/hooks/useFlashnetAuth.ts b/packages/flashnet/src/hooks/useFlashnetAuth.ts new file mode 100644 index 0000000..490a4d4 --- /dev/null +++ b/packages/flashnet/src/hooks/useFlashnetAuth.ts @@ -0,0 +1,84 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { useContext } from 'react' +import { verifyAuth } from '../actions/verifyAuth' +import { FlashnetContext } from '../context' +import type { FlashnetAuthStatus, FlashnetClient } from '../types' + +/** Parameters for {@link useFlashnetAuth}. */ +export type UseFlashnetAuthParameters = { + /** Flashnet client instance. If omitted, uses FlashnetProvider context. */ + client?: FlashnetClient | undefined + /** Whether to enable automatic auth verification. @default true */ + enabled?: boolean | undefined +} + +/** Return type of {@link useFlashnetAuth}. */ +export type UseFlashnetAuthReturnType = { + /** The current authentication status. */ + data: FlashnetAuthStatus | undefined + /** Error from the last verification attempt. */ + error: Error | null + /** Whether verification is currently in progress. */ + isPending: boolean + /** Whether the client is authenticated. Convenience shorthand for `data?.authenticated`. */ + isAuthenticated: boolean + /** Whether the last verification failed. */ + isError: boolean + /** Whether verification succeeded at least once. */ + isSuccess: boolean + /** Re-run the auth verification. */ + refetch: () => void +} + +/** + * Hook that verifies Flashnet authentication status. + * Automatically checks if the API key is valid on mount. + * + * Uses the client from {@link FlashnetProvider} context, or accepts a client prop. + * + * @example + * ```tsx + * import { useFlashnetAuth } from '@mbga/flashnet' + * + * function AuthStatus() { + * const { isAuthenticated, isPending } = useFlashnetAuth() + * + * if (isPending) return Verifying... + * if (isAuthenticated) return Authenticated + * return Not authenticated + * } + * ``` + */ +export function useFlashnetAuth( + parameters: UseFlashnetAuthParameters = {}, +): UseFlashnetAuthReturnType { + const { client: clientProp, enabled = true } = parameters + + const contextClient = useContext(FlashnetContext) + const client = clientProp ?? contextClient + + const query = useQuery({ + queryKey: ['flashnet', 'auth'], + queryFn: () => { + if (!client) { + return { authenticated: false } satisfies FlashnetAuthStatus + } + return verifyAuth(client) + }, + enabled: enabled && client != null, + staleTime: 5 * 60 * 1000, // 5 minutes + retry: false, + }) + + return { + data: query.data, + error: query.error, + isPending: query.isPending, + isAuthenticated: query.data?.authenticated ?? false, + isError: query.isError, + isSuccess: query.isSuccess, + refetch: query.refetch, + } +} diff --git a/packages/flashnet/src/types.ts b/packages/flashnet/src/types.ts new file mode 100644 index 0000000..36fd3ed --- /dev/null +++ b/packages/flashnet/src/types.ts @@ -0,0 +1,43 @@ +/** Authentication status returned by {@link verifyAuth}. */ +export type FlashnetAuthStatus = { + /** Whether the API key is valid and authenticated. */ + authenticated: boolean + /** The authenticated account/organization ID, if available. */ + accountId?: string | undefined +} + +/** Configuration for creating a Flashnet client. */ +export type FlashnetClientConfig = { + /** Flashnet API key (e.g. `fn_...`). Required for authenticated operations. */ + apiKey: string + /** Base URL for the Flashnet Orchestra API. @default 'https://orchestration.flashnet.xyz' */ + baseUrl?: string | undefined +} + +/** A configured Flashnet client that handles authenticated API requests. */ +export type FlashnetClient = { + /** The base URL for API requests. */ + readonly baseUrl: string + /** Whether an API key has been configured. */ + readonly hasApiKey: boolean + /** + * Make an authenticated request to the Flashnet API. + * + * @param path - The API path (e.g. `/v1/routes`). + * @param options - Fetch options (method, body, headers, etc.). + * @returns The parsed JSON response. + */ + request( + path: string, + options?: FlashnetRequestOptions, + ): Promise +} + +/** Options for a Flashnet API request. */ +export type FlashnetRequestOptions = { + method?: string | undefined + body?: unknown | undefined + headers?: Record | undefined + signal?: AbortSignal | undefined + idempotencyKey?: string | undefined +} diff --git a/packages/flashnet/src/version.ts b/packages/flashnet/src/version.ts new file mode 100644 index 0000000..6af1fbc --- /dev/null +++ b/packages/flashnet/src/version.ts @@ -0,0 +1 @@ +export const version = '0.0.1' diff --git a/packages/flashnet/tsconfig.build.json b/packages/flashnet/tsconfig.build.json new file mode 100644 index 0000000..323bf8d --- /dev/null +++ b/packages/flashnet/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false + }, + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.test-d.ts"] +} diff --git a/packages/flashnet/tsconfig.json b/packages/flashnet/tsconfig.json new file mode 100644 index 0000000..99944f6 --- /dev/null +++ b/packages/flashnet/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*.ts", "src/**/*.tsx"], + "compilerOptions": { + "composite": true, + "rootDir": "src", + "jsx": "react-jsx", + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + } +} diff --git a/packages/flashnet/tsup.config.ts b/packages/flashnet/tsup.config.ts new file mode 100644 index 0000000..6e393ad --- /dev/null +++ b/packages/flashnet/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/exports/index.ts', + actions: 'src/exports/actions.ts', + }, + format: ['cjs', 'esm'], + dts: true, + splitting: true, + clean: true, + tsconfig: 'tsconfig.build.json', + outDir: 'dist', +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dcc66d..390bbb5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,6 +196,34 @@ importers: specifier: ^2.4.9 version: 2.4.9 + packages/flashnet: + dependencies: + '@mbga/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@tanstack/react-query': + specifier: ^5.49.2 + version: 5.95.2(react@19.2.4) + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.2.0 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.14) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + packages/kit: dependencies: '@mbga/core': diff --git a/tsconfig.json b/tsconfig.json index 4bbc055..abc8870 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "packages/core" }, { "path": "packages/connectors" }, - { "path": "packages/react" } + { "path": "packages/react" }, + { "path": "packages/flashnet" } ] } diff --git a/tsconfig.typetest.json b/tsconfig.typetest.json index 151855b..a479dc7 100644 --- a/tsconfig.typetest.json +++ b/tsconfig.typetest.json @@ -5,6 +5,7 @@ "paths": { "@mbga/core": ["./packages/core/src/exports/index.ts"], "@mbga/connectors": ["./packages/connectors/src/exports/index.ts"], + "@mbga/flashnet": ["./packages/flashnet/src/exports/index.ts"], "@mbga/test": ["./packages/test/src/exports/index.ts"], "mbga": ["./packages/react/src/exports/index.ts"] }, diff --git a/vitest.config.ts b/vitest.config.ts index eadcf9a..607bb48 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,7 @@ const alias = { ), '@mbga/core': path.resolve(__dirname, './packages/core/src/exports'), '@mbga/test': path.resolve(__dirname, './packages/test/src/exports'), + '@mbga/flashnet': path.resolve(__dirname, './packages/flashnet/src/exports'), '@mbga/kit': path.resolve(__dirname, './packages/kit/src/exports'), mbga: path.resolve(__dirname, './packages/react/src/exports'), } @@ -74,6 +75,23 @@ export default defineConfig({ }, resolve: { alias }, }, + { + test: { + name: 'flashnet', + include: ['./packages/flashnet/src/**/*.test.ts'], + environment: 'node', + }, + resolve: { alias }, + }, + { + test: { + name: 'flashnet-react', + include: ['./packages/flashnet/src/**/*.test.ts?(x)'], + exclude: ['./packages/flashnet/src/**/*.test.ts'], + environment: 'happy-dom', + }, + resolve: { alias }, + }, { test: { name: 'kit', From d5efcd647f2a4be8f8f56f8e9a951ca8d9cc4874 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 10:54:20 +0200 Subject: [PATCH 2/8] chore: mark Flashnet authentication done, update NEXT_TASK.md Co-authored-by: Claude Signed-off-by: Claude --- NEXT_TASK.md | 27 +++++++-------------------- plans/todos.md | 2 +- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/NEXT_TASK.md b/NEXT_TASK.md index 4971b85..340c930 100644 --- a/NEXT_TASK.md +++ b/NEXT_TASK.md @@ -7,21 +7,21 @@ After the task is done, Claude will update this file with the next task in line. ## Current Task -**Flashnet authentication** +**Flashnet swaps and clawbacks** -Simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations. +Requires auth above; atomic swaps between BTC/Lightning/Spark and other assets via Flashnet, plus clawback support for reversible transfers. ## Prompt ``` Work on the quantumlyy/mbga repo. -Your current task: **Flashnet authentication** +Your current task: **Flashnet swaps and clawbacks** This includes: -### Flashnet authentication -- Simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations +### Flashnet swaps and clawbacks +- Requires auth above; atomic swaps between BTC/Lightning/Spark and other assets via Flashnet, plus clawback support for reversible transfers After completing the task: 1. Mark it done in plans/todos.md (change `- [ ]` to `- [x]`) @@ -29,19 +29,6 @@ After completing the task: description inside the prompt with the next unchecked item from plans/todos.md. Pick the highest-priority unchecked item (Medium Term first). -Git conventions: -- Author: Nejc Drobnic -- Committer: Claude (so commits are signed/verified) -- To achieve this, run commits like: - GIT_COMMITTER_NAME="Claude" GIT_COMMITTER_EMAIL="noreply@anthropic.com" \ - git -c user.name="Nejc Drobnic" -c user.email="nejc@flashnet.xyz" \ - commit -m "message" -- Include Co-authored-by and Signed-off-by trailers: - Co-authored-by: Claude - Signed-off-by: Claude -- Push to whatever branch Claude Code created for this session. -- Do NOT create a PR unless told to. - Split work into logical commits. Run tests before pushing. ``` @@ -56,6 +43,6 @@ Split work into logical commits. Run tests before pushing. 4. ~~Build documentation site with VitePress~~ (DONE) 5. ~~Multi-wallet simultaneous connections~~ (DONE) 6. ~~Spark token operations~~ (DONE) -7. Flashnet authentication **(CURRENT)** -8. Flashnet swaps and clawbacks +7. ~~Flashnet authentication~~ (DONE) +8. Flashnet swaps and clawbacks **(CURRENT)** 9. Configure npm publishing workflow diff --git a/plans/todos.md b/plans/todos.md index fca055f..cfc0ecf 100644 --- a/plans/todos.md +++ b/plans/todos.md @@ -87,7 +87,7 @@ - [x] Spark token operations (fetch token lists from btknlist.org registry, validate with ArkType schema parser, `Token`/`TokenList` types matching the btkn-info schema, `getTokenBalance`/`sendToken` actions, `useTokenList`/`useTokenBalance`/`useSendToken` hooks, `formatTokenAmount`/`parseTokenAmount` helpers respecting per-token decimals) ### Flashnet Integration -- [ ] Flashnet authentication (simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations) +- [x] Flashnet authentication (simple, effortless auth flow — OAuth or API-key based — so apps can authenticate with Flashnet before any swap/trade operations) - [ ] Flashnet swaps and clawbacks (requires auth above; atomic swaps between BTC/Lightning/Spark and other assets via Flashnet, plus clawback support for reversible transfers) ### Publishing & Infrastructure From 0d0cfef5e1c3fb57fe6cd6b5902bc15806b395b7 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 11:07:14 +0200 Subject: [PATCH 3/8] fix(flashnet): separate React exports, fix build/cache/types issues - Move React context and hooks to @mbga/flashnet/react entrypoint so non-React consumers importing from @mbga/flashnet don't need react or @tanstack/react-query installed (finding 1) - Add @mbga/flashnet to root build script so pnpm build/release produces its artifacts (finding 2) - Include client.baseUrl in useFlashnetAuth query key so switching clients busts the TanStack Query cache (finding 3) - Make FlashnetProviderProps a discriminated union: apiKey is only required when client is absent, so is type-safe without a dummy apiKey (finding 4) - Add test for client-switching cache scenario Co-authored-by: Claude Signed-off-by: Claude --- package.json | 2 +- packages/flashnet/package.json | 13 +++++ packages/flashnet/src/context.test.tsx | 14 +++--- packages/flashnet/src/context.ts | 44 ++++++++++++----- packages/flashnet/src/exports/index.ts | 21 --------- packages/flashnet/src/exports/react.ts | 13 +++++ .../src/hooks/useFlashnetAuth.test.tsx | 47 ++++++++++++++++++- .../flashnet/src/hooks/useFlashnetAuth.ts | 2 +- packages/flashnet/tsup.config.ts | 1 + 9 files changed, 113 insertions(+), 44 deletions(-) create mode 100644 packages/flashnet/src/exports/react.ts diff --git a/package.json b/package.json index 23cfeee..c1e3dc1 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "build": "pnpm run --filter @mbga/core build && pnpm run --filter @mbga/connectors build && pnpm run --filter mbga build && pnpm run --filter @mbga/kit build && pnpm run --filter create-mbga build", + "build": "pnpm run --filter @mbga/core build && pnpm run --filter @mbga/connectors build && pnpm run --filter mbga build && pnpm run --filter @mbga/kit build && pnpm run --filter @mbga/flashnet build && pnpm run --filter create-mbga build", "check": "biome check --write", "check:types": "pnpm build && pnpm run --r --parallel check:types && tsc --noEmit", "clean": "pnpm run --r --parallel clean", diff --git a/packages/flashnet/package.json b/packages/flashnet/package.json index 6a95b09..d23b0d7 100644 --- a/packages/flashnet/package.json +++ b/packages/flashnet/package.json @@ -49,12 +49,25 @@ "default": "./dist/actions.cjs" } }, + "./react": { + "import": { + "types": "./dist/react.d.ts", + "default": "./dist/react.js" + }, + "require": { + "types": "./dist/react.d.cts", + "default": "./dist/react.cjs" + } + }, "./package.json": "./package.json" }, "typesVersions": { "*": { "actions": [ "./dist/actions.d.ts" + ], + "react": [ + "./dist/react.d.ts" ] } }, diff --git a/packages/flashnet/src/context.test.tsx b/packages/flashnet/src/context.test.tsx index 609d50b..d9c65e9 100644 --- a/packages/flashnet/src/context.test.tsx +++ b/packages/flashnet/src/context.test.tsx @@ -6,22 +6,23 @@ import { FlashnetProvider, useFlashnetClient } from './context' import { FlashnetProviderNotFoundError } from './errors' import type { FlashnetClient } from './types' -function createMockClient(): FlashnetClient { +function createMockClient( + baseUrl = 'https://test.flashnet.xyz', +): FlashnetClient { return { - baseUrl: 'https://test.flashnet.xyz', + baseUrl, hasApiKey: true, request: async () => ({}) as T, } } describe('FlashnetProvider', () => { - it('provides client to children via context', () => { + it('provides client to children via client prop', () => { const mockClient = createMockClient() const { result } = renderHook(() => useFlashnetClient(), { wrapper: ({ children }) => createElement(FlashnetProvider, { - apiKey: 'fn_test', client: mockClient, children, }), @@ -64,10 +65,7 @@ describe('useFlashnetClient', () => { expect(() => { renderHook(() => useFlashnetClient(), { wrapper: ({ children }) => - createElement(QueryClientProvider, { - client: queryClient, - children, - }), + createElement(QueryClientProvider, { client: queryClient }, children), }) }).toThrow(FlashnetProviderNotFoundError) }) diff --git a/packages/flashnet/src/context.ts b/packages/flashnet/src/context.ts index c2edc14..30dd343 100644 --- a/packages/flashnet/src/context.ts +++ b/packages/flashnet/src/context.ts @@ -11,22 +11,29 @@ export const FlashnetContext = createContext( ) /** Props for {@link FlashnetProvider}. */ -export type FlashnetProviderProps = { - /** Flashnet API key. */ - apiKey: string - /** Base URL for the Flashnet API. */ - baseUrl?: string | undefined - /** Pre-configured client instance. Takes precedence over apiKey/baseUrl. */ - client?: FlashnetClient | undefined -} +export type FlashnetProviderProps = + | { + /** Pre-configured client instance. Takes precedence over apiKey/baseUrl. */ + client: FlashnetClient + apiKey?: undefined + baseUrl?: undefined + } + | { + client?: undefined + /** Flashnet API key. */ + apiKey: string + /** Base URL for the Flashnet API. */ + baseUrl?: string | undefined + } /** * Provider component that makes a Flashnet client available to all hooks. * * @example * ```tsx - * import { FlashnetProvider } from '@mbga/flashnet' + * import { FlashnetProvider } from '@mbga/flashnet/react' * + * // With API key * function App() { * return ( * @@ -34,17 +41,30 @@ export type FlashnetProviderProps = { * * ) * } + * + * // With pre-configured client + * function App() { + * const client = createFlashnetClient({ apiKey: 'fn_...' }) + * return ( + * + * + * + * ) + * } * ``` */ export function FlashnetProvider( parameters: React.PropsWithChildren, ) { - const { children, apiKey, baseUrl, client: clientProp } = parameters + const { children, client: clientProp } = parameters const client = useMemo(() => { if (clientProp) return clientProp - return createFlashnetClient({ apiKey, baseUrl }) - }, [clientProp, apiKey, baseUrl]) + return createFlashnetClient({ + apiKey: parameters.apiKey as string, + baseUrl: parameters.baseUrl, + }) + }, [clientProp, parameters.apiKey, parameters.baseUrl]) return createElement(FlashnetContext.Provider, { value: client }, children) } diff --git a/packages/flashnet/src/exports/index.ts b/packages/flashnet/src/exports/index.ts index 0831110..1f78ace 100644 --- a/packages/flashnet/src/exports/index.ts +++ b/packages/flashnet/src/exports/index.ts @@ -5,17 +5,6 @@ // biome-ignore lint/performance/noBarrelFile: entrypoint module export { createFlashnetClient } from '../client' -//////////////////////////////////////////////////////////////////////////////// -// Context -//////////////////////////////////////////////////////////////////////////////// - -export { - FlashnetContext, - FlashnetProvider, - type FlashnetProviderProps, - useFlashnetClient, -} from '../context' - //////////////////////////////////////////////////////////////////////////////// // Actions //////////////////////////////////////////////////////////////////////////////// @@ -27,16 +16,6 @@ export { verifyAuth, } from '../actions/verifyAuth' -//////////////////////////////////////////////////////////////////////////////// -// Hooks -//////////////////////////////////////////////////////////////////////////////// - -export { - type UseFlashnetAuthParameters, - type UseFlashnetAuthReturnType, - useFlashnetAuth, -} from '../hooks/useFlashnetAuth' - //////////////////////////////////////////////////////////////////////////////// // Errors //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/flashnet/src/exports/react.ts b/packages/flashnet/src/exports/react.ts new file mode 100644 index 0000000..57f2a28 --- /dev/null +++ b/packages/flashnet/src/exports/react.ts @@ -0,0 +1,13 @@ +// biome-ignore lint/performance/noBarrelFile: entrypoint module +export { + FlashnetContext, + FlashnetProvider, + type FlashnetProviderProps, + useFlashnetClient, +} from '../context' + +export { + type UseFlashnetAuthParameters, + type UseFlashnetAuthReturnType, + useFlashnetAuth, +} from '../hooks/useFlashnetAuth' diff --git a/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx b/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx index 418893f..d68898e 100644 --- a/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx +++ b/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx @@ -8,9 +8,10 @@ import { useFlashnetAuth } from './useFlashnetAuth' function createMockClient( requestImpl?: FlashnetClient['request'], + baseUrl = 'https://test.flashnet.xyz', ): FlashnetClient { return { - baseUrl: 'https://test.flashnet.xyz', + baseUrl, hasApiKey: true, request: requestImpl ?? @@ -121,4 +122,48 @@ describe('useFlashnetAuth', () => { expect(result.current.isPending).toBe(true) expect(result.current.isAuthenticated).toBe(false) }) + + it('re-fetches when client changes (different baseUrl busts cache)', async () => { + const clientA = createMockClient( + vi.fn().mockResolvedValue({ authenticated: true, account_id: 'acct_a' }), + 'https://a.flashnet.xyz', + ) + const clientB = createMockClient( + vi.fn().mockResolvedValue({ authenticated: true, account_id: 'acct_b' }), + 'https://b.flashnet.xyz', + ) + + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + function Wrapper({ children }: { children: React.ReactNode }) { + return createElement( + QueryClientProvider, + { client: queryClient }, + children, + ) + } + + // Start with client A + const { result, rerender } = renderHook( + ({ client }: { client: FlashnetClient }) => useFlashnetAuth({ client }), + { + wrapper: Wrapper, + initialProps: { client: clientA }, + }, + ) + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true) + }) + expect(result.current.data?.accountId).toBe('acct_a') + + // Switch to client B + rerender({ client: clientB }) + + await waitFor(() => { + expect(result.current.data?.accountId).toBe('acct_b') + }) + }) }) diff --git a/packages/flashnet/src/hooks/useFlashnetAuth.ts b/packages/flashnet/src/hooks/useFlashnetAuth.ts index 490a4d4..955af80 100644 --- a/packages/flashnet/src/hooks/useFlashnetAuth.ts +++ b/packages/flashnet/src/hooks/useFlashnetAuth.ts @@ -60,7 +60,7 @@ export function useFlashnetAuth( const client = clientProp ?? contextClient const query = useQuery({ - queryKey: ['flashnet', 'auth'], + queryKey: ['flashnet', 'auth', client?.baseUrl], queryFn: () => { if (!client) { return { authenticated: false } satisfies FlashnetAuthStatus diff --git a/packages/flashnet/tsup.config.ts b/packages/flashnet/tsup.config.ts index 6e393ad..87cdf35 100644 --- a/packages/flashnet/tsup.config.ts +++ b/packages/flashnet/tsup.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/exports/index.ts', actions: 'src/exports/actions.ts', + react: 'src/exports/react.ts', }, format: ['cjs', 'esm'], dts: true, From 7ccffabea9d0da8f36a4c7f2d20e4ac388a9ff05 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 11:57:56 +0200 Subject: [PATCH 4/8] docs(flashnet): add package README, site pages, example app integration - Add packages/flashnet/README.md with vanilla and React quick start - Add root README entry for @mbga/flashnet - Add site/pages/flashnet/ with overview and authentication docs - Add Flashnet section to sidebar and vocs config - Add FlashnetAuth component + page to vite-react example - Add Flashnet snippet to playground page - Update getting-started, API index, and landing page to mention Flashnet Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 + examples/vite-react/package.json | 1 + examples/vite-react/src/App.tsx | 2 + .../src/components/FlashnetAuth.tsx | 102 +++++++++++ examples/vite-react/src/components/Layout.tsx | 1 + .../vite-react/src/pages/FlashnetPage.tsx | 16 ++ .../vite-react/src/pages/PlaygroundPage.tsx | 24 +++ packages/flashnet/README.md | 114 ++++++++++++ pnpm-lock.yaml | 3 + site/pages/api/index.mdx | 1 + site/pages/flashnet/authentication.mdx | 166 ++++++++++++++++++ site/pages/flashnet/index.mdx | 62 +++++++ site/pages/getting-started.mdx | 1 + site/pages/index.mdx | 1 + site/sidebar.ts | 7 + site/vocs.config.ts | 1 + 16 files changed, 504 insertions(+) create mode 100644 examples/vite-react/src/components/FlashnetAuth.tsx create mode 100644 examples/vite-react/src/pages/FlashnetPage.tsx create mode 100644 packages/flashnet/README.md create mode 100644 site/pages/flashnet/authentication.mdx create mode 100644 site/pages/flashnet/index.mdx diff --git a/README.md b/README.md index 3a26bfc..93767e3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Reactive primitives for Spark apps. - **Balance Queries** — fetch wallet balances with automatic caching - **Payments** — send payments, create invoices, estimate fees, and wait for payment confirmations - **Message Signing** — sign messages with connected wallets +- **Flashnet Auth** — API-key authentication for Flashnet orchestration (swaps, cross-chain) - **React Hooks** — first-class React bindings powered by TanStack Query - **TypeScript** — fully typed APIs with strict mode - **SSR Ready** — compatible with server-side rendering frameworks @@ -92,6 +93,7 @@ pnpm create mbga | [`@mbga/core`](./packages/core) | VanillaJS library for Spark | | [`@mbga/connectors`](./packages/connectors) | Collection of wallet connectors for MBGA | | [`@mbga/test`](./packages/test) | Test utilities for MBGA | +| [`@mbga/flashnet`](./packages/flashnet) | Flashnet authentication and orchestration | | [`create-mbga`](./packages/create-mbga) | Scaffold a new MBGA project | ## Community diff --git a/examples/vite-react/package.json b/examples/vite-react/package.json index 5a81dd1..6b228a8 100644 --- a/examples/vite-react/package.json +++ b/examples/vite-react/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@mbga/connectors": "workspace:*", + "@mbga/flashnet": "workspace:*", "@tanstack/react-query": "^5.49.2", "@mbga/kit": "workspace:*", "mbga": "workspace:*", diff --git a/examples/vite-react/src/App.tsx b/examples/vite-react/src/App.tsx index 2c91dff..a3c6115 100644 --- a/examples/vite-react/src/App.tsx +++ b/examples/vite-react/src/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Route, Routes } from 'react-router-dom' import { Layout } from './components/Layout' import { config } from './mbga' import { AddressPage } from './pages/AddressPage' +import { FlashnetPage } from './pages/FlashnetPage' import { FeesPage } from './pages/FeesPage' import { HomePage } from './pages/HomePage' import { PlaygroundPage } from './pages/PlaygroundPage' @@ -24,6 +25,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/examples/vite-react/src/components/FlashnetAuth.tsx b/examples/vite-react/src/components/FlashnetAuth.tsx new file mode 100644 index 0000000..6eaa4b9 --- /dev/null +++ b/examples/vite-react/src/components/FlashnetAuth.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react' +import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' + +function AuthStatus() { + const { isAuthenticated, isPending, isError, data, error, refetch } = + useFlashnetAuth() + + return ( +
+
+ + + {isPending + ? 'Verifying...' + : isAuthenticated + ? 'Authenticated' + : 'Not authenticated'} + +
+ + {data?.accountId && ( +

+ Account: {data.accountId} +

+ )} + + {isError && error && ( +

Error: {error.message}

+ )} + + +
+ ) +} + +export function FlashnetAuth() { + const [apiKey, setApiKey] = useState('') + const [active, setActive] = useState(false) + + if (active && apiKey) { + return ( +
+
+

Flashnet Auth Status

+ +
+ + + +
+ ) + } + + return ( +
{ + e.preventDefault() + if (apiKey.trim()) setActive(true) + }} + className="space-y-3 rounded-lg border border-[var(--color-border)] p-4" + > + + +
+ ) +} diff --git a/examples/vite-react/src/components/Layout.tsx b/examples/vite-react/src/components/Layout.tsx index 363f8c8..5785563 100644 --- a/examples/vite-react/src/components/Layout.tsx +++ b/examples/vite-react/src/components/Layout.tsx @@ -9,6 +9,7 @@ const navItems = [ { to: '/fees', label: 'Estimate Fee', icon: '≈' }, { to: '/transactions', label: 'Transactions', icon: '↔' }, { to: '/address', label: 'Address Tools', icon: '◎' }, + { to: '/flashnet', label: 'Flashnet Auth', icon: '⚡' }, { to: '/playground', label: 'Playground', icon: '▶' }, ] diff --git a/examples/vite-react/src/pages/FlashnetPage.tsx b/examples/vite-react/src/pages/FlashnetPage.tsx new file mode 100644 index 0000000..1ebef3b --- /dev/null +++ b/examples/vite-react/src/pages/FlashnetPage.tsx @@ -0,0 +1,16 @@ +import { FlashnetAuth } from '../components/FlashnetAuth' + +export function FlashnetPage() { + return ( + <> +

Flashnet Auth

+

+ Authenticate with the Flashnet orchestration API using an API key. +

+ +
+ +
+ + ) +} diff --git a/examples/vite-react/src/pages/PlaygroundPage.tsx b/examples/vite-react/src/pages/PlaygroundPage.tsx index 0741a5b..6699330 100644 --- a/examples/vite-react/src/pages/PlaygroundPage.tsx +++ b/examples/vite-react/src/pages/PlaygroundPage.tsx @@ -2,6 +2,7 @@ import { type ReactNode, useState } from 'react' import { AddressValidator } from '../components/AddressValidator' import { Balance } from '../components/Balance' import { EstimateFee } from '../components/EstimateFee' +import { FlashnetAuth } from '../components/FlashnetAuth' import { ReceivePayment } from '../components/ReceivePayment' import { SendPayment } from '../components/SendPayment' import { SignMessage } from '../components/SignMessage' @@ -170,6 +171,29 @@ isValidLightningInvoice('lnbc1pv...') // true isValidSparkAddress('sp1qq...') // true`, component: , }, + { + title: 'Flashnet Auth', + description: + 'Authenticate with the Flashnet orchestration API using an API key.', + code: `import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' + +function App() { + return ( + + + + ) +} + +function AuthStatus() { + const { isAuthenticated, isPending, data } = useFlashnetAuth() + + if (isPending) return

Verifying...

+ if (isAuthenticated) return

Authenticated as {data?.accountId}

+ return

Not authenticated

+}`, + component: , + }, ] export function PlaygroundPage() { diff --git a/packages/flashnet/README.md b/packages/flashnet/README.md new file mode 100644 index 0000000..72627b5 --- /dev/null +++ b/packages/flashnet/README.md @@ -0,0 +1,114 @@ +# @mbga/flashnet + +Flashnet integration for [MBGA](../react) -- authenticate with the [Flashnet](https://flashnet.xyz/) orchestration API for cross-chain swaps and trades on Spark. + +**Works with or without React.** The root import (`@mbga/flashnet`) is framework-agnostic. React hooks and context are available via `@mbga/flashnet/react`. + +## Installation + +```bash +npm install @mbga/flashnet +pnpm add @mbga/flashnet +yarn add @mbga/flashnet +``` + +For React hooks, also install the peer dependencies: + +```bash +pnpm add react @tanstack/react-query +``` + +## Quick Start (Vanilla) + +```ts +import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' + +// 1. Create a client with your API key +const client = createFlashnetClient({ + apiKey: 'fn_your_api_key', +}) + +// 2. Verify authentication +const status = await verifyAuth(client) + +if (status.authenticated) { + console.log('Authenticated as', status.accountId) +} + +// 3. Make authenticated requests +const routes = await client.request('/v1/routes') +``` + +## Quick Start (React) + +```tsx +import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' + +// Wrap your app (or a subtree) with FlashnetProvider +function App() { + return ( + + + + ) +} + +function AuthStatus() { + const { isAuthenticated, isPending } = useFlashnetAuth() + + if (isPending) return

Verifying...

+ if (isAuthenticated) return

Authenticated!

+ return

Not authenticated

+} +``` + +## API + +### Client + +| Export | Description | +|--------|-------------| +| `createFlashnetClient` | Create an authenticated Flashnet API client | +| `verifyAuth` | Verify the client's authentication status | + +### React (`@mbga/flashnet/react`) + +| Export | Description | +|--------|-------------| +| `FlashnetProvider` | Context provider -- accepts `apiKey` or a pre-built `client` | +| `useFlashnetClient` | Access the Flashnet client from context | +| `useFlashnetAuth` | Query authentication status (caches for 5 min) | + +### Errors + +| Error | Description | +|-------|-------------| +| `FlashnetAuthError` | Invalid or expired API key | +| `FlashnetClientNotConfiguredError` | Action called without a client | +| `FlashnetRequestError` | Non-auth API error (includes `status` and `code`) | +| `FlashnetProviderNotFoundError` | Hook used outside `FlashnetProvider` | + +## Sub-path Exports + +```ts +// Framework-agnostic client + actions + errors + types +import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' + +// Just actions (tree-shakeable) +import { verifyAuth } from '@mbga/flashnet/actions' + +// React hooks + context +import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' +``` + +## Related Packages + +| Package | Description | +|---------|-------------| +| [`mbga`](../react) | React hooks for Spark | +| [`@mbga/core`](../core) | Framework-agnostic core library | +| [`@mbga/connectors`](../connectors) | Wallet connector implementations | + +## License + +Apache-2.0 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 390bbb5..5824cf2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,9 @@ importers: '@mbga/connectors': specifier: workspace:* version: link:../../packages/connectors + '@mbga/flashnet': + specifier: workspace:* + version: link:../../packages/flashnet '@mbga/kit': specifier: workspace:* version: link:../../packages/kit diff --git a/site/pages/api/index.mdx b/site/pages/api/index.mdx index 5cb0c35..f062172 100644 --- a/site/pages/api/index.mdx +++ b/site/pages/api/index.mdx @@ -14,6 +14,7 @@ Complete reference for all public APIs in the MBGA library. | `@mbga/connectors` | `import { ... } from '@mbga/connectors'` | Wallet connector implementations | | `@mbga/core` | `import { ... } from '@mbga/core'` | Framework-agnostic core (config, actions, state) | | `@mbga/kit` | `import { ... } from '@mbga/kit'` | Pre-built UI components | +| `@mbga/flashnet` | `import { ... } from '@mbga/flashnet'` | Flashnet authentication and orchestration | ## Sections diff --git a/site/pages/flashnet/authentication.mdx b/site/pages/flashnet/authentication.mdx new file mode 100644 index 0000000..e926aeb --- /dev/null +++ b/site/pages/flashnet/authentication.mdx @@ -0,0 +1,166 @@ +--- +outline: deep +--- + +# Flashnet Authentication + +Authenticate with the Flashnet orchestration API using an API key. This is required before performing any swap or trade operations. + +## Vanilla JS + +### Create a Client + +```ts +import { createFlashnetClient } from '@mbga/flashnet' + +const client = createFlashnetClient({ + apiKey: 'fn_your_api_key', + // Optional: override the base URL (defaults to https://orchestration.flashnet.xyz) + baseUrl: 'https://custom.api.xyz', +}) +``` + +The client automatically adds `Authorization: Bearer ` to every request. + +### Verify Authentication + +```ts +import { verifyAuth } from '@mbga/flashnet' + +const status = await verifyAuth(client) + +if (status.authenticated) { + console.log('Authenticated as', status.accountId) +} else { + console.log('Invalid or expired API key') +} +``` + +`verifyAuth` calls `/v1/auth/verify` and returns `{ authenticated: false }` for invalid credentials instead of throwing -- so you can handle it gracefully. + +### Make Authenticated Requests + +```ts +// GET request +const routes = await client.request('/v1/routes') + +// POST request with body and idempotency key +const order = await client.request('/v1/orders', { + method: 'POST', + body: { amount: '1000', pair: 'BTC/SPARK' }, + idempotencyKey: 'order-abc-123', +}) +``` + +## React + +### FlashnetProvider + +Wrap your app (or a subtree) with `FlashnetProvider` to make the client available to hooks. + +**With an API key:** + +```tsx +import { FlashnetProvider } from '@mbga/flashnet/react' + +function App() { + return ( + + + + ) +} +``` + +**With a pre-configured client:** + +```tsx +import { createFlashnetClient } from '@mbga/flashnet' +import { FlashnetProvider } from '@mbga/flashnet/react' + +const client = createFlashnetClient({ apiKey: 'fn_...' }) + +function App() { + return ( + + + + ) +} +``` + +### useFlashnetAuth + +Automatically verifies authentication on mount. Results are cached for 5 minutes. + +```tsx +import { useFlashnetAuth } from '@mbga/flashnet/react' + +function AuthStatus() { + const { isAuthenticated, isPending, data, error, refetch } = useFlashnetAuth() + + if (isPending) return

Verifying...

+ if (error) return

Error: {error.message}

+ if (isAuthenticated) return

Authenticated as {data?.accountId}

+ return

Not authenticated

+} +``` + +**Parameters:** + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `client` | `FlashnetClient` | context | Override the context client | +| `enabled` | `boolean` | `true` | Enable/disable automatic verification | + +**Return value:** + +| Field | Type | Description | +|-------|------|-------------| +| `isAuthenticated` | `boolean` | Whether the client is authenticated | +| `isPending` | `boolean` | Whether verification is in progress | +| `isSuccess` | `boolean` | Whether verification succeeded | +| `isError` | `boolean` | Whether verification failed with a non-auth error | +| `data` | `FlashnetAuthStatus` | `{ authenticated, accountId }` | +| `error` | `Error \| null` | Error from the last attempt | +| `refetch` | `() => void` | Re-run verification | + +### useFlashnetClient + +Access the Flashnet client from context for direct API calls. + +```tsx +import { useFlashnetClient } from '@mbga/flashnet/react' + +function MyComponent() { + const client = useFlashnetClient() + + async function fetchRoutes() { + const routes = await client.request('/v1/routes') + // ... + } +} +``` + +## Error Handling + +| Error | When | +|-------|------| +| `FlashnetAuthError` | API key is invalid, expired, or missing | +| `FlashnetRequestError` | Non-auth API error (check `.status` and `.code`) | +| `FlashnetClientNotConfiguredError` | Action called without creating a client first | +| `FlashnetProviderNotFoundError` | React hook used outside `FlashnetProvider` | + +```ts +import { FlashnetAuthError, FlashnetRequestError } from '@mbga/flashnet' + +try { + await client.request('/v1/orders', { method: 'POST', body: { ... } }) +} catch (error) { + if (error instanceof FlashnetAuthError) { + // Re-authenticate or refresh API key + } else if (error instanceof FlashnetRequestError) { + console.log(error.status, error.code, error.message) + } +} +``` diff --git a/site/pages/flashnet/index.mdx b/site/pages/flashnet/index.mdx new file mode 100644 index 0000000..4d3164c --- /dev/null +++ b/site/pages/flashnet/index.mdx @@ -0,0 +1,62 @@ +--- +outline: deep +--- + +# Flashnet + +[Flashnet](https://flashnet.xyz/) is a cross-chain orchestration layer that enables atomic swaps between BTC, Lightning, Spark, and other assets. The `@mbga/flashnet` package provides API-key authentication and a typed client for interacting with the Flashnet API. + +## Installation + +:::code-group + +```bash [pnpm] +pnpm add @mbga/flashnet +``` + +```bash [npm] +npm install @mbga/flashnet +``` + +```bash [yarn] +yarn add @mbga/flashnet +``` + +::: + +For React hooks, also install the optional peer dependencies: + +```bash +pnpm add react @tanstack/react-query +``` + +## Architecture + +`@mbga/flashnet` is structured with separate entrypoints so you only pay for what you use: + +| Entrypoint | Import path | Requires React | +|------------|-------------|---------------| +| Root | `@mbga/flashnet` | No | +| Actions | `@mbga/flashnet/actions` | No | +| React | `@mbga/flashnet/react` | Yes | + +The root entrypoint exports the client factory, actions, errors, and types -- everything needed for a vanilla JS/TS app. The `/react` entrypoint adds `FlashnetProvider` and hooks. + +## Quick Example + +```ts +import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' + +const client = createFlashnetClient({ + apiKey: 'fn_your_api_key', +}) + +const status = await verifyAuth(client) +if (status.authenticated) { + console.log('Account:', status.accountId) +} +``` + +## Next Steps + +- [Authentication](/flashnet/authentication) -- set up API-key auth with the client or React provider diff --git a/site/pages/getting-started.mdx b/site/pages/getting-started.mdx index 18e7998..251aa4c 100644 --- a/site/pages/getting-started.mdx +++ b/site/pages/getting-started.mdx @@ -107,6 +107,7 @@ MBGA abstracts all three behind a single `sendPayment` call. | `@mbga/connectors` | Wallet connectors (Sats Connect, Spark SDK, Wallet Standard) | | `@mbga/core` | Framework-agnostic config, actions, and state | | `@mbga/kit` | Pre-built UI components | +| `@mbga/flashnet` | Flashnet authentication and orchestration | ## Next Steps diff --git a/site/pages/index.mdx b/site/pages/index.mdx index 4b58687..7b88474 100644 --- a/site/pages/index.mdx +++ b/site/pages/index.mdx @@ -123,6 +123,7 @@ mbga supports all these features out-of-the-box: - [Create invoices](/api/hooks#usecreateinvoice) and [wait for payments](/api/hooks#usewaitforpayment) - [Sign messages](/api/hooks#usesignmessage) with BIP-322 - [Estimate fees](/api/hooks#useestimatefee) before sending +- Authenticate with [Flashnet](/flashnet) for cross-chain swaps and orchestration - Framework-agnostic [core actions](/api/core-actions) for vanilla JS usage - TypeScript ready with [strict typing](/api/types) throughout diff --git a/site/sidebar.ts b/site/sidebar.ts index 22bd60e..59b688c 100644 --- a/site/sidebar.ts +++ b/site/sidebar.ts @@ -23,6 +23,13 @@ export const sidebar = [ { text: 'Next.js', link: '/frameworks/nextjs' }, ], }, + { + text: 'Flashnet', + items: [ + { text: 'Overview', link: '/flashnet' }, + { text: 'Authentication', link: '/flashnet/authentication' }, + ], + }, { text: 'API Reference', collapsed: true, diff --git a/site/vocs.config.ts b/site/vocs.config.ts index f3ed93d..c0ce67c 100644 --- a/site/vocs.config.ts +++ b/site/vocs.config.ts @@ -45,6 +45,7 @@ export default defineConfig({ '/installation': sidebar, '/connectors': sidebar, '/frameworks': sidebar, + '/flashnet': sidebar, '/api': sidebar, '/examples': sidebar, '/acknowledgments': sidebar, From 8ce8b6ec73e0a33bc448f46d8e957f96b5dde978 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 12:15:02 +0200 Subject: [PATCH 5/8] refactor(flashnet): rework to challenge-response auth with extension pattern Replace the incorrect API-key auth with the real Flashnet challenge-response flow (POST /v1/auth/challenge -> sign -> POST /v1/auth/verify -> access token). - Add createFlashnetExtension() factory with .plugin for MBGA config - Add FlashnetSigner interface + createSignerFromConfig() adapter - Add challengeResponse() implementing the full auth flow - Add signIntent() for intent-based operation signing - Add sha256() and toHex() crypto utilities (Web Crypto API) - Rewrite FlashnetProvider to accept an extension prop - Replace useFlashnetAuth with useSyncExternalStore-based hook - Add useFlashnetAuthenticate mutation hook - Remove old createFlashnetClient, verifyAuth, API-key types - Rewrite all tests, docs, example app, and playground snippet Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/FlashnetAuth.tsx | 69 +++-- .../vite-react/src/pages/PlaygroundPage.tsx | 16 +- packages/flashnet/README.md | 120 +++++---- .../flashnet/src/actions/verifyAuth.test.ts | 90 ------- packages/flashnet/src/actions/verifyAuth.ts | 65 ----- packages/flashnet/src/auth.test.ts | 141 ++++++++++ packages/flashnet/src/auth.ts | 95 +++++++ packages/flashnet/src/client.test.ts | 248 ------------------ packages/flashnet/src/client.ts | 111 -------- packages/flashnet/src/context.test.tsx | 79 ++---- packages/flashnet/src/context.ts | 60 ++--- packages/flashnet/src/crypto.ts | 15 ++ packages/flashnet/src/errors.test.ts | 59 ----- packages/flashnet/src/errors.ts | 60 ++++- packages/flashnet/src/exports/actions.ts | 9 +- packages/flashnet/src/exports/index.ts | 51 +++- packages/flashnet/src/exports/react.ts | 8 +- packages/flashnet/src/extension.test.ts | 237 +++++++++++++++++ packages/flashnet/src/extension.ts | 204 ++++++++++++++ .../src/hooks/useFlashnetAuth.test.tsx | 206 +++++++-------- .../flashnet/src/hooks/useFlashnetAuth.ts | 91 +++---- .../src/hooks/useFlashnetAuthenticate.ts | 86 ++++++ packages/flashnet/src/intent.test.ts | 46 ++++ packages/flashnet/src/intent.ts | 29 ++ packages/flashnet/src/signer.test.ts | 74 ++++++ packages/flashnet/src/signer.ts | 95 +++++++ packages/flashnet/src/types.ts | 135 ++++++++-- site/pages/flashnet/authentication.mdx | 201 ++++++++------ site/pages/flashnet/index.mdx | 53 +++- 29 files changed, 1687 insertions(+), 1066 deletions(-) delete mode 100644 packages/flashnet/src/actions/verifyAuth.test.ts delete mode 100644 packages/flashnet/src/actions/verifyAuth.ts create mode 100644 packages/flashnet/src/auth.test.ts create mode 100644 packages/flashnet/src/auth.ts delete mode 100644 packages/flashnet/src/client.test.ts delete mode 100644 packages/flashnet/src/client.ts create mode 100644 packages/flashnet/src/crypto.ts delete mode 100644 packages/flashnet/src/errors.test.ts create mode 100644 packages/flashnet/src/extension.test.ts create mode 100644 packages/flashnet/src/extension.ts create mode 100644 packages/flashnet/src/hooks/useFlashnetAuthenticate.ts create mode 100644 packages/flashnet/src/intent.test.ts create mode 100644 packages/flashnet/src/intent.ts create mode 100644 packages/flashnet/src/signer.test.ts create mode 100644 packages/flashnet/src/signer.ts diff --git a/examples/vite-react/src/components/FlashnetAuth.tsx b/examples/vite-react/src/components/FlashnetAuth.tsx index 6eaa4b9..6fc41ad 100644 --- a/examples/vite-react/src/components/FlashnetAuth.tsx +++ b/examples/vite-react/src/components/FlashnetAuth.tsx @@ -1,16 +1,24 @@ +import { createFlashnetExtension } from '@mbga/flashnet' +import { + FlashnetProvider, + useFlashnetAuth, + useFlashnetExtension, +} from '@mbga/flashnet/react' import { useState } from 'react' -import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' + +const flashnet = createFlashnetExtension() function AuthStatus() { - const { isAuthenticated, isPending, isError, data, error, refetch } = + const { isAuthenticated, isAuthenticating, isError, publicKey, error } = useFlashnetAuth() + const extension = useFlashnetExtension() return (
- {isPending - ? 'Verifying...' + {isAuthenticating + ? 'Authenticating...' : isAuthenticated ? 'Authenticated' : 'Not authenticated'}
- {data?.accountId && ( + {publicKey && (

- Account: {data.accountId} + Public Key: {publicKey}

)} @@ -38,21 +46,20 @@ function AuthStatus() {
) } export function FlashnetAuth() { - const [apiKey, setApiKey] = useState('') const [active, setActive] = useState(false) - if (active && apiKey) { + if (active) { return (
@@ -65,38 +72,30 @@ export function FlashnetAuth() { Reset
- + +

+ Note: Authentication requires a connected wallet with identity key + signing (e.g. Spark SDK connector). +

) } return ( -
{ - e.preventDefault() - if (apiKey.trim()) setActive(true) - }} - className="space-y-3 rounded-lg border border-[var(--color-border)] p-4" - > - +
+

+ Flashnet uses wallet-based challenge-response authentication. Connect a + Spark SDK wallet, then authenticate to get an access token. +

- +
) } diff --git a/examples/vite-react/src/pages/PlaygroundPage.tsx b/examples/vite-react/src/pages/PlaygroundPage.tsx index 6699330..991e236 100644 --- a/examples/vite-react/src/pages/PlaygroundPage.tsx +++ b/examples/vite-react/src/pages/PlaygroundPage.tsx @@ -174,22 +174,26 @@ isValidSparkAddress('sp1qq...') // true`, { title: 'Flashnet Auth', description: - 'Authenticate with the Flashnet orchestration API using an API key.', - code: `import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' + 'Challenge-response authentication with the Flashnet orchestration API.', + code: `import { createFlashnetExtension } from '@mbga/flashnet' +import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' + +const flashnet = createFlashnetExtension() +// Register flashnet.plugin in createConfig({ plugins: [...] }) function App() { return ( - + ) } function AuthStatus() { - const { isAuthenticated, isPending, data } = useFlashnetAuth() + const { isAuthenticated, isAuthenticating, publicKey } = useFlashnetAuth() - if (isPending) return

Verifying...

- if (isAuthenticated) return

Authenticated as {data?.accountId}

+ if (isAuthenticating) return

Authenticating...

+ if (isAuthenticated) return

Authenticated: {publicKey}

return

Not authenticated

}`, component: , diff --git a/packages/flashnet/README.md b/packages/flashnet/README.md index 72627b5..ea3f20f 100644 --- a/packages/flashnet/README.md +++ b/packages/flashnet/README.md @@ -1,6 +1,6 @@ # @mbga/flashnet -Flashnet integration for [MBGA](../react) -- authenticate with the [Flashnet](https://flashnet.xyz/) orchestration API for cross-chain swaps and trades on Spark. +Flashnet integration for [MBGA](../react) -- challenge-response authentication and intent signing for the [Flashnet](https://flashnet.xyz/) orchestration API. **Works with or without React.** The root import (`@mbga/flashnet`) is framework-agnostic. React hooks and context are available via `@mbga/flashnet/react`. @@ -18,88 +18,118 @@ For React hooks, also install the peer dependencies: pnpm add react @tanstack/react-query ``` -## Quick Start (Vanilla) +## How It Works -```ts -import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' +Flashnet uses **challenge-response authentication** with wallet signing: + +1. Your app requests a challenge from the Flashnet API +2. The connected wallet signs the challenge with its identity key +3. The signed challenge is verified to obtain an access token +4. All subsequent requests use the access token as a Bearer token + +Operations use **intent-based signing** -- each action (swap, add liquidity, etc.) generates a JSON intent message that is SHA256-hashed and signed by the wallet. + +## Quick Start (Extension + MBGA Config) -// 1. Create a client with your API key -const client = createFlashnetClient({ - apiKey: 'fn_your_api_key', +```ts +import { createFlashnetExtension } from '@mbga/flashnet' +import { createConfig, sparkMainnet } from '@mbga/core' +import { sparkSdk } from '@mbga/connectors' + +// 1. Create the extension +const flashnet = createFlashnetExtension() + +// 2. Register the plugin in your MBGA config +const config = createConfig({ + network: sparkMainnet, + connectors: [sparkSdk({ mnemonic: '...' })], + plugins: [flashnet.plugin], // auto-clears auth on wallet disconnect }) -// 2. Verify authentication -const status = await verifyAuth(client) +// 3. After connecting a wallet, authenticate +const { accessToken } = await flashnet.authenticate(config) -if (status.authenticated) { - console.log('Authenticated as', status.accountId) -} +// 4. Make authenticated requests +const routes = await flashnet.request('/v1/routes') +``` + +## Standalone (Custom Signer) + +```ts +import { createFlashnetExtension, createSigner } from '@mbga/flashnet' + +const flashnet = createFlashnetExtension() + +const signer = createSigner({ + publicKey: '02abc...', + sign: async (messageHash) => myLib.sign(privateKey, messageHash), +}) -// 3. Make authenticated requests -const routes = await client.request('/v1/routes') +await flashnet.authenticateWithSigner(signer) ``` -## Quick Start (React) +## React ```tsx import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' +import { createFlashnetExtension } from '@mbga/flashnet' + +const flashnet = createFlashnetExtension() -// Wrap your app (or a subtree) with FlashnetProvider function App() { return ( - - - + + + + + ) } function AuthStatus() { - const { isAuthenticated, isPending } = useFlashnetAuth() + const { isAuthenticated, isAuthenticating, publicKey } = useFlashnetAuth() - if (isPending) return

Verifying...

- if (isAuthenticated) return

Authenticated!

+ if (isAuthenticating) return

Authenticating...

+ if (isAuthenticated) return

Authenticated: {publicKey}

return

Not authenticated

} ``` ## API -### Client +### Extension + +| Export | Description | +|--------|-------------| +| `createFlashnetExtension` | Create an extension with `.plugin`, `.authenticate()`, `.request()`, etc. | +| `createSigner` | Create a signer from an explicit public key + sign function | +| `createSignerFromConfig` | Create a signer from the connected MBGA wallet | + +### Actions (`@mbga/flashnet/actions`) | Export | Description | |--------|-------------| -| `createFlashnetClient` | Create an authenticated Flashnet API client | -| `verifyAuth` | Verify the client's authentication status | +| `challengeResponse` | Low-level challenge-response auth flow | +| `signIntent` | Sign a Flashnet intent message (JSON -> SHA256 -> sign) | ### React (`@mbga/flashnet/react`) | Export | Description | |--------|-------------| -| `FlashnetProvider` | Context provider -- accepts `apiKey` or a pre-built `client` | -| `useFlashnetClient` | Access the Flashnet client from context | -| `useFlashnetAuth` | Query authentication status (caches for 5 min) | +| `FlashnetProvider` | Context provider -- accepts an `extension` | +| `useFlashnetExtension` | Access the extension from context | +| `useFlashnetAuth` | Reactive auth state (via `useSyncExternalStore`) | +| `useFlashnetAuthenticate` | Mutation hook to trigger authentication | ### Errors | Error | Description | |-------|-------------| -| `FlashnetAuthError` | Invalid or expired API key | -| `FlashnetClientNotConfiguredError` | Action called without a client | -| `FlashnetRequestError` | Non-auth API error (includes `status` and `code`) | -| `FlashnetProviderNotFoundError` | Hook used outside `FlashnetProvider` | - -## Sub-path Exports - -```ts -// Framework-agnostic client + actions + errors + types -import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' - -// Just actions (tree-shakeable) -import { verifyAuth } from '@mbga/flashnet/actions' - -// React hooks + context -import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' -``` +| `FlashnetAuthError` | Challenge-response or token verification failed | +| `FlashnetChallengeError` | Challenge request to the API failed | +| `FlashnetSigningError` | Wallet signing failed | +| `FlashnetNotAuthenticatedError` | Action called before authenticating | +| `FlashnetRequestError` | Non-auth API error (includes `.status` and `.code`) | ## Related Packages diff --git a/packages/flashnet/src/actions/verifyAuth.test.ts b/packages/flashnet/src/actions/verifyAuth.test.ts deleted file mode 100644 index 1b78aaa..0000000 --- a/packages/flashnet/src/actions/verifyAuth.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { FlashnetClientNotConfiguredError } from '../errors' -import type { FlashnetClient } from '../types' -import { verifyAuth } from './verifyAuth' - -function createMockClient( - requestImpl: FlashnetClient['request'] = vi.fn(), -): FlashnetClient { - return { - baseUrl: 'https://test.flashnet.xyz', - hasApiKey: true, - request: requestImpl, - } -} - -describe('verifyAuth', () => { - it('returns authenticated status on success', async () => { - const client = createMockClient( - vi.fn().mockResolvedValue({ - authenticated: true, - account_id: 'acct_123', - }), - ) - - const result = await verifyAuth(client) - - expect(result).toEqual({ - authenticated: true, - accountId: 'acct_123', - }) - }) - - it('calls /v1/auth/verify endpoint', async () => { - const request = vi.fn().mockResolvedValue({ authenticated: true }) - const client = createMockClient(request) - - await verifyAuth(client) - - expect(request).toHaveBeenCalledWith('/v1/auth/verify', { - signal: undefined, - }) - }) - - it('forwards abort signal', async () => { - const controller = new AbortController() - const request = vi.fn().mockResolvedValue({ authenticated: true }) - const client = createMockClient(request) - - await verifyAuth(client, { signal: controller.signal }) - - expect(request).toHaveBeenCalledWith('/v1/auth/verify', { - signal: controller.signal, - }) - }) - - it('returns authenticated: false on auth error', async () => { - const authError = new Error('Invalid token') - authError.name = 'FlashnetAuthError' - - const client = createMockClient(vi.fn().mockRejectedValue(authError)) - - const result = await verifyAuth(client) - - expect(result).toEqual({ authenticated: false }) - }) - - it('re-throws non-auth errors', async () => { - const networkError = new Error('Network failure') - const client = createMockClient(vi.fn().mockRejectedValue(networkError)) - - await expect(verifyAuth(client)).rejects.toThrow('Network failure') - }) - - it('throws if client is null', async () => { - await expect(verifyAuth(null as unknown as FlashnetClient)).rejects.toThrow( - FlashnetClientNotConfiguredError, - ) - }) - - it('defaults authenticated to true when field is missing', async () => { - const client = createMockClient( - vi.fn().mockResolvedValue({ account_id: 'acct_456' }), - ) - - const result = await verifyAuth(client) - - expect(result.authenticated).toBe(true) - expect(result.accountId).toBe('acct_456') - }) -}) diff --git a/packages/flashnet/src/actions/verifyAuth.ts b/packages/flashnet/src/actions/verifyAuth.ts deleted file mode 100644 index bd73fd9..0000000 --- a/packages/flashnet/src/actions/verifyAuth.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { FlashnetClientNotConfiguredError } from '../errors' -import type { FlashnetAuthStatus, FlashnetClient } from '../types' - -/** Parameters for {@link verifyAuth}. */ -export type VerifyAuthParameters = { - /** Optional abort signal. */ - signal?: AbortSignal | undefined -} - -/** Return type of {@link verifyAuth}. */ -export type VerifyAuthReturnType = FlashnetAuthStatus - -/** Error types that {@link verifyAuth} may throw. */ -export type VerifyAuthErrorType = Error - -/** - * Verifies that the Flashnet client is authenticated by making a lightweight - * request to the API. Returns the auth status without throwing on invalid credentials. - * - * @param client - The Flashnet client instance. - * @param parameters - Optional parameters (e.g. abort signal). - * @returns The authentication status. - * - * @example - * ```ts - * import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' - * - * const client = createFlashnetClient({ apiKey: 'fn_...' }) - * const status = await verifyAuth(client) - * - * if (status.authenticated) { - * console.log('Authenticated as', status.accountId) - * } - * ``` - */ -export async function verifyAuth( - client: FlashnetClient, - parameters: VerifyAuthParameters = {}, -): Promise { - if (!client) { - throw new FlashnetClientNotConfiguredError() - } - - try { - const result = await client.request<{ - authenticated?: boolean - account_id?: string - }>('/v1/auth/verify', { - signal: parameters.signal, - }) - - return { - authenticated: result.authenticated ?? true, - accountId: result.account_id, - } - } catch (error) { - // Auth errors mean credentials are invalid but we don't throw — - // we return { authenticated: false } so the caller can handle it gracefully. - if (error instanceof Error && error.name === 'FlashnetAuthError') { - return { authenticated: false } - } - - throw error - } -} diff --git a/packages/flashnet/src/auth.test.ts b/packages/flashnet/src/auth.test.ts new file mode 100644 index 0000000..52be0d2 --- /dev/null +++ b/packages/flashnet/src/auth.test.ts @@ -0,0 +1,141 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { challengeResponse } from './auth' +import { FlashnetAuthError, FlashnetChallengeError } from './errors' +import type { FlashnetSigner } from './types' + +const mockFetch = vi.fn() + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function createMockSigner( + publicKey = '02abc123', + signResult = new Uint8Array([0xde, 0xad, 0xbe, 0xef]), +): FlashnetSigner { + return { + getPublicKey: vi.fn().mockResolvedValue(publicKey), + signMessage: vi.fn().mockResolvedValue(signResult), + } +} + +describe('challengeResponse', () => { + it('completes the full challenge-response flow', async () => { + // Step 1: challenge response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'aabbccdd', + challengeString: 'Sign this challenge: aabbccdd', + }), + }) + // Step 2: verify response + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + accessToken: 'tok_abc123', + }), + }) + + const signer = createMockSigner() + const result = await challengeResponse('https://test.flashnet.xyz', signer) + + expect(result.accessToken).toBe('tok_abc123') + expect(result.publicKey).toBe('02abc123') + + // Verify challenge request + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.flashnet.xyz/v1/auth/challenge', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ publicKey: '02abc123' }), + }), + ) + + // Verify the signer was called with a SHA256 hash (32 bytes) + expect(signer.signMessage).toHaveBeenCalledWith(expect.any(Uint8Array)) + const hashArg = (signer.signMessage as ReturnType).mock + .calls[0]![0] as Uint8Array + expect(hashArg.length).toBe(32) + + // Verify verify request + expect(mockFetch).toHaveBeenCalledWith( + 'https://test.flashnet.xyz/v1/auth/verify', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + publicKey: '02abc123', + signature: 'deadbeef', + }), + }), + ) + }) + + it('throws FlashnetChallengeError on challenge failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }) + + const signer = createMockSigner() + await expect( + challengeResponse('https://test.flashnet.xyz', signer), + ).rejects.toThrow(FlashnetChallengeError) + }) + + it('throws FlashnetChallengeError when no challenge returned', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ challenge: 'aabb' }), + }) + + const signer = createMockSigner() + await expect( + challengeResponse('https://test.flashnet.xyz', signer), + ).rejects.toThrow(FlashnetChallengeError) + }) + + it('throws FlashnetAuthError on verify failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'aabb', + challengeString: 'Sign this: aabb', + }), + }) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Invalid signature', + }) + + const signer = createMockSigner() + await expect( + challengeResponse('https://test.flashnet.xyz', signer), + ).rejects.toThrow(FlashnetAuthError) + }) + + it('throws FlashnetAuthError when no access token returned', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'aabb', + challengeString: 'Sign this: aabb', + }), + }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({}), + }) + + const signer = createMockSigner() + await expect( + challengeResponse('https://test.flashnet.xyz', signer), + ).rejects.toThrow(FlashnetAuthError) + }) +}) diff --git a/packages/flashnet/src/auth.ts b/packages/flashnet/src/auth.ts new file mode 100644 index 0000000..9ff5520 --- /dev/null +++ b/packages/flashnet/src/auth.ts @@ -0,0 +1,95 @@ +import { sha256, toHex } from './crypto' +import { + FlashnetAuthError, + FlashnetChallengeError, + FlashnetSigningError, +} from './errors' +import type { + ChallengeResponse, + FlashnetAuthResult, + FlashnetSigner, + VerifyResponse, +} from './types' + +/** + * Perform the Flashnet challenge-response authentication flow. + * + * 1. POST `/v1/auth/challenge` with the signer's public key + * 2. Sign the challenge string (SHA256 + secp256k1) + * 3. POST `/v1/auth/verify` with the public key and signature + * 4. Return the access token + * + * @param gatewayUrl - The Flashnet AMM gateway base URL. + * @param signer - The signer to use for the challenge-response. + * @returns The auth result with access token and public key. + */ +export async function challengeResponse( + gatewayUrl: string, + signer: FlashnetSigner, +): Promise { + const publicKey = await signer.getPublicKey() + + // Step 1: Request challenge + const challengeRes = await fetch(`${gatewayUrl}/v1/auth/challenge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicKey }), + }) + + if (!challengeRes.ok) { + const text = await challengeRes.text().catch(() => '') + throw new FlashnetChallengeError( + `Challenge request failed (${challengeRes.status}): ${text}`, + ) + } + + const challengeData = (await challengeRes.json()) as ChallengeResponse + + if (!challengeData.challengeString) { + throw new FlashnetChallengeError('No challenge received from server.') + } + + // Step 2: Sign the challenge + // The challenge string is hashed with SHA256 before signing + let signature: Uint8Array + try { + const challengeBytes = new TextEncoder().encode( + challengeData.challengeString, + ) + const messageHash = await sha256(challengeBytes) + signature = await signer.signMessage(messageHash) + } catch (error) { + if (error instanceof FlashnetSigningError) throw error + throw new FlashnetSigningError( + `Failed to sign challenge: ${error instanceof Error ? error.message : 'Unknown error'}`, + ) + } + + // Step 3: Verify signature and get access token + const verifyRes = await fetch(`${gatewayUrl}/v1/auth/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publicKey, + signature: toHex(signature), + }), + }) + + if (!verifyRes.ok) { + const text = await verifyRes.text().catch(() => '') + throw new FlashnetAuthError( + `Verification failed (${verifyRes.status}): ${text}`, + ) + } + + const verifyData = (await verifyRes.json()) as VerifyResponse + + if (!verifyData.accessToken) { + throw new FlashnetAuthError('No access token received from server.') + } + + return { + accessToken: verifyData.accessToken, + publicKey, + } +} diff --git a/packages/flashnet/src/client.test.ts b/packages/flashnet/src/client.test.ts deleted file mode 100644 index 827cfee..0000000 --- a/packages/flashnet/src/client.test.ts +++ /dev/null @@ -1,248 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { createFlashnetClient } from './client' -import { FlashnetAuthError, FlashnetRequestError } from './errors' - -const mockFetch = vi.fn() - -beforeEach(() => { - vi.stubGlobal('fetch', mockFetch) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -describe('createFlashnetClient', () => { - it('creates a client with default base URL', () => { - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - expect(client.baseUrl).toBe('https://orchestration.flashnet.xyz') - expect(client.hasApiKey).toBe(true) - }) - - it('creates a client with custom base URL', () => { - const client = createFlashnetClient({ - apiKey: 'fn_test_key', - baseUrl: 'https://custom.api.xyz', - }) - expect(client.baseUrl).toBe('https://custom.api.xyz') - }) - - it('throws if apiKey is empty', () => { - expect(() => createFlashnetClient({ apiKey: '' })).toThrow( - FlashnetAuthError, - ) - }) - - describe('request', () => { - it('sends authenticated GET request', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ data: 'test' }), - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - const result = await client.request('/v1/routes') - - expect(mockFetch).toHaveBeenCalledWith( - 'https://orchestration.flashnet.xyz/v1/routes', - expect.objectContaining({ - method: 'GET', - headers: expect.objectContaining({ - Authorization: 'Bearer fn_test_key', - 'Content-Type': 'application/json', - Accept: 'application/json', - }), - }), - ) - expect(result).toEqual({ data: 'test' }) - }) - - it('sends POST request with body', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({ id: '123' }), - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - await client.request('/v1/orders', { - method: 'POST', - body: { amount: '1000' }, - }) - - expect(mockFetch).toHaveBeenCalledWith( - 'https://orchestration.flashnet.xyz/v1/orders', - expect.objectContaining({ - method: 'POST', - body: JSON.stringify({ amount: '1000' }), - }), - ) - }) - - it('includes idempotency key header', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - await client.request('/v1/orders', { - method: 'POST', - idempotencyKey: 'idem-123', - }) - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - 'X-Idempotency-Key': 'idem-123', - }), - }), - ) - }) - - it('handles 204 No Content', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 204, - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - const result = await client.request('/v1/something') - - expect(result).toBeUndefined() - }) - - it('throws FlashnetAuthError on 401', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 401, - text: async () => JSON.stringify({ message: 'Invalid token' }), - }) - - const client = createFlashnetClient({ apiKey: 'fn_bad_key' }) - await expect(client.request('/v1/routes')).rejects.toThrow( - FlashnetAuthError, - ) - }) - - it('throws FlashnetAuthError on 403', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 403, - text: async () => JSON.stringify({ message: 'Forbidden' }), - }) - - const client = createFlashnetClient({ apiKey: 'fn_bad_key' }) - await expect(client.request('/v1/routes')).rejects.toThrow( - FlashnetAuthError, - ) - }) - - it('throws FlashnetRequestError on other errors', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => - JSON.stringify({ message: 'Internal error', code: 'SERVER_ERROR' }), - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - - try { - await client.request('/v1/routes') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(FlashnetRequestError) - const reqError = error as InstanceType - expect(reqError.status).toBe(500) - expect(reqError.code).toBe('SERVER_ERROR') - } - }) - - it('handles non-JSON error responses', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 502, - text: async () => 'Bad Gateway', - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - - try { - await client.request('/v1/routes') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(FlashnetRequestError) - const reqError = error as InstanceType - expect(reqError.status).toBe(502) - expect(reqError.shortMessage).toContain('Bad Gateway') - } - }) - - it('handles error responses where text() fails', async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - text: async () => { - throw new Error('body stream error') - }, - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - - try { - await client.request('/v1/routes') - expect.fail('Should have thrown') - } catch (error) { - expect(error).toBeInstanceOf(FlashnetRequestError) - const reqError = error as InstanceType - expect(reqError.status).toBe(500) - } - }) - - it('forwards abort signal', async () => { - const controller = new AbortController() - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - await client.request('/v1/routes', { signal: controller.signal }) - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - signal: controller.signal, - }), - ) - }) - - it('merges custom headers', async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - status: 200, - json: async () => ({}), - }) - - const client = createFlashnetClient({ apiKey: 'fn_test_key' }) - await client.request('/v1/routes', { - headers: { 'X-Custom': 'value' }, - }) - - expect(mockFetch).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: 'Bearer fn_test_key', - 'X-Custom': 'value', - }), - }), - ) - }) - }) -}) diff --git a/packages/flashnet/src/client.ts b/packages/flashnet/src/client.ts deleted file mode 100644 index bf2a18a..0000000 --- a/packages/flashnet/src/client.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { FlashnetAuthError, FlashnetRequestError } from './errors' -import type { - FlashnetClient, - FlashnetClientConfig, - FlashnetRequestOptions, -} from './types' - -const DEFAULT_BASE_URL = 'https://orchestration.flashnet.xyz' - -/** - * Creates a Flashnet client configured with API-key authentication. - * The client automatically adds bearer token auth headers to all requests. - * - * @param config - Client configuration including API key and optional base URL. - * @returns A {@link FlashnetClient} instance for making authenticated API requests. - * - * @example - * ```ts - * import { createFlashnetClient } from '@mbga/flashnet' - * - * const client = createFlashnetClient({ - * apiKey: 'fn_your_api_key', - * }) - * - * // Make authenticated requests - * const routes = await client.request('/v1/routes') - * ``` - */ -export function createFlashnetClient( - config: FlashnetClientConfig, -): FlashnetClient { - const { apiKey, baseUrl = DEFAULT_BASE_URL } = config - - if (!apiKey) { - throw new FlashnetAuthError('API key is required.') - } - - async function request( - path: string, - options: FlashnetRequestOptions = {}, - ): Promise { - const { - method = 'GET', - body, - headers = {}, - signal, - idempotencyKey, - } = options - - const url = `${baseUrl}${path}` - - const requestHeaders: Record = { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - Accept: 'application/json', - ...headers, - } - - if (idempotencyKey) { - requestHeaders['X-Idempotency-Key'] = idempotencyKey - } - - const response = await fetch(url, { - method, - headers: requestHeaders, - body: body != null ? JSON.stringify(body) : undefined, - signal, - }) - - if (!response.ok) { - const errorBody = await response.text().catch(() => '') - let message = `HTTP ${response.status}` - let code: string | undefined - - try { - const parsed = JSON.parse(errorBody) as { - message?: string - error?: string - code?: string - } - message = parsed.message ?? parsed.error ?? message - code = parsed.code - } catch { - if (errorBody) message = errorBody - } - - if (response.status === 401 || response.status === 403) { - throw new FlashnetAuthError(message) - } - - throw new FlashnetRequestError({ - status: response.status, - message, - code, - }) - } - - // Handle 204 No Content - if (response.status === 204) { - return undefined as T - } - - return (await response.json()) as T - } - - return { - baseUrl, - hasApiKey: true, - request, - } -} diff --git a/packages/flashnet/src/context.test.tsx b/packages/flashnet/src/context.test.tsx index d9c65e9..ef38bcf 100644 --- a/packages/flashnet/src/context.test.tsx +++ b/packages/flashnet/src/context.test.tsx @@ -1,72 +1,49 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { renderHook } from '@testing-library/react' import { createElement } from 'react' -import { describe, expect, it } from 'vitest' -import { FlashnetProvider, useFlashnetClient } from './context' +import { describe, expect, it, vi } from 'vitest' +import { FlashnetProvider, useFlashnetExtension } from './context' import { FlashnetProviderNotFoundError } from './errors' -import type { FlashnetClient } from './types' +import type { FlashnetExtension } from './types' -function createMockClient( - baseUrl = 'https://test.flashnet.xyz', -): FlashnetClient { +function createMockExtension( + overrides: Partial = {}, +): FlashnetExtension { return { - baseUrl, - hasApiKey: true, - request: async () => ({}) as T, + plugin: vi.fn(), + state: { + status: 'idle', + accessToken: null, + publicKey: null, + error: null, + }, + gatewayUrl: 'https://test.flashnet.xyz', + subscribe: vi.fn().mockReturnValue(() => {}), + authenticate: vi.fn(), + authenticateWithSigner: vi.fn(), + request: vi.fn(), + signIntent: vi.fn(), + disconnect: vi.fn(), + ...overrides, } } describe('FlashnetProvider', () => { - it('provides client to children via client prop', () => { - const mockClient = createMockClient() + it('provides extension to children', () => { + const ext = createMockExtension() - const { result } = renderHook(() => useFlashnetClient(), { + const { result } = renderHook(() => useFlashnetExtension(), { wrapper: ({ children }) => - createElement(FlashnetProvider, { - client: mockClient, - children, - }), + createElement(FlashnetProvider, { extension: ext, children }), }) - expect(result.current).toBe(mockClient) - }) - - it('creates client from apiKey when no client prop', () => { - const { result } = renderHook(() => useFlashnetClient(), { - wrapper: ({ children }) => - createElement(FlashnetProvider, { - apiKey: 'fn_test_key', - children, - }), - }) - - expect(result.current.hasApiKey).toBe(true) - expect(result.current.baseUrl).toBe('https://orchestration.flashnet.xyz') - }) - - it('uses custom baseUrl', () => { - const { result } = renderHook(() => useFlashnetClient(), { - wrapper: ({ children }) => - createElement(FlashnetProvider, { - apiKey: 'fn_test_key', - baseUrl: 'https://custom.api.xyz', - children, - }), - }) - - expect(result.current.baseUrl).toBe('https://custom.api.xyz') + expect(result.current).toBe(ext) }) }) -describe('useFlashnetClient', () => { +describe('useFlashnetExtension', () => { it('throws outside provider', () => { - const queryClient = new QueryClient() - expect(() => { - renderHook(() => useFlashnetClient(), { - wrapper: ({ children }) => - createElement(QueryClientProvider, { client: queryClient }, children), - }) + renderHook(() => useFlashnetExtension()) }).toThrow(FlashnetProviderNotFoundError) }) }) diff --git a/packages/flashnet/src/context.ts b/packages/flashnet/src/context.ts index 30dd343..ef359ed 100644 --- a/packages/flashnet/src/context.ts +++ b/packages/flashnet/src/context.ts @@ -1,52 +1,33 @@ 'use client' -import { createContext, createElement, useContext, useMemo } from 'react' -import { createFlashnetClient } from './client' +import { createContext, createElement, useContext } from 'react' import { FlashnetProviderNotFoundError } from './errors' -import type { FlashnetClient } from './types' +import type { FlashnetExtension } from './types' -/** React context that holds the Flashnet client instance. */ -export const FlashnetContext = createContext( +/** React context that holds the Flashnet extension instance. */ +export const FlashnetContext = createContext( undefined, ) /** Props for {@link FlashnetProvider}. */ -export type FlashnetProviderProps = - | { - /** Pre-configured client instance. Takes precedence over apiKey/baseUrl. */ - client: FlashnetClient - apiKey?: undefined - baseUrl?: undefined - } - | { - client?: undefined - /** Flashnet API key. */ - apiKey: string - /** Base URL for the Flashnet API. */ - baseUrl?: string | undefined - } +export type FlashnetProviderProps = { + /** The Flashnet extension created by `createFlashnetExtension()`. */ + extension: FlashnetExtension +} /** - * Provider component that makes a Flashnet client available to all hooks. + * Provider component that makes a Flashnet extension available to all hooks. * * @example * ```tsx * import { FlashnetProvider } from '@mbga/flashnet/react' + * import { createFlashnetExtension } from '@mbga/flashnet' * - * // With API key - * function App() { - * return ( - * - * - * - * ) - * } + * const flashnet = createFlashnetExtension() * - * // With pre-configured client * function App() { - * const client = createFlashnetClient({ apiKey: 'fn_...' }) * return ( - * + * * * * ) @@ -56,25 +37,16 @@ export type FlashnetProviderProps = export function FlashnetProvider( parameters: React.PropsWithChildren, ) { - const { children, client: clientProp } = parameters - - const client = useMemo(() => { - if (clientProp) return clientProp - return createFlashnetClient({ - apiKey: parameters.apiKey as string, - baseUrl: parameters.baseUrl, - }) - }, [clientProp, parameters.apiKey, parameters.baseUrl]) - - return createElement(FlashnetContext.Provider, { value: client }, children) + const { children, extension } = parameters + return createElement(FlashnetContext.Provider, { value: extension }, children) } /** - * Hook to access the Flashnet client from context. + * Hook to access the Flashnet extension from context. * * @throws {FlashnetProviderNotFoundError} If used outside of FlashnetProvider. */ -export function useFlashnetClient(): FlashnetClient { +export function useFlashnetExtension(): FlashnetExtension { const context = useContext(FlashnetContext) if (!context) throw new FlashnetProviderNotFoundError() return context diff --git a/packages/flashnet/src/crypto.ts b/packages/flashnet/src/crypto.ts new file mode 100644 index 0000000..ed0dc9e --- /dev/null +++ b/packages/flashnet/src/crypto.ts @@ -0,0 +1,15 @@ +/** SHA256 hash using the Web Crypto API (works in browsers and Node 18+). */ +export async function sha256(data: Uint8Array): Promise { + const hash = await globalThis.crypto.subtle.digest( + 'SHA-256', + data as ArrayBufferView, + ) + return new Uint8Array(hash) +} + +/** Convert a Uint8Array to a hex string. */ +export function toHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/packages/flashnet/src/errors.test.ts b/packages/flashnet/src/errors.test.ts deleted file mode 100644 index e4f3663..0000000 --- a/packages/flashnet/src/errors.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - FlashnetAuthError, - FlashnetClientNotConfiguredError, - FlashnetProviderNotFoundError, - FlashnetRequestError, -} from './errors' - -describe('FlashnetAuthError', () => { - it('has default message', () => { - const error = new FlashnetAuthError() - expect(error.name).toBe('FlashnetAuthError') - expect(error.shortMessage).toBe('Flashnet authentication failed.') - expect(error.details).toContain('API key') - }) - - it('accepts custom message', () => { - const error = new FlashnetAuthError('Custom auth error') - expect(error.shortMessage).toBe('Custom auth error') - }) -}) - -describe('FlashnetClientNotConfiguredError', () => { - it('has correct message', () => { - const error = new FlashnetClientNotConfiguredError() - expect(error.name).toBe('FlashnetClientNotConfiguredError') - expect(error.shortMessage).toContain('not configured') - }) -}) - -describe('FlashnetRequestError', () => { - it('includes status and code', () => { - const error = new FlashnetRequestError({ - status: 500, - message: 'Internal error', - code: 'SERVER_ERROR', - }) - expect(error.name).toBe('FlashnetRequestError') - expect(error.status).toBe(500) - expect(error.code).toBe('SERVER_ERROR') - expect(error.shortMessage).toContain('500') - }) - - it('works without code', () => { - const error = new FlashnetRequestError({ - status: 404, - message: 'Not found', - }) - expect(error.code).toBeUndefined() - }) -}) - -describe('FlashnetProviderNotFoundError', () => { - it('has correct message', () => { - const error = new FlashnetProviderNotFoundError() - expect(error.name).toBe('FlashnetProviderNotFoundError') - expect(error.shortMessage).toContain('FlashnetProvider') - }) -}) diff --git a/packages/flashnet/src/errors.ts b/packages/flashnet/src/errors.ts index dbe0eb8..8e40024 100644 --- a/packages/flashnet/src/errors.ts +++ b/packages/flashnet/src/errors.ts @@ -2,26 +2,66 @@ import { BaseError } from '@mbga/core' export type FlashnetAuthErrorType = typeof FlashnetAuthError -/** Thrown when Flashnet authentication fails (invalid or missing API key). */ +/** Thrown when Flashnet authentication fails (challenge-response or token error). */ export class FlashnetAuthError extends BaseError { override name = 'FlashnetAuthError' constructor(message?: string) { super(message ?? 'Flashnet authentication failed.', { - details: 'Verify that your API key is valid and has not expired.', + details: + 'The challenge-response flow failed. Ensure your wallet is connected and can sign messages.', + }) + } +} + +export type FlashnetChallengeErrorType = typeof FlashnetChallengeError + +/** Thrown when the challenge request to the Flashnet API fails. */ +export class FlashnetChallengeError extends BaseError { + override name = 'FlashnetChallengeError' + constructor(message?: string) { + super(message ?? 'Failed to obtain auth challenge from Flashnet.', { + details: 'POST /v1/auth/challenge returned an error.', + }) + } +} + +export type FlashnetSigningErrorType = typeof FlashnetSigningError + +/** Thrown when message signing fails (wallet not connected, signer error). */ +export class FlashnetSigningError extends BaseError { + override name = 'FlashnetSigningError' + constructor(message?: string) { + super(message ?? 'Failed to sign message.', { + details: + 'Ensure your wallet is connected and supports identity key signing.', + }) + } +} + +export type FlashnetNotAuthenticatedErrorType = + typeof FlashnetNotAuthenticatedError + +/** Thrown when an action requires authentication but the extension is not authenticated. */ +export class FlashnetNotAuthenticatedError extends BaseError { + override name = 'FlashnetNotAuthenticatedError' + constructor() { + super('Flashnet extension is not authenticated.', { + details: + 'Call extension.authenticate(config) or extension.authenticateWithSigner(signer) first.', }) } } -export type FlashnetClientNotConfiguredErrorType = - typeof FlashnetClientNotConfiguredError +export type FlashnetExtensionNotConfiguredErrorType = + typeof FlashnetExtensionNotConfiguredError -/** Thrown when a Flashnet action is called without a configured client. */ -export class FlashnetClientNotConfiguredError extends BaseError { - override name = 'FlashnetClientNotConfiguredError' +/** Thrown when a Flashnet action is called without a configured extension. */ +export class FlashnetExtensionNotConfiguredError extends BaseError { + override name = 'FlashnetExtensionNotConfiguredError' constructor() { - super('Flashnet client is not configured.', { + super('Flashnet extension is not configured.', { details: - 'Call createFlashnetClient() with a valid API key, or wrap your app in .', + 'Call createFlashnetExtension() and pass extension.plugin to createConfig({ plugins: [...] }).', }) } } @@ -57,6 +97,6 @@ export type FlashnetProviderNotFoundErrorType = export class FlashnetProviderNotFoundError extends BaseError { override name = 'FlashnetProviderNotFoundError' constructor() { - super('`useFlashnetClient` must be used within `FlashnetProvider`.') + super('`useFlashnetExtension` must be used within `FlashnetProvider`.') } } diff --git a/packages/flashnet/src/exports/actions.ts b/packages/flashnet/src/exports/actions.ts index dbd20c1..d97865a 100644 --- a/packages/flashnet/src/exports/actions.ts +++ b/packages/flashnet/src/exports/actions.ts @@ -1,7 +1,4 @@ // biome-ignore lint/performance/noBarrelFile: entrypoint module -export { - type VerifyAuthErrorType, - type VerifyAuthParameters, - type VerifyAuthReturnType, - verifyAuth, -} from '../actions/verifyAuth' +export { challengeResponse } from '../auth' +export { signIntent } from '../intent' +export { createSigner, createSignerFromConfig } from '../signer' diff --git a/packages/flashnet/src/exports/index.ts b/packages/flashnet/src/exports/index.ts index 1f78ace..80b4409 100644 --- a/packages/flashnet/src/exports/index.ts +++ b/packages/flashnet/src/exports/index.ts @@ -1,20 +1,33 @@ //////////////////////////////////////////////////////////////////////////////// -// Client +// Extension //////////////////////////////////////////////////////////////////////////////// // biome-ignore lint/performance/noBarrelFile: entrypoint module -export { createFlashnetClient } from '../client' +export { createFlashnetExtension } from '../extension' //////////////////////////////////////////////////////////////////////////////// -// Actions +// Signer //////////////////////////////////////////////////////////////////////////////// -export { - type VerifyAuthErrorType, - type VerifyAuthParameters, - type VerifyAuthReturnType, - verifyAuth, -} from '../actions/verifyAuth' +export { createSigner, createSignerFromConfig } from '../signer' + +//////////////////////////////////////////////////////////////////////////////// +// Auth +//////////////////////////////////////////////////////////////////////////////// + +export { challengeResponse } from '../auth' + +//////////////////////////////////////////////////////////////////////////////// +// Intent +//////////////////////////////////////////////////////////////////////////////// + +export { signIntent } from '../intent' + +//////////////////////////////////////////////////////////////////////////////// +// Crypto +//////////////////////////////////////////////////////////////////////////////// + +export { sha256, toHex } from '../crypto' //////////////////////////////////////////////////////////////////////////////// // Errors @@ -23,12 +36,18 @@ export { export { FlashnetAuthError, type FlashnetAuthErrorType, - FlashnetClientNotConfiguredError, - type FlashnetClientNotConfiguredErrorType, + FlashnetChallengeError, + type FlashnetChallengeErrorType, + FlashnetExtensionNotConfiguredError, + type FlashnetExtensionNotConfiguredErrorType, + FlashnetNotAuthenticatedError, + type FlashnetNotAuthenticatedErrorType, FlashnetProviderNotFoundError, type FlashnetProviderNotFoundErrorType, FlashnetRequestError, type FlashnetRequestErrorType, + FlashnetSigningError, + type FlashnetSigningErrorType, } from '../errors' //////////////////////////////////////////////////////////////////////////////// @@ -36,10 +55,14 @@ export { //////////////////////////////////////////////////////////////////////////////// export type { - FlashnetAuthStatus, - FlashnetClient, - FlashnetClientConfig, + ChallengeResponse, + FlashnetAuthResult, + FlashnetExtension, + FlashnetExtensionOptions, FlashnetRequestOptions, + FlashnetSigner, + FlashnetState, + VerifyResponse, } from '../types' //////////////////////////////////////////////////////////////////////////////// diff --git a/packages/flashnet/src/exports/react.ts b/packages/flashnet/src/exports/react.ts index 57f2a28..df96317 100644 --- a/packages/flashnet/src/exports/react.ts +++ b/packages/flashnet/src/exports/react.ts @@ -3,7 +3,7 @@ export { FlashnetContext, FlashnetProvider, type FlashnetProviderProps, - useFlashnetClient, + useFlashnetExtension, } from '../context' export { @@ -11,3 +11,9 @@ export { type UseFlashnetAuthReturnType, useFlashnetAuth, } from '../hooks/useFlashnetAuth' + +export { + type UseFlashnetAuthenticateParameters, + type UseFlashnetAuthenticateReturnType, + useFlashnetAuthenticate, +} from '../hooks/useFlashnetAuthenticate' diff --git a/packages/flashnet/src/extension.test.ts b/packages/flashnet/src/extension.test.ts new file mode 100644 index 0000000..7d62382 --- /dev/null +++ b/packages/flashnet/src/extension.test.ts @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + FlashnetAuthError, + FlashnetNotAuthenticatedError, + FlashnetRequestError, +} from './errors' +import { createFlashnetExtension } from './extension' + +const mockFetch = vi.fn() + +beforeEach(() => { + vi.stubGlobal('fetch', mockFetch) +}) + +afterEach(() => { + vi.restoreAllMocks() +}) + +function mockChallengeVerifyFlow() { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'aabb', + challengeString: 'Sign this: aabb', + }), + }) + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ accessToken: 'tok_test' }), + }) +} + +function createMockSigner() { + return { + getPublicKey: vi.fn().mockResolvedValue('02abc123'), + signMessage: vi.fn().mockResolvedValue(new Uint8Array([0xab, 0xcd])), + } +} + +describe('createFlashnetExtension', () => { + it('creates extension with default gateway URL', () => { + const ext = createFlashnetExtension() + expect(ext.gatewayUrl).toBe('https://orchestration.flashnet.xyz') + expect(ext.state.status).toBe('idle') + }) + + it('creates extension with custom gateway URL', () => { + const ext = createFlashnetExtension({ + gatewayUrl: 'https://custom.gateway.xyz', + }) + expect(ext.gatewayUrl).toBe('https://custom.gateway.xyz') + }) + + describe('authenticateWithSigner', () => { + it('authenticates and updates state', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + + const result = await ext.authenticateWithSigner(createMockSigner()) + + expect(result.accessToken).toBe('tok_test') + expect(result.publicKey).toBe('02abc123') + expect(ext.state.status).toBe('authenticated') + expect(ext.state.accessToken).toBe('tok_test') + expect(ext.state.publicKey).toBe('02abc123') + }) + + it('sets error state on failure', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Server Error', + }) + + const ext = createFlashnetExtension() + + await expect( + ext.authenticateWithSigner(createMockSigner()), + ).rejects.toThrow() + expect(ext.state.status).toBe('error') + expect(ext.state.error).toBeInstanceOf(Error) + }) + + it('emits state changes to subscribers', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + const states: string[] = [] + + ext.subscribe((s) => states.push(s.status)) + + await ext.authenticateWithSigner(createMockSigner()) + + expect(states).toEqual(['authenticating', 'authenticated']) + }) + }) + + describe('request', () => { + it('sends authenticated request', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension({ + gatewayUrl: 'https://test.gw', + }) + await ext.authenticateWithSigner(createMockSigner()) + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ data: 'test' }), + }) + + const result = await ext.request('/v1/routes') + + expect(result).toEqual({ data: 'test' }) + expect(mockFetch).toHaveBeenLastCalledWith( + 'https://test.gw/v1/routes', + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer tok_test', + }), + }), + ) + }) + + it('throws FlashnetNotAuthenticatedError when not authenticated', async () => { + const ext = createFlashnetExtension() + await expect(ext.request('/v1/routes')).rejects.toThrow( + FlashnetNotAuthenticatedError, + ) + }) + + it('resets auth state on 401', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + await ext.authenticateWithSigner(createMockSigner()) + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => 'Token expired', + }) + + await expect(ext.request('/v1/routes')).rejects.toThrow(FlashnetAuthError) + expect(ext.state.status).toBe('idle') + expect(ext.state.accessToken).toBeNull() + }) + + it('throws FlashnetRequestError on non-auth errors', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + await ext.authenticateWithSigner(createMockSigner()) + + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => + JSON.stringify({ message: 'Internal error', errorCode: 'SRV_ERR' }), + }) + + try { + await ext.request('/v1/routes') + expect.fail('Should have thrown') + } catch (error) { + expect(error).toBeInstanceOf(FlashnetRequestError) + const reqError = error as InstanceType + expect(reqError.status).toBe(500) + expect(reqError.code).toBe('SRV_ERR') + } + }) + + it('handles 204 No Content', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + await ext.authenticateWithSigner(createMockSigner()) + + mockFetch.mockResolvedValueOnce({ ok: true, status: 204 }) + + const result = await ext.request('/v1/something') + expect(result).toBeUndefined() + }) + + it('includes idempotency key header', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + await ext.authenticateWithSigner(createMockSigner()) + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + }) + + await ext.request('/v1/orders', { + method: 'POST', + idempotencyKey: 'idem-123', + }) + + expect(mockFetch).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + 'X-Idempotency-Key': 'idem-123', + }), + }), + ) + }) + }) + + describe('disconnect', () => { + it('resets state to idle', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + await ext.authenticateWithSigner(createMockSigner()) + + ext.disconnect() + + expect(ext.state.status).toBe('idle') + expect(ext.state.accessToken).toBeNull() + expect(ext.state.publicKey).toBeNull() + }) + }) + + describe('subscribe', () => { + it('returns unsubscribe function', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + const states: string[] = [] + + const unsub = ext.subscribe((s) => states.push(s.status)) + unsub() + + await ext.authenticateWithSigner(createMockSigner()) + + // Should not have received any updates after unsubscribe + expect(states).toEqual([]) + }) + }) +}) diff --git a/packages/flashnet/src/extension.ts b/packages/flashnet/src/extension.ts new file mode 100644 index 0000000..5358190 --- /dev/null +++ b/packages/flashnet/src/extension.ts @@ -0,0 +1,204 @@ +import type { Config, ConfigPlugin } from '@mbga/core' +import { challengeResponse } from './auth' +import { + FlashnetAuthError, + FlashnetNotAuthenticatedError, + FlashnetRequestError, +} from './errors' +import { signIntent as signIntentImpl } from './intent' +import { createSignerFromConfig } from './signer' +import type { + FlashnetAuthResult, + FlashnetExtension, + FlashnetExtensionOptions, + FlashnetRequestOptions, + FlashnetSigner, + FlashnetState, +} from './types' + +const DEFAULT_GATEWAY_URL = 'https://orchestration.flashnet.xyz' + +const INITIAL_STATE: FlashnetState = { + status: 'idle', + accessToken: null, + publicKey: null, + error: null, +} + +/** + * Creates a Flashnet extension for use with MBGA. + * + * The extension provides challenge-response authentication using the connected + * wallet's identity key, authenticated API requests, and intent signing. + * + * Register `extension.plugin` in your MBGA config to auto-clear auth on wallet disconnect. + * + * @example + * ```ts + * import { createFlashnetExtension } from '@mbga/flashnet' + * import { createConfig, sparkMainnet } from '@mbga/core' + * import { sparkSdk } from '@mbga/connectors' + * + * const flashnet = createFlashnetExtension() + * + * const config = createConfig({ + * network: sparkMainnet, + * connectors: [sparkSdk({ mnemonic: '...' })], + * plugins: [flashnet.plugin], + * }) + * + * // After connecting a wallet: + * const { accessToken } = await flashnet.authenticate(config) + * ``` + */ +export function createFlashnetExtension( + options: FlashnetExtensionOptions = {}, +): FlashnetExtension { + const gatewayUrl = options.gatewayUrl ?? DEFAULT_GATEWAY_URL + + // Simple reactive state store + let state: FlashnetState = { ...INITIAL_STATE } + const listeners = new Set<(state: FlashnetState) => void>() + + function setState(partial: Partial) { + state = { ...state, ...partial } + for (const listener of listeners) listener(state) + } + + // ConfigPlugin — auto-clear auth when wallet disconnects + const plugin: ConfigPlugin = (config: Config) => { + const unsubscribe = config.subscribe( + (s) => s.status, + (status) => { + if (status === 'disconnected' && state.status === 'authenticated') { + setState({ ...INITIAL_STATE }) + } + }, + ) + return unsubscribe as () => void + } + + async function authenticate(config: Config): Promise { + const signer = createSignerFromConfig(config) + return authenticateWithSigner(signer) + } + + async function authenticateWithSigner( + signer: FlashnetSigner, + ): Promise { + setState({ status: 'authenticating', error: null }) + + try { + const result = await challengeResponse(gatewayUrl, signer) + setState({ + status: 'authenticated', + accessToken: result.accessToken, + publicKey: result.publicKey, + error: null, + }) + return result + } catch (error) { + const err = + error instanceof Error ? error : new FlashnetAuthError(String(error)) + setState({ status: 'error', error: err }) + throw err + } + } + + async function request( + path: string, + options: FlashnetRequestOptions = {}, + ): Promise { + if (!state.accessToken) { + throw new FlashnetNotAuthenticatedError() + } + + const { + method = 'GET', + body, + headers = {}, + signal, + idempotencyKey, + } = options + + const url = `${gatewayUrl}${path}` + + const requestHeaders: Record = { + Authorization: `Bearer ${state.accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, + } + + if (idempotencyKey) { + requestHeaders['X-Idempotency-Key'] = idempotencyKey + } + + const response = await fetch(url, { + method, + headers: requestHeaders, + body: body != null ? JSON.stringify(body) : undefined, + signal, + }) + + if (!response.ok) { + const errorBody = await response.text().catch(() => '') + let message = `HTTP ${response.status}` + let code: string | undefined + + try { + const parsed = JSON.parse(errorBody) as { + message?: string + error?: string + code?: string + errorCode?: string + } + message = parsed.message ?? parsed.error ?? message + code = parsed.errorCode ?? parsed.code + } catch { + if (errorBody) message = errorBody + } + + if (response.status === 401 || response.status === 403) { + // Token expired or invalid — reset auth state + setState({ ...INITIAL_STATE }) + throw new FlashnetAuthError(message) + } + + throw new FlashnetRequestError({ + status: response.status, + message, + code, + }) + } + + if (response.status === 204) { + return undefined as T + } + + return (await response.json()) as T + } + + function disconnect() { + setState({ ...INITIAL_STATE }) + } + + return { + plugin, + get state() { + return state + }, + gatewayUrl, + subscribe(listener: (state: FlashnetState) => void) { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + authenticate, + authenticateWithSigner, + request, + signIntent: signIntentImpl, + disconnect, + } +} diff --git a/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx b/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx index d68898e..9d39ed2 100644 --- a/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx +++ b/packages/flashnet/src/hooks/useFlashnetAuth.test.tsx @@ -1,169 +1,141 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { renderHook, waitFor } from '@testing-library/react' +import { act, renderHook } from '@testing-library/react' import { createElement } from 'react' import { describe, expect, it, vi } from 'vitest' import { FlashnetContext } from '../context' -import type { FlashnetClient } from '../types' +import type { FlashnetExtension, FlashnetState } from '../types' import { useFlashnetAuth } from './useFlashnetAuth' -function createMockClient( - requestImpl?: FlashnetClient['request'], - baseUrl = 'https://test.flashnet.xyz', -): FlashnetClient { +function createMockExtension( + initialState: FlashnetState = { + status: 'idle', + accessToken: null, + publicKey: null, + error: null, + }, +): FlashnetExtension & { _setState: (s: FlashnetState) => void } { + let state = initialState + const listeners = new Set<(s: FlashnetState) => void>() + return { - baseUrl, - hasApiKey: true, - request: - requestImpl ?? - vi.fn().mockResolvedValue({ authenticated: true, account_id: 'acct_1' }), + plugin: vi.fn(), + get state() { + return state + }, + gatewayUrl: 'https://test.flashnet.xyz', + subscribe(listener: (s: FlashnetState) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + authenticate: vi.fn(), + authenticateWithSigner: vi.fn(), + request: vi.fn(), + signIntent: vi.fn(), + disconnect: vi.fn(), + _setState(newState: FlashnetState) { + state = newState + for (const l of listeners) l(state) + }, } } -function createWrapper(client?: FlashnetClient) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }) - +function createWrapper(ext: FlashnetExtension) { return function Wrapper({ children }: { children: React.ReactNode }) { - const inner = client - ? createElement(FlashnetContext.Provider, { value: client }, children) - : children - return createElement(QueryClientProvider, { client: queryClient }, inner) + return createElement(FlashnetContext.Provider, { value: ext }, children) } } describe('useFlashnetAuth', () => { - it('returns authenticated status from context client', async () => { - const client = createMockClient() + it('returns idle state initially', () => { + const ext = createMockExtension() const { result } = renderHook(() => useFlashnetAuth(), { - wrapper: createWrapper(client), + wrapper: createWrapper(ext), }) - await waitFor(() => { - expect(result.current.isSuccess).toBe(true) - }) - - expect(result.current.isAuthenticated).toBe(true) - expect(result.current.data?.accountId).toBe('acct_1') + expect(result.current.isAuthenticated).toBe(false) + expect(result.current.isAuthenticating).toBe(false) + expect(result.current.state.status).toBe('idle') }) - it('returns authenticated status from client prop', async () => { - const client = createMockClient() - - const { result } = renderHook(() => useFlashnetAuth({ client }), { - wrapper: createWrapper(), // no context client + it('reflects authenticated state', () => { + const ext = createMockExtension({ + status: 'authenticated', + accessToken: 'tok_123', + publicKey: '02abc', + error: null, }) - await waitFor(() => { - expect(result.current.isSuccess).toBe(true) + const { result } = renderHook(() => useFlashnetAuth(), { + wrapper: createWrapper(ext), }) expect(result.current.isAuthenticated).toBe(true) + expect(result.current.publicKey).toBe('02abc') }) - it('returns not authenticated when auth fails', async () => { - const authError = new Error('Invalid token') - authError.name = 'FlashnetAuthError' - - const client = createMockClient(vi.fn().mockRejectedValue(authError)) - - const { result } = renderHook(() => useFlashnetAuth({ client }), { - wrapper: createWrapper(), + it('reflects error state', () => { + const err = new Error('Auth failed') + const ext = createMockExtension({ + status: 'error', + accessToken: null, + publicKey: null, + error: err, }) - await waitFor(() => { - expect(result.current.isSuccess).toBe(true) + const { result } = renderHook(() => useFlashnetAuth(), { + wrapper: createWrapper(ext), }) - expect(result.current.isAuthenticated).toBe(false) + expect(result.current.isError).toBe(true) + expect(result.current.error).toBe(err) }) - it('reports error for non-auth failures', async () => { - const networkError = new Error('Network failure') - const client = createMockClient(vi.fn().mockRejectedValue(networkError)) - - const { result } = renderHook(() => useFlashnetAuth({ client }), { - wrapper: createWrapper(), - }) + it('updates reactively when state changes', () => { + const ext = createMockExtension() - await waitFor(() => { - expect(result.current.isError).toBe(true) + const { result } = renderHook(() => useFlashnetAuth(), { + wrapper: createWrapper(ext), }) - expect(result.current.error?.message).toBe('Network failure') expect(result.current.isAuthenticated).toBe(false) - }) - - it('does not fetch when disabled', async () => { - const request = vi.fn() - const client = createMockClient(request) - - renderHook(() => useFlashnetAuth({ client, enabled: false }), { - wrapper: createWrapper(), - }) - // Wait a tick to ensure no fetch happens - await new Promise((r) => setTimeout(r, 50)) - expect(request).not.toHaveBeenCalled() - }) - - it('is pending initially', () => { - const client = createMockClient( - vi.fn().mockReturnValue(new Promise(() => {})), // never resolves - ) - - const { result } = renderHook(() => useFlashnetAuth({ client }), { - wrapper: createWrapper(), + act(() => { + ext._setState({ + status: 'authenticated', + accessToken: 'tok_new', + publicKey: '02xyz', + error: null, + }) }) - expect(result.current.isPending).toBe(true) - expect(result.current.isAuthenticated).toBe(false) + expect(result.current.isAuthenticated).toBe(true) + expect(result.current.publicKey).toBe('02xyz') }) - it('re-fetches when client changes (different baseUrl busts cache)', async () => { - const clientA = createMockClient( - vi.fn().mockResolvedValue({ authenticated: true, account_id: 'acct_a' }), - 'https://a.flashnet.xyz', - ) - const clientB = createMockClient( - vi.fn().mockResolvedValue({ authenticated: true, account_id: 'acct_b' }), - 'https://b.flashnet.xyz', - ) - - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, + it('accepts extension prop override', () => { + const contextExt = createMockExtension() + const propExt = createMockExtension({ + status: 'authenticated', + accessToken: 'tok_prop', + publicKey: '02prop', + error: null, }) - function Wrapper({ children }: { children: React.ReactNode }) { - return createElement( - QueryClientProvider, - { client: queryClient }, - children, - ) - } - - // Start with client A - const { result, rerender } = renderHook( - ({ client }: { client: FlashnetClient }) => useFlashnetAuth({ client }), + const { result } = renderHook( + () => useFlashnetAuth({ extension: propExt }), { - wrapper: Wrapper, - initialProps: { client: clientA }, + wrapper: createWrapper(contextExt), }, ) - await waitFor(() => { - expect(result.current.isSuccess).toBe(true) - }) - expect(result.current.data?.accountId).toBe('acct_a') + expect(result.current.isAuthenticated).toBe(true) + expect(result.current.publicKey).toBe('02prop') + }) - // Switch to client B - rerender({ client: clientB }) + it('works without provider when no extension', () => { + const { result } = renderHook(() => useFlashnetAuth()) - await waitFor(() => { - expect(result.current.data?.accountId).toBe('acct_b') - }) + expect(result.current.isAuthenticated).toBe(false) + expect(result.current.state.status).toBe('idle') }) }) diff --git a/packages/flashnet/src/hooks/useFlashnetAuth.ts b/packages/flashnet/src/hooks/useFlashnetAuth.ts index 955af80..b9107a9 100644 --- a/packages/flashnet/src/hooks/useFlashnetAuth.ts +++ b/packages/flashnet/src/hooks/useFlashnetAuth.ts @@ -1,52 +1,45 @@ 'use client' -import { useQuery } from '@tanstack/react-query' -import { useContext } from 'react' -import { verifyAuth } from '../actions/verifyAuth' +import { useContext, useSyncExternalStore } from 'react' import { FlashnetContext } from '../context' -import type { FlashnetAuthStatus, FlashnetClient } from '../types' +import type { FlashnetExtension, FlashnetState } from '../types' /** Parameters for {@link useFlashnetAuth}. */ export type UseFlashnetAuthParameters = { - /** Flashnet client instance. If omitted, uses FlashnetProvider context. */ - client?: FlashnetClient | undefined - /** Whether to enable automatic auth verification. @default true */ - enabled?: boolean | undefined + /** Override the context extension. */ + extension?: FlashnetExtension | undefined } /** Return type of {@link useFlashnetAuth}. */ export type UseFlashnetAuthReturnType = { - /** The current authentication status. */ - data: FlashnetAuthStatus | undefined - /** Error from the last verification attempt. */ - error: Error | null - /** Whether verification is currently in progress. */ - isPending: boolean - /** Whether the client is authenticated. Convenience shorthand for `data?.authenticated`. */ + /** The full auth state. */ + state: FlashnetState + /** Whether the extension is authenticated. */ isAuthenticated: boolean - /** Whether the last verification failed. */ + /** Whether authentication is in progress. */ + isAuthenticating: boolean + /** Whether the last auth attempt failed. */ isError: boolean - /** Whether verification succeeded at least once. */ - isSuccess: boolean - /** Re-run the auth verification. */ - refetch: () => void + /** The identity public key, if authenticated. */ + publicKey: string | null + /** The error from the last auth attempt. */ + error: Error | null } /** - * Hook that verifies Flashnet authentication status. - * Automatically checks if the API key is valid on mount. + * Hook that reactively tracks the Flashnet authentication state. * - * Uses the client from {@link FlashnetProvider} context, or accepts a client prop. + * Uses `useSyncExternalStore` to subscribe to the extension's state changes. * * @example * ```tsx - * import { useFlashnetAuth } from '@mbga/flashnet' + * import { useFlashnetAuth } from '@mbga/flashnet/react' * * function AuthStatus() { - * const { isAuthenticated, isPending } = useFlashnetAuth() + * const { isAuthenticated, isAuthenticating, publicKey } = useFlashnetAuth() * - * if (isPending) return Verifying... - * if (isAuthenticated) return Authenticated + * if (isAuthenticating) return Authenticating... + * if (isAuthenticated) return Authenticated: {publicKey} * return Not authenticated * } * ``` @@ -54,31 +47,31 @@ export type UseFlashnetAuthReturnType = { export function useFlashnetAuth( parameters: UseFlashnetAuthParameters = {}, ): UseFlashnetAuthReturnType { - const { client: clientProp, enabled = true } = parameters - - const contextClient = useContext(FlashnetContext) - const client = clientProp ?? contextClient + const contextExtension = useContext(FlashnetContext) + const extension = parameters.extension ?? contextExtension - const query = useQuery({ - queryKey: ['flashnet', 'auth', client?.baseUrl], - queryFn: () => { - if (!client) { - return { authenticated: false } satisfies FlashnetAuthStatus - } - return verifyAuth(client) + const state = useSyncExternalStore( + (callback) => { + if (!extension) return () => {} + return extension.subscribe(callback) }, - enabled: enabled && client != null, - staleTime: 5 * 60 * 1000, // 5 minutes - retry: false, - }) + () => extension?.state ?? IDLE_STATE, + () => extension?.state ?? IDLE_STATE, + ) return { - data: query.data, - error: query.error, - isPending: query.isPending, - isAuthenticated: query.data?.authenticated ?? false, - isError: query.isError, - isSuccess: query.isSuccess, - refetch: query.refetch, + state, + isAuthenticated: state.status === 'authenticated', + isAuthenticating: state.status === 'authenticating', + isError: state.status === 'error', + publicKey: state.publicKey, + error: state.error, } } + +const IDLE_STATE: FlashnetState = { + status: 'idle', + accessToken: null, + publicKey: null, + error: null, +} diff --git a/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts b/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts new file mode 100644 index 0000000..9a9a9f2 --- /dev/null +++ b/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts @@ -0,0 +1,86 @@ +'use client' + +import { useMutation } from '@tanstack/react-query' +import { useContext } from 'react' +import { FlashnetContext } from '../context' +import { FlashnetProviderNotFoundError } from '../errors' +import type { FlashnetAuthResult, FlashnetExtension } from '../types' + +/** Parameters for {@link useFlashnetAuthenticate}. */ +export type UseFlashnetAuthenticateParameters = { + /** Override the context extension. */ + extension?: FlashnetExtension | undefined +} + +/** Return type of {@link useFlashnetAuthenticate}. */ +export type UseFlashnetAuthenticateReturnType = { + /** Trigger authentication (fire-and-forget). */ + authenticate: () => void + /** Trigger authentication (returns a promise). */ + authenticateAsync: () => Promise + /** Whether authentication is in progress. */ + isPending: boolean + /** Whether the last attempt failed. */ + isError: boolean + /** Whether authentication succeeded. */ + isSuccess: boolean + /** Error from the last attempt. */ + error: Error | null + /** The auth result if successful. */ + data: FlashnetAuthResult | undefined +} + +/** + * Mutation hook that triggers Flashnet challenge-response authentication. + * + * Requires both an MBGA config (via `MbgaProvider`) and a Flashnet extension (via `FlashnetProvider`). + * The config must have a connected wallet to provide the signer. + * + * @example + * ```tsx + * import { useFlashnetAuthenticate } from '@mbga/flashnet/react' + * + * function AuthButton() { + * const { authenticate, isPending, isSuccess } = useFlashnetAuthenticate() + * + * return ( + * + * ) + * } + * ``` + */ +export function useFlashnetAuthenticate( + parameters: UseFlashnetAuthenticateParameters & { + /** The MBGA config. If not provided, you must pass it to authenticate(). */ + config?: Parameters[0] + } = {}, +): UseFlashnetAuthenticateReturnType { + const contextExtension = useContext(FlashnetContext) + const extension = parameters.extension ?? contextExtension + + if (!extension) throw new FlashnetProviderNotFoundError() + + const mutation = useMutation({ + mutationKey: ['flashnet', 'authenticate'], + mutationFn: async () => { + if (!parameters.config) { + throw new Error( + 'MBGA config is required. Pass it via useFlashnetAuthenticate({ config }).', + ) + } + return extension.authenticate(parameters.config) + }, + }) + + return { + authenticate: () => mutation.mutate(), + authenticateAsync: () => mutation.mutateAsync(), + isPending: mutation.isPending, + isError: mutation.isError, + isSuccess: mutation.isSuccess, + error: mutation.error, + data: mutation.data, + } +} diff --git a/packages/flashnet/src/intent.test.ts b/packages/flashnet/src/intent.test.ts new file mode 100644 index 0000000..bf1d7a9 --- /dev/null +++ b/packages/flashnet/src/intent.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { signIntent } from './intent' +import type { FlashnetSigner } from './types' + +describe('signIntent', () => { + it('serializes intent to JSON and signs it', async () => { + const signer: FlashnetSigner = { + getPublicKey: vi.fn().mockResolvedValue('02abc'), + signMessage: vi.fn().mockResolvedValue(new Uint8Array([0xca, 0xfe])), + } + + const result = await signIntent(signer, { + userPublicKey: '02abc', + amountIn: '1000', + nonce: 'n1', + }) + + expect(result.intent).toBe( + JSON.stringify({ + userPublicKey: '02abc', + amountIn: '1000', + nonce: 'n1', + }), + ) + expect(result.signature).toBe('cafe') + + // Verify signer was called with a 32-byte SHA256 hash + expect(signer.signMessage).toHaveBeenCalledWith(expect.any(Uint8Array)) + const hashArg = (signer.signMessage as ReturnType).mock + .calls[0]![0] as Uint8Array + expect(hashArg.length).toBe(32) + }) + + it('produces deterministic results for same input', async () => { + const signer: FlashnetSigner = { + getPublicKey: vi.fn().mockResolvedValue('02abc'), + signMessage: vi.fn().mockResolvedValue(new Uint8Array([0x01])), + } + + const intent = { a: '1', b: '2' } + const result1 = await signIntent(signer, intent) + const result2 = await signIntent(signer, intent) + + expect(result1.intent).toBe(result2.intent) + }) +}) diff --git a/packages/flashnet/src/intent.ts b/packages/flashnet/src/intent.ts new file mode 100644 index 0000000..6986fe5 --- /dev/null +++ b/packages/flashnet/src/intent.ts @@ -0,0 +1,29 @@ +import { sha256, toHex } from './crypto' +import type { FlashnetSigner } from './types' + +/** + * Sign an intent message for a Flashnet operation. + * + * Following the Flashnet protocol, the intent is: + * 1. Serialized to JSON + * 2. SHA256 hashed + * 3. Signed with the signer's identity key + * + * @param signer - The signer to use. + * @param intent - The intent data object (e.g. swap params, pool init params). + * @returns The JSON intent string and hex-encoded signature. + */ +export async function signIntent( + signer: FlashnetSigner, + intent: Record, +): Promise<{ intent: string; signature: string }> { + const intentJson = JSON.stringify(intent) + const intentBytes = new TextEncoder().encode(intentJson) + const messageHash = await sha256(intentBytes) + const signatureBytes = await signer.signMessage(messageHash) + + return { + intent: intentJson, + signature: toHex(signatureBytes), + } +} diff --git a/packages/flashnet/src/signer.test.ts b/packages/flashnet/src/signer.test.ts new file mode 100644 index 0000000..e45ee44 --- /dev/null +++ b/packages/flashnet/src/signer.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from 'vitest' +import { FlashnetSigningError } from './errors' +import { createSigner, createSignerFromConfig } from './signer' + +describe('createSigner', () => { + it('wraps publicKey and sign function', async () => { + const signer = createSigner({ + publicKey: '02abc', + sign: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), + }) + + expect(await signer.getPublicKey()).toBe('02abc') + expect(await signer.signMessage(new Uint8Array([4, 5]))).toEqual( + new Uint8Array([1, 2, 3]), + ) + }) +}) + +describe('createSignerFromConfig', () => { + it('throws when no wallet is connected', () => { + const config = { + state: { current: null, connections: new Map(), status: 'disconnected' }, + } as any + + expect(() => createSignerFromConfig(config)).toThrow(FlashnetSigningError) + }) + + it('throws when connector lacks getIdentityPublicKey', async () => { + const connector = { + name: 'test', + getProvider: vi.fn().mockResolvedValue({}), + } + const config = { + state: { + current: 'uid1', + connections: new Map([['uid1', { connector, accounts: ['sp1...'] }]]), + status: 'connected', + }, + } as any + + const signer = createSignerFromConfig(config) + await expect(signer.getPublicKey()).rejects.toThrow(FlashnetSigningError) + }) + + it('creates signer from SparkWallet provider', async () => { + const mockWallet = { + getIdentityPublicKey: vi.fn().mockResolvedValue('02abc'), + signMessageWithIdentityKey: vi.fn().mockResolvedValue('deadbeef'), + } + const connector = { + name: 'sparkSdk', + getProvider: vi.fn().mockResolvedValue(mockWallet), + } + const config = { + state: { + current: 'uid1', + connections: new Map([['uid1', { connector, accounts: ['sp1...'] }]]), + status: 'connected', + }, + } as any + + const signer = createSignerFromConfig(config) + + expect(await signer.getPublicKey()).toBe('02abc') + + const sig = await signer.signMessage(new Uint8Array([0xab])) + expect(mockWallet.signMessageWithIdentityKey).toHaveBeenCalledWith( + new Uint8Array([0xab]), + true, + ) + // 'deadbeef' -> [0xde, 0xad, 0xbe, 0xef] + expect(sig).toEqual(new Uint8Array([0xde, 0xad, 0xbe, 0xef])) + }) +}) diff --git a/packages/flashnet/src/signer.ts b/packages/flashnet/src/signer.ts new file mode 100644 index 0000000..ceff0a1 --- /dev/null +++ b/packages/flashnet/src/signer.ts @@ -0,0 +1,95 @@ +import type { Config } from '@mbga/core' +import { FlashnetSigningError } from './errors' +import type { FlashnetSigner } from './types' + +/** + * Create a {@link FlashnetSigner} from the currently connected MBGA wallet. + * + * Uses the connector's `getProvider()` to obtain the SparkWallet, then wraps + * `getIdentityPublicKey()` and `signMessageWithIdentityKey()`. + * + * @param config - The MBGA config with an active wallet connection. + * @throws {FlashnetSigningError} If no wallet is connected or the connector lacks signing support. + */ +export function createSignerFromConfig(config: Config): FlashnetSigner { + const { current, connections } = config.state + if (!current) { + throw new FlashnetSigningError('No wallet connected.') + } + + const connection = connections.get(current) + if (!connection) { + throw new FlashnetSigningError('No active connection found.') + } + + const { connector } = connection + + return { + async getPublicKey() { + const provider = await connector.getProvider() + const wallet = provider as { + getIdentityPublicKey?: () => Promise + } + if (typeof wallet.getIdentityPublicKey !== 'function') { + throw new FlashnetSigningError( + `Connector "${connector.name}" does not expose getIdentityPublicKey(). Use authenticateWithSigner() with a custom signer instead.`, + ) + } + return wallet.getIdentityPublicKey() + }, + + async signMessage(message: Uint8Array) { + const provider = await connector.getProvider() + const wallet = provider as { + signMessageWithIdentityKey?: ( + message: Uint8Array, + raw: boolean, + ) => Promise + } + if (typeof wallet.signMessageWithIdentityKey !== 'function') { + throw new FlashnetSigningError( + `Connector "${connector.name}" does not expose signMessageWithIdentityKey(). Use authenticateWithSigner() with a custom signer instead.`, + ) + } + const hexSignature = await wallet.signMessageWithIdentityKey( + message, + true, + ) + // Convert hex string to Uint8Array + const bytes = new Uint8Array(hexSignature.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(hexSignature.slice(i * 2, i * 2 + 2), 16) + } + return bytes + }, + } +} + +/** + * Create a {@link FlashnetSigner} from an explicit public key and sign function. + * + * Use this for server-side or custom wallet integrations where you manage the keys directly. + * + * @example + * ```ts + * const signer = createSigner({ + * publicKey: '02abc...', + * sign: async (messageHash) => mySigningLib.sign(privateKey, messageHash), + * }) + * + * await flashnet.authenticateWithSigner(signer) + * ``` + */ +export function createSigner(options: { + publicKey: string + sign: (message: Uint8Array) => Promise +}): FlashnetSigner { + return { + async getPublicKey() { + return options.publicKey + }, + async signMessage(message: Uint8Array) { + return options.sign(message) + }, + } +} diff --git a/packages/flashnet/src/types.ts b/packages/flashnet/src/types.ts index 36fd3ed..fa2c67d 100644 --- a/packages/flashnet/src/types.ts +++ b/packages/flashnet/src/types.ts @@ -1,38 +1,103 @@ -/** Authentication status returned by {@link verifyAuth}. */ -export type FlashnetAuthStatus = { - /** Whether the API key is valid and authenticated. */ - authenticated: boolean - /** The authenticated account/organization ID, if available. */ - accountId?: string | undefined -} - -/** Configuration for creating a Flashnet client. */ -export type FlashnetClientConfig = { - /** Flashnet API key (e.g. `fn_...`). Required for authenticated operations. */ - apiKey: string - /** Base URL for the Flashnet Orchestra API. @default 'https://orchestration.flashnet.xyz' */ - baseUrl?: string | undefined -} - -/** A configured Flashnet client that handles authenticated API requests. */ -export type FlashnetClient = { - /** The base URL for API requests. */ - readonly baseUrl: string - /** Whether an API key has been configured. */ - readonly hasApiKey: boolean +import type { Config, ConfigPlugin } from '@mbga/core' + +//////////////////////////////////////////////////////////////////////////////// +// Signer +//////////////////////////////////////////////////////////////////////////////// + +/** + * Interface for signing Flashnet challenges and intents. + * Compatible with both SparkWallet (via adapter) and custom signers. + */ +export type FlashnetSigner = { + /** Returns the hex-encoded secp256k1 identity public key. */ + getPublicKey(): Promise + /** + * Sign a message (already SHA256-hashed bytes). + * @returns The signature as a Uint8Array. + */ + signMessage(message: Uint8Array): Promise +} + +//////////////////////////////////////////////////////////////////////////////// +// Extension +//////////////////////////////////////////////////////////////////////////////// + +/** Options for {@link createFlashnetExtension}. */ +export type FlashnetExtensionOptions = { + /** + * Base URL for the Flashnet AMM gateway. + * @default 'https://orchestration.flashnet.xyz' + */ + gatewayUrl?: string | undefined +} + +/** Reactive state managed by the Flashnet extension. */ +export type FlashnetState = { + /** Current auth lifecycle status. */ + status: 'idle' | 'authenticating' | 'authenticated' | 'error' + /** Bearer token for authenticated requests. `null` when not authenticated. */ + accessToken: string | null + /** The identity public key used for the current session. */ + publicKey: string | null + /** Error from the most recent auth attempt. */ + error: Error | null +} + +/** Successful authentication result. */ +export type FlashnetAuthResult = { + accessToken: string + publicKey: string +} + +/** + * The Flashnet extension object returned by {@link createFlashnetExtension}. + * Provides authentication, request, and intent signing capabilities. + */ +export type FlashnetExtension = { + /** + * ConfigPlugin for registration in `createConfig({ plugins: [...] })`. + * Subscribes to wallet disconnect events to auto-clear auth state. + */ + readonly plugin: ConfigPlugin + /** Current auth state. */ + readonly state: FlashnetState + /** The AMM gateway URL. */ + readonly gatewayUrl: string + /** Subscribe to state changes. Returns an unsubscribe function. */ + subscribe(listener: (state: FlashnetState) => void): () => void + /** + * Authenticate using the connected wallet from the MBGA config. + * Gets the identity public key and signing capability from the current connector. + */ + authenticate(config: Config): Promise + /** + * Authenticate using an explicit signer (for standalone/server use). + */ + authenticateWithSigner(signer: FlashnetSigner): Promise /** * Make an authenticated request to the Flashnet API. - * - * @param path - The API path (e.g. `/v1/routes`). - * @param options - Fetch options (method, body, headers, etc.). - * @returns The parsed JSON response. + * Automatically adds the Bearer token. */ request( path: string, options?: FlashnetRequestOptions, ): Promise + /** + * Sign an intent message for Flashnet operations. + * Serializes the intent to JSON, SHA256 hashes it, and signs with the wallet. + */ + signIntent( + signer: FlashnetSigner, + intent: Record, + ): Promise<{ intent: string; signature: string }> + /** Clear auth state and token. */ + disconnect(): void } +//////////////////////////////////////////////////////////////////////////////// +// Request +//////////////////////////////////////////////////////////////////////////////// + /** Options for a Flashnet API request. */ export type FlashnetRequestOptions = { method?: string | undefined @@ -41,3 +106,21 @@ export type FlashnetRequestOptions = { signal?: AbortSignal | undefined idempotencyKey?: string | undefined } + +//////////////////////////////////////////////////////////////////////////////// +// API Responses +//////////////////////////////////////////////////////////////////////////////// + +/** Response from POST /v1/auth/challenge. */ +export type ChallengeResponse = { + /** Hex-encoded challenge. */ + challenge: string + /** UTF-8 friendly challenge string for wallet signing. */ + challengeString: string +} + +/** Response from POST /v1/auth/verify. */ +export type VerifyResponse = { + /** Bearer access token for authenticated requests. */ + accessToken: string +} diff --git a/site/pages/flashnet/authentication.mdx b/site/pages/flashnet/authentication.mdx index e926aeb..4892c23 100644 --- a/site/pages/flashnet/authentication.mdx +++ b/site/pages/flashnet/authentication.mdx @@ -4,141 +4,177 @@ outline: deep # Flashnet Authentication -Authenticate with the Flashnet orchestration API using an API key. This is required before performing any swap or trade operations. +Authenticate with the Flashnet orchestration API using wallet-based challenge-response. This is required before performing any swap or trade operations. -## Vanilla JS +## Extension Setup -### Create a Client +Create a Flashnet extension and register it as a plugin: ```ts -import { createFlashnetClient } from '@mbga/flashnet' +import { createFlashnetExtension } from '@mbga/flashnet' +import { createConfig, sparkMainnet } from '@mbga/core' +import { sparkSdk } from '@mbga/connectors' -const client = createFlashnetClient({ - apiKey: 'fn_your_api_key', - // Optional: override the base URL (defaults to https://orchestration.flashnet.xyz) - baseUrl: 'https://custom.api.xyz', +const flashnet = createFlashnetExtension({ + // Optional: override the gateway URL + gatewayUrl: 'https://orchestration.flashnet.xyz', +}) + +const config = createConfig({ + network: sparkMainnet, + connectors: [sparkSdk({ mnemonic: '...' })], + plugins: [flashnet.plugin], }) ``` -The client automatically adds `Authorization: Bearer ` to every request. +The plugin subscribes to wallet disconnect events and automatically clears the auth state. + +## Authenticating with MBGA Wallet -### Verify Authentication +After a wallet is connected, call `authenticate(config)`: ```ts -import { verifyAuth } from '@mbga/flashnet' +import { connect } from '@mbga/core' -const status = await verifyAuth(client) +// Connect wallet first +await connect(config, { connector: config.connectors[0] }) -if (status.authenticated) { - console.log('Authenticated as', status.accountId) -} else { - console.log('Invalid or expired API key') -} +// Then authenticate with Flashnet +const { accessToken, publicKey } = await flashnet.authenticate(config) ``` -`verifyAuth` calls `/v1/auth/verify` and returns `{ authenticated: false }` for invalid credentials instead of throwing -- so you can handle it gracefully. +This internally: +1. Gets the identity public key from the connected wallet's SparkWallet provider +2. Runs the challenge-response flow against the Flashnet API +3. Stores the access token in the extension state + +## Custom Signer (Server/Standalone) -### Make Authenticated Requests +For server-side or custom wallet integrations: + +```ts +import { createFlashnetExtension, createSigner } from '@mbga/flashnet' + +const flashnet = createFlashnetExtension() + +const signer = createSigner({ + publicKey: '02abc...', + sign: async (messageHash) => { + // messageHash is a 32-byte SHA256 hash + return mySigningLib.sign(privateKey, messageHash) + }, +}) + +await flashnet.authenticateWithSigner(signer) +``` + +## Making Authenticated Requests ```ts // GET request -const routes = await client.request('/v1/routes') +const routes = await flashnet.request('/v1/routes') // POST request with body and idempotency key -const order = await client.request('/v1/orders', { +const order = await flashnet.request('/v1/orders', { method: 'POST', body: { amount: '1000', pair: 'BTC/SPARK' }, idempotencyKey: 'order-abc-123', }) ``` -## React +## Intent Signing -### FlashnetProvider +Flashnet operations require signed intents. Use `signIntent()` on the extension: -Wrap your app (or a subtree) with `FlashnetProvider` to make the client available to hooks. +```ts +import { createSignerFromConfig } from '@mbga/flashnet' -**With an API key:** +const signer = createSignerFromConfig(config) -```tsx -import { FlashnetProvider } from '@mbga/flashnet/react' +const { intent, signature } = await flashnet.signIntent(signer, { + userPublicKey: '02abc...', + amountIn: '1000', + nonce: 'unique-nonce', +}) -function App() { - return ( - - - - ) +// Send the intent + signature with the API request +await flashnet.request('/v1/swap', { + method: 'POST', + body: { ...JSON.parse(intent), signature }, +}) +``` + +## Checking Auth State + +```ts +// Synchronous check +if (flashnet.state.status === 'authenticated') { + console.log('Token:', flashnet.state.accessToken) + console.log('Public Key:', flashnet.state.publicKey) } + +// Subscribe to changes +const unsub = flashnet.subscribe((state) => { + console.log('Auth status:', state.status) +}) ``` -**With a pre-configured client:** +## React + +### FlashnetProvider ```tsx -import { createFlashnetClient } from '@mbga/flashnet' import { FlashnetProvider } from '@mbga/flashnet/react' +import { createFlashnetExtension } from '@mbga/flashnet' -const client = createFlashnetClient({ apiKey: 'fn_...' }) +const flashnet = createFlashnetExtension() function App() { return ( - - - + + + + + ) } ``` ### useFlashnetAuth -Automatically verifies authentication on mount. Results are cached for 5 minutes. +Reactively tracks authentication state using `useSyncExternalStore`. ```tsx import { useFlashnetAuth } from '@mbga/flashnet/react' function AuthStatus() { - const { isAuthenticated, isPending, data, error, refetch } = useFlashnetAuth() + const { isAuthenticated, isAuthenticating, isError, publicKey, error } = + useFlashnetAuth() - if (isPending) return

Verifying...

- if (error) return

Error: {error.message}

- if (isAuthenticated) return

Authenticated as {data?.accountId}

+ if (isAuthenticating) return

Authenticating...

+ if (isError) return

Error: {error?.message}

+ if (isAuthenticated) return

Authenticated: {publicKey}

return

Not authenticated

} ``` -**Parameters:** - -| Parameter | Type | Default | Description | -|-----------|------|---------|-------------| -| `client` | `FlashnetClient` | context | Override the context client | -| `enabled` | `boolean` | `true` | Enable/disable automatic verification | +### useFlashnetAuthenticate -**Return value:** - -| Field | Type | Description | -|-------|------|-------------| -| `isAuthenticated` | `boolean` | Whether the client is authenticated | -| `isPending` | `boolean` | Whether verification is in progress | -| `isSuccess` | `boolean` | Whether verification succeeded | -| `isError` | `boolean` | Whether verification failed with a non-auth error | -| `data` | `FlashnetAuthStatus` | `{ authenticated, accountId }` | -| `error` | `Error \| null` | Error from the last attempt | -| `refetch` | `() => void` | Re-run verification | - -### useFlashnetClient - -Access the Flashnet client from context for direct API calls. +Mutation hook to trigger authentication. ```tsx -import { useFlashnetClient } from '@mbga/flashnet/react' +import { useFlashnetAuthenticate } from '@mbga/flashnet/react' -function MyComponent() { - const client = useFlashnetClient() +function AuthButton() { + const { authenticate, isPending, isSuccess } = useFlashnetAuthenticate({ + config, // pass your MBGA config + }) - async function fetchRoutes() { - const routes = await client.request('/v1/routes') - // ... - } + return ( + + ) } ``` @@ -146,21 +182,32 @@ function MyComponent() { | Error | When | |-------|------| -| `FlashnetAuthError` | API key is invalid, expired, or missing | +| `FlashnetAuthError` | Challenge-response failed, token invalid | +| `FlashnetChallengeError` | Challenge request to the API failed | +| `FlashnetSigningError` | Wallet signing failed (not connected, wrong connector) | +| `FlashnetNotAuthenticatedError` | `request()` called before `authenticate()` | | `FlashnetRequestError` | Non-auth API error (check `.status` and `.code`) | -| `FlashnetClientNotConfiguredError` | Action called without creating a client first | -| `FlashnetProviderNotFoundError` | React hook used outside `FlashnetProvider` | ```ts import { FlashnetAuthError, FlashnetRequestError } from '@mbga/flashnet' try { - await client.request('/v1/orders', { method: 'POST', body: { ... } }) + await flashnet.request('/v1/orders', { method: 'POST', body: { ... } }) } catch (error) { if (error instanceof FlashnetAuthError) { - // Re-authenticate or refresh API key + // Token expired — re-authenticate + await flashnet.authenticate(config) } else if (error instanceof FlashnetRequestError) { console.log(error.status, error.code, error.message) } } ``` + +## Disconnecting + +```ts +flashnet.disconnect() +// State resets to idle, token cleared +``` + +The plugin also auto-disconnects when the MBGA wallet disconnects. diff --git a/site/pages/flashnet/index.mdx b/site/pages/flashnet/index.mdx index 4d3164c..e16c787 100644 --- a/site/pages/flashnet/index.mdx +++ b/site/pages/flashnet/index.mdx @@ -4,7 +4,7 @@ outline: deep # Flashnet -[Flashnet](https://flashnet.xyz/) is a cross-chain orchestration layer that enables atomic swaps between BTC, Lightning, Spark, and other assets. The `@mbga/flashnet` package provides API-key authentication and a typed client for interacting with the Flashnet API. +[Flashnet](https://flashnet.xyz/) is a cross-chain orchestration layer that enables atomic swaps between BTC, Lightning, Spark, and other assets. The `@mbga/flashnet` package provides challenge-response authentication and intent signing for the Flashnet API. ## Installation @@ -30,9 +30,35 @@ For React hooks, also install the optional peer dependencies: pnpm add react @tanstack/react-query ``` +## How It Works + +Flashnet uses **wallet-based authentication**, not API keys. The flow is: + +1. **Challenge** -- your app requests a challenge from `POST /v1/auth/challenge` +2. **Sign** -- the connected wallet signs the challenge with its identity key (SHA256 + secp256k1) +3. **Verify** -- the signature is sent to `POST /v1/auth/verify` to get an access token +4. **Use** -- all subsequent requests include the access token as a Bearer header + +Operations like swaps use **intent-based signing** -- each action generates a JSON intent, SHA256 hashes it, and signs with the wallet key. + ## Architecture -`@mbga/flashnet` is structured with separate entrypoints so you only pay for what you use: +`@mbga/flashnet` uses an **extension pattern** that integrates with MBGA's existing plugin system: + +```ts +const flashnet = createFlashnetExtension() + +const config = createConfig({ + plugins: [flashnet.plugin], // registers with MBGA +}) +``` + +The extension is a standalone object that: +- Exposes a `plugin` property for MBGA's `createConfig` +- Manages its own auth state (tokens, status) +- Auto-clears auth when the wallet disconnects +- Provides `request()` for authenticated API calls +- Provides `signIntent()` for operation signing | Entrypoint | Import path | Requires React | |------------|-------------|---------------| @@ -40,23 +66,26 @@ pnpm add react @tanstack/react-query | Actions | `@mbga/flashnet/actions` | No | | React | `@mbga/flashnet/react` | Yes | -The root entrypoint exports the client factory, actions, errors, and types -- everything needed for a vanilla JS/TS app. The `/react` entrypoint adds `FlashnetProvider` and hooks. - ## Quick Example ```ts -import { createFlashnetClient, verifyAuth } from '@mbga/flashnet' +import { createFlashnetExtension } from '@mbga/flashnet' +import { createConfig, sparkMainnet } from '@mbga/core' +import { sparkSdk } from '@mbga/connectors' + +const flashnet = createFlashnetExtension() -const client = createFlashnetClient({ - apiKey: 'fn_your_api_key', +const config = createConfig({ + network: sparkMainnet, + connectors: [sparkSdk({ mnemonic: '...' })], + plugins: [flashnet.plugin], }) -const status = await verifyAuth(client) -if (status.authenticated) { - console.log('Account:', status.accountId) -} +// After wallet connection: +await flashnet.authenticate(config) +const routes = await flashnet.request('/v1/routes') ``` ## Next Steps -- [Authentication](/flashnet/authentication) -- set up API-key auth with the client or React provider +- [Authentication](/flashnet/authentication) -- detailed auth setup with wallet or custom signer From 8f917ea237811adec79d4d1e801278934b96e1c1 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 12:24:46 +0200 Subject: [PATCH 6/8] fix(flashnet): clear stale token on re-auth failure, wrap network errors, wire up example auth - Clear accessToken/publicKey when re-authentication starts and on failure, preventing stale tokens from being used after a failed re-auth - Wrap fetch() calls in auth.ts with try/catch so network-level failures (offline, DNS, CORS) surface as FlashnetChallengeError/FlashnetAuthError instead of raw TypeError - Add Authenticate button to the vite-react example so the demo actually triggers the challenge-response flow when a wallet is connected - Add tests for stale token clearing and network error wrapping Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/FlashnetAuth.tsx | 42 +++++++++++++------ packages/flashnet/src/auth.test.ts | 25 +++++++++++ packages/flashnet/src/auth.ts | 40 ++++++++++++------ packages/flashnet/src/extension.test.ts | 22 ++++++++++ packages/flashnet/src/extension.ts | 14 ++++++- 5 files changed, 116 insertions(+), 27 deletions(-) diff --git a/examples/vite-react/src/components/FlashnetAuth.tsx b/examples/vite-react/src/components/FlashnetAuth.tsx index 6fc41ad..a17aed2 100644 --- a/examples/vite-react/src/components/FlashnetAuth.tsx +++ b/examples/vite-react/src/components/FlashnetAuth.tsx @@ -5,6 +5,7 @@ import { useFlashnetExtension, } from '@mbga/flashnet/react' import { useState } from 'react' +import { useConfig, useConnection } from 'mbga' const flashnet = createFlashnetExtension() @@ -12,6 +13,8 @@ function AuthStatus() { const { isAuthenticated, isAuthenticating, isError, publicKey, error } = useFlashnetAuth() const extension = useFlashnetExtension() + const config = useConfig() + const { isConnected } = useConnection() return (
@@ -44,14 +47,33 @@ function AuthStatus() {

Error: {error.message}

)} - +
+ {!isAuthenticated && ( + + )} + {isAuthenticated && ( + + )} +
+ + {!isConnected && !isAuthenticated && ( +

+ Connect a wallet first (Spark SDK connector required). +

+ )}
) } @@ -75,10 +97,6 @@ export function FlashnetAuth() { -

- Note: Authentication requires a connected wallet with identity key - signing (e.g. Spark SDK connector). -

) } diff --git a/packages/flashnet/src/auth.test.ts b/packages/flashnet/src/auth.test.ts index 52be0d2..3602367 100644 --- a/packages/flashnet/src/auth.test.ts +++ b/packages/flashnet/src/auth.test.ts @@ -120,6 +120,31 @@ describe('challengeResponse', () => { ).rejects.toThrow(FlashnetAuthError) }) + it('wraps network error on challenge fetch as FlashnetChallengeError', async () => { + mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')) + + const signer = createMockSigner() + await expect( + challengeResponse('https://test.flashnet.xyz', signer), + ).rejects.toThrow(FlashnetChallengeError) + }) + + it('wraps network error on verify fetch as FlashnetAuthError', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + challenge: 'aabb', + challengeString: 'Sign this: aabb', + }), + }) + mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch')) + + const signer = createMockSigner() + await expect( + challengeResponse('https://test.flashnet.xyz', signer), + ).rejects.toThrow(FlashnetAuthError) + }) + it('throws FlashnetAuthError when no access token returned', async () => { mockFetch.mockResolvedValueOnce({ ok: true, diff --git a/packages/flashnet/src/auth.ts b/packages/flashnet/src/auth.ts index 9ff5520..dc491fd 100644 --- a/packages/flashnet/src/auth.ts +++ b/packages/flashnet/src/auth.ts @@ -30,11 +30,18 @@ export async function challengeResponse( const publicKey = await signer.getPublicKey() // Step 1: Request challenge - const challengeRes = await fetch(`${gatewayUrl}/v1/auth/challenge`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ publicKey }), - }) + let challengeRes: Response + try { + challengeRes = await fetch(`${gatewayUrl}/v1/auth/challenge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ publicKey }), + }) + } catch (error) { + throw new FlashnetChallengeError( + `Challenge request failed: ${error instanceof Error ? error.message : 'Network error'}`, + ) + } if (!challengeRes.ok) { const text = await challengeRes.text().catch(() => '') @@ -66,14 +73,21 @@ export async function challengeResponse( } // Step 3: Verify signature and get access token - const verifyRes = await fetch(`${gatewayUrl}/v1/auth/verify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - publicKey, - signature: toHex(signature), - }), - }) + let verifyRes: Response + try { + verifyRes = await fetch(`${gatewayUrl}/v1/auth/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publicKey, + signature: toHex(signature), + }), + }) + } catch (error) { + throw new FlashnetAuthError( + `Verification request failed: ${error instanceof Error ? error.message : 'Network error'}`, + ) + } if (!verifyRes.ok) { const text = await verifyRes.text().catch(() => '') diff --git a/packages/flashnet/src/extension.test.ts b/packages/flashnet/src/extension.test.ts index 7d62382..8a55987 100644 --- a/packages/flashnet/src/extension.test.ts +++ b/packages/flashnet/src/extension.test.ts @@ -81,6 +81,28 @@ describe('createFlashnetExtension', () => { expect(ext.state.error).toBeInstanceOf(Error) }) + it('clears stale token on re-authentication failure', async () => { + // First: successful auth + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + await ext.authenticateWithSigner(createMockSigner()) + expect(ext.state.accessToken).toBe('tok_test') + + // Second: failed re-auth — stale token must be cleared + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + text: async () => 'Server Error', + }) + + await expect( + ext.authenticateWithSigner(createMockSigner()), + ).rejects.toThrow() + expect(ext.state.status).toBe('error') + expect(ext.state.accessToken).toBeNull() + expect(ext.state.publicKey).toBeNull() + }) + it('emits state changes to subscribers', async () => { mockChallengeVerifyFlow() const ext = createFlashnetExtension() diff --git a/packages/flashnet/src/extension.ts b/packages/flashnet/src/extension.ts index 5358190..a0f1be1 100644 --- a/packages/flashnet/src/extension.ts +++ b/packages/flashnet/src/extension.ts @@ -86,7 +86,12 @@ export function createFlashnetExtension( async function authenticateWithSigner( signer: FlashnetSigner, ): Promise { - setState({ status: 'authenticating', error: null }) + setState({ + status: 'authenticating', + accessToken: null, + publicKey: null, + error: null, + }) try { const result = await challengeResponse(gatewayUrl, signer) @@ -100,7 +105,12 @@ export function createFlashnetExtension( } catch (error) { const err = error instanceof Error ? error : new FlashnetAuthError(String(error)) - setState({ status: 'error', error: err }) + setState({ + status: 'error', + accessToken: null, + publicKey: null, + error: err, + }) throw err } } From babebd93a50ef085caf918a3193c8f895e5e8359 Mon Sep 17 00:00:00 2001 From: Nejc Drobnic Date: Sun, 29 Mar 2026 12:43:56 +0200 Subject: [PATCH 7/8] feat(core,flashnet): add type-safe extensions to Config for seamless integration Add an `extensions` field to `createConfig()` that stores typed extensions on the config object and auto-runs their plugins. This eliminates the need for separate providers and manual config passing. Core changes: - createConfig is now generic over TExtensions - Config carries the extensions type through ResolvedRegister - Extension plugins are auto-invoked when registered via extensions field Flashnet changes: - Extension captures configRef in its plugin, making authenticate() callable without arguments - useFlashnetExtension falls back to config.extensions.flashnet via MbgaContext - useFlashnetAuthenticate no longer requires a config parameter - FlashnetProvider becomes optional when using the extensions pattern Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/FlashnetAuth.tsx | 54 ++----------------- examples/vite-react/src/mbga.ts | 4 ++ .../vite-react/src/pages/PlaygroundPage.tsx | 23 ++++---- packages/core/src/createConfig.ts | 47 ++++++++++++++-- packages/flashnet/README.md | 31 +++++------ packages/flashnet/package.json | 4 ++ packages/flashnet/src/context.ts | 41 ++++++++++++-- packages/flashnet/src/extension.test.ts | 45 ++++++++++++++++ packages/flashnet/src/extension.ts | 22 +++++--- .../src/hooks/useFlashnetAuthenticate.ts | 26 +++------ packages/flashnet/src/types.ts | 5 +- site/pages/flashnet/authentication.mdx | 30 ++++------- site/pages/flashnet/index.mdx | 13 +++-- 13 files changed, 202 insertions(+), 143 deletions(-) diff --git a/examples/vite-react/src/components/FlashnetAuth.tsx b/examples/vite-react/src/components/FlashnetAuth.tsx index a17aed2..d259877 100644 --- a/examples/vite-react/src/components/FlashnetAuth.tsx +++ b/examples/vite-react/src/components/FlashnetAuth.tsx @@ -1,23 +1,17 @@ -import { createFlashnetExtension } from '@mbga/flashnet' import { - FlashnetProvider, useFlashnetAuth, useFlashnetExtension, } from '@mbga/flashnet/react' -import { useState } from 'react' -import { useConfig, useConnection } from 'mbga' +import { useConnection } from 'mbga' -const flashnet = createFlashnetExtension() - -function AuthStatus() { +export function FlashnetAuth() { const { isAuthenticated, isAuthenticating, isError, publicKey, error } = useFlashnetAuth() const extension = useFlashnetExtension() - const config = useConfig() const { isConnected } = useConnection() return ( -
+
extension.authenticate(config)} + onClick={() => extension.authenticate()} disabled={isAuthenticating || !isConnected} className="rounded-md bg-[var(--color-accent)] px-3 py-1.5 text-xs font-medium text-white hover:opacity-90 disabled:opacity-50" > @@ -77,43 +71,3 @@ function AuthStatus() {
) } - -export function FlashnetAuth() { - const [active, setActive] = useState(false) - - if (active) { - return ( -
-
-

Flashnet Auth Status

- -
- - - -
- ) - } - - return ( -
-

- Flashnet uses wallet-based challenge-response authentication. Connect a - Spark SDK wallet, then authenticate to get an access token. -

- -
- ) -} diff --git a/examples/vite-react/src/mbga.ts b/examples/vite-react/src/mbga.ts index ab2e268..f7374a6 100644 --- a/examples/vite-react/src/mbga.ts +++ b/examples/vite-react/src/mbga.ts @@ -1,8 +1,12 @@ import { walletStandardDiscovery, xverse } from '@mbga/connectors' +import { createFlashnetExtension } from '@mbga/flashnet' import { createConfig, sparkMainnet } from 'mbga' +export const flashnet = createFlashnetExtension() + export const config = createConfig({ network: sparkMainnet, connectors: [xverse()], plugins: [walletStandardDiscovery()], + extensions: { flashnet }, }) diff --git a/examples/vite-react/src/pages/PlaygroundPage.tsx b/examples/vite-react/src/pages/PlaygroundPage.tsx index 991e236..b99fc4c 100644 --- a/examples/vite-react/src/pages/PlaygroundPage.tsx +++ b/examples/vite-react/src/pages/PlaygroundPage.tsx @@ -176,25 +176,24 @@ isValidSparkAddress('sp1qq...') // true`, description: 'Challenge-response authentication with the Flashnet orchestration API.', code: `import { createFlashnetExtension } from '@mbga/flashnet' -import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' +import { useFlashnetAuth, useFlashnetExtension } from '@mbga/flashnet/react' +import { createConfig, sparkMainnet } from 'mbga' +// Register as an extension — auto-runs plugin, captures config const flashnet = createFlashnetExtension() -// Register flashnet.plugin in createConfig({ plugins: [...] }) - -function App() { - return ( - - - - ) -} +const config = createConfig({ + network: sparkMainnet, + extensions: { flashnet }, +}) +// No FlashnetProvider needed — hooks resolve from config.extensions function AuthStatus() { const { isAuthenticated, isAuthenticating, publicKey } = useFlashnetAuth() + const ext = useFlashnetExtension() if (isAuthenticating) return

Authenticating...

- if (isAuthenticated) return

Authenticated: {publicKey}

- return

Not authenticated

+ if (isAuthenticated) return

{publicKey}

+ return }`, component: , }, diff --git a/packages/core/src/createConfig.ts b/packages/core/src/createConfig.ts index d3d5c35..7e5e74e 100644 --- a/packages/core/src/createConfig.ts +++ b/packages/core/src/createConfig.ts @@ -33,7 +33,9 @@ import { version } from './version' * }) * ``` */ -export function createConfig(parameters: CreateConfigParameters): Config { +export function createConfig< + const TExtensions extends Record = Record, +>(parameters: CreateConfigParameters): Config { const { storage = createStorage({ storage: getDefaultStorage(), @@ -123,7 +125,8 @@ export function createConfig(parameters: CreateConfigParameters): Config { const cleanups: (() => void)[] = [] - const config: Config = { + const config: Config = { + extensions: (rest.extensions ?? {}) as TExtensions, get connectors() { return connectors.getState() }, @@ -165,7 +168,20 @@ export function createConfig(parameters: CreateConfigParameters): Config { version, } - // Run plugins after config is fully constructed + // Auto-run extension plugins + for (const ext of Object.values(rest.extensions ?? {})) { + if ( + ext && + typeof ext === 'object' && + 'plugin' in ext && + typeof (ext as { plugin: unknown }).plugin === 'function' + ) { + const cleanup = (ext as { plugin: ConfigPlugin }).plugin(config) + if (cleanup) cleanups.push(cleanup) + } + } + + // Run explicit plugins after config is fully constructed for (const plugin of rest.plugins ?? []) { const cleanup = plugin(config) if (cleanup) cleanups.push(cleanup) @@ -216,9 +232,27 @@ export type PartializedState = Compute< export type ConfigPlugin = (config: Config) => (() => void) | void /** Parameters for {@link createConfig}. */ -export type CreateConfigParameters = { +export type CreateConfigParameters< + TExtensions extends Record = Record, +> = { /** Connector factory functions to register. */ connectors?: CreateConnectorFn[] | undefined + /** + * Extensions to register on the config. Each extension is stored on `config.extensions` + * and its `plugin` (if present) is auto-invoked. + * + * @example + * ```ts + * import { createFlashnetExtension } from '@mbga/flashnet' + * + * const config = createConfig({ + * extensions: { flashnet: createFlashnetExtension() }, + * }) + * + * config.extensions.flashnet // fully typed + * ``` + */ + extensions?: TExtensions | undefined /** The Spark network to operate on (mainnet or testnet). */ network: SparkNetwork /** @@ -242,7 +276,10 @@ export type CreateConfigParameters = { } /** Central configuration object returned by {@link createConfig}. Passed to all actions and hooks. */ -export type Config = { +export type Config< + TExtensions extends Record = Record, +> = { + readonly extensions: TExtensions readonly connectors: readonly Connector[] readonly state: State readonly storage: Storage | null diff --git a/packages/flashnet/README.md b/packages/flashnet/README.md index ea3f20f..2439d4a 100644 --- a/packages/flashnet/README.md +++ b/packages/flashnet/README.md @@ -39,18 +39,20 @@ import { sparkSdk } from '@mbga/connectors' // 1. Create the extension const flashnet = createFlashnetExtension() -// 2. Register the plugin in your MBGA config +// 2. Register as an extension — auto-runs plugin, captures config const config = createConfig({ network: sparkMainnet, connectors: [sparkSdk({ mnemonic: '...' })], - plugins: [flashnet.plugin], // auto-clears auth on wallet disconnect + extensions: { flashnet }, }) -// 3. After connecting a wallet, authenticate -const { accessToken } = await flashnet.authenticate(config) +// 3. After connecting a wallet, authenticate (no config arg needed) +await flashnet.authenticate() // 4. Make authenticated requests const routes = await flashnet.request('/v1/routes') + +// config.extensions.flashnet is fully typed ``` ## Standalone (Custom Signer) @@ -70,28 +72,19 @@ await flashnet.authenticateWithSigner(signer) ## React -```tsx -import { FlashnetProvider, useFlashnetAuth } from '@mbga/flashnet/react' -import { createFlashnetExtension } from '@mbga/flashnet' +When using `createConfig({ extensions: { flashnet } })`, hooks resolve the extension automatically from `MbgaProvider` — no `FlashnetProvider` needed: -const flashnet = createFlashnetExtension() - -function App() { - return ( - - - - - - ) -} +```tsx +import { useFlashnetAuth, useFlashnetExtension } from '@mbga/flashnet/react' +// Inside — hooks just work function AuthStatus() { const { isAuthenticated, isAuthenticating, publicKey } = useFlashnetAuth() + const ext = useFlashnetExtension() if (isAuthenticating) return

Authenticating...

if (isAuthenticated) return

Authenticated: {publicKey}

- return

Not authenticated

+ return } ``` diff --git a/packages/flashnet/package.json b/packages/flashnet/package.json index d23b0d7..12f633d 100644 --- a/packages/flashnet/package.json +++ b/packages/flashnet/package.json @@ -76,6 +76,7 @@ }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", + "mbga": ">=0.0.1", "react": ">=18", "typescript": ">=5.7.3" }, @@ -83,6 +84,9 @@ "@tanstack/react-query": { "optional": true }, + "mbga": { + "optional": true + }, "react": { "optional": true }, diff --git a/packages/flashnet/src/context.ts b/packages/flashnet/src/context.ts index ef359ed..47c2e70 100644 --- a/packages/flashnet/src/context.ts +++ b/packages/flashnet/src/context.ts @@ -9,6 +9,19 @@ export const FlashnetContext = createContext( undefined, ) +// Resolve MbgaContext from optional `mbga` peer dep at module load time. +// This enables useFlashnetExtension to fall back to config.extensions.flashnet +// when no explicit FlashnetProvider is used. +let MbgaContextRef: React.Context | undefined +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mbga = require('mbga') as { MbgaContext?: React.Context } + MbgaContextRef = mbga.MbgaContext +} catch { + // mbga not installed — FlashnetProvider is required +} +const FallbackContext = createContext(undefined) + /** Props for {@link FlashnetProvider}. */ export type FlashnetProviderProps = { /** The Flashnet extension created by `createFlashnetExtension()`. */ @@ -17,6 +30,7 @@ export type FlashnetProviderProps = { /** * Provider component that makes a Flashnet extension available to all hooks. + * Optional when using `createConfig({ extensions: { flashnet } })` with `MbgaProvider`. * * @example * ```tsx @@ -42,12 +56,29 @@ export function FlashnetProvider( } /** - * Hook to access the Flashnet extension from context. + * Hook to access the Flashnet extension. + * + * Resolution order: + * 1. Explicit `FlashnetProvider` context + * 2. `config.extensions.flashnet` via `MbgaProvider` (when `mbga` is installed) * - * @throws {FlashnetProviderNotFoundError} If used outside of FlashnetProvider. + * @throws {FlashnetProviderNotFoundError} If the extension cannot be resolved. */ export function useFlashnetExtension(): FlashnetExtension { - const context = useContext(FlashnetContext) - if (!context) throw new FlashnetProviderNotFoundError() - return context + const flashnetCtx = useContext(FlashnetContext) + const mbgaConfig = useContext(MbgaContextRef ?? FallbackContext) + + if (flashnetCtx) return flashnetCtx + + if ( + mbgaConfig && + typeof mbgaConfig === 'object' && + 'extensions' in mbgaConfig + ) { + const ext = (mbgaConfig as { extensions: Record }) + .extensions.flashnet + if (ext) return ext as FlashnetExtension + } + + throw new FlashnetProviderNotFoundError() } diff --git a/packages/flashnet/src/extension.test.ts b/packages/flashnet/src/extension.test.ts index 8a55987..660a3de 100644 --- a/packages/flashnet/src/extension.test.ts +++ b/packages/flashnet/src/extension.test.ts @@ -241,6 +241,51 @@ describe('createFlashnetExtension', () => { }) }) + describe('authenticate (config-optional)', () => { + it('throws when no config captured and none provided', async () => { + const ext = createFlashnetExtension() + await expect(ext.authenticate()).rejects.toThrow( + 'Flashnet extension is not configured', + ) + }) + + it('uses captured configRef when plugin has run', async () => { + mockChallengeVerifyFlow() + const ext = createFlashnetExtension() + + // Simulate what createConfig does: run the plugin with a mock config + const mockWallet = { + getIdentityPublicKey: vi.fn().mockResolvedValue('02abc123'), + signMessageWithIdentityKey: vi.fn().mockResolvedValue('abcd'), + } + const mockConfig = { + state: { + current: 'uid1', + connections: new Map([ + [ + 'uid1', + { + connector: { + name: 'sparkSdk', + getProvider: vi.fn().mockResolvedValue(mockWallet), + }, + accounts: ['sp1...'], + }, + ], + ]), + status: 'connected', + }, + subscribe: vi.fn().mockReturnValue(() => {}), + } as any + + // Run the plugin to capture configRef + ext.plugin(mockConfig) + + const result = await ext.authenticate() + expect(result.accessToken).toBe('tok_test') + }) + }) + describe('subscribe', () => { it('returns unsubscribe function', async () => { mockChallengeVerifyFlow() diff --git a/packages/flashnet/src/extension.ts b/packages/flashnet/src/extension.ts index a0f1be1..7cc82d8 100644 --- a/packages/flashnet/src/extension.ts +++ b/packages/flashnet/src/extension.ts @@ -2,6 +2,7 @@ import type { Config, ConfigPlugin } from '@mbga/core' import { challengeResponse } from './auth' import { FlashnetAuthError, + FlashnetExtensionNotConfiguredError, FlashnetNotAuthenticatedError, FlashnetRequestError, } from './errors' @@ -31,7 +32,8 @@ const INITIAL_STATE: FlashnetState = { * The extension provides challenge-response authentication using the connected * wallet's identity key, authenticated API requests, and intent signing. * - * Register `extension.plugin` in your MBGA config to auto-clear auth on wallet disconnect. + * Register as an extension in `createConfig` for seamless integration (auto-runs plugin, + * captures config reference, enables `authenticate()` without arguments). * * @example * ```ts @@ -44,11 +46,11 @@ const INITIAL_STATE: FlashnetState = { * const config = createConfig({ * network: sparkMainnet, * connectors: [sparkSdk({ mnemonic: '...' })], - * plugins: [flashnet.plugin], + * extensions: { flashnet }, * }) * * // After connecting a wallet: - * const { accessToken } = await flashnet.authenticate(config) + * await flashnet.authenticate() // config captured automatically * ``` */ export function createFlashnetExtension( @@ -65,8 +67,12 @@ export function createFlashnetExtension( for (const listener of listeners) listener(state) } - // ConfigPlugin — auto-clear auth when wallet disconnects + // Config reference captured when the plugin runs (via createConfig extensions or plugins) + let configRef: Config | null = null + + // ConfigPlugin — captures config ref and auto-clears auth when wallet disconnects const plugin: ConfigPlugin = (config: Config) => { + configRef = config const unsubscribe = config.subscribe( (s) => s.status, (status) => { @@ -78,8 +84,12 @@ export function createFlashnetExtension( return unsubscribe as () => void } - async function authenticate(config: Config): Promise { - const signer = createSignerFromConfig(config) + async function authenticate(config?: Config): Promise { + const resolved = config ?? configRef + if (!resolved) { + throw new FlashnetExtensionNotConfiguredError() + } + const signer = createSignerFromConfig(resolved) return authenticateWithSigner(signer) } diff --git a/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts b/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts index 9a9a9f2..f297e9e 100644 --- a/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts +++ b/packages/flashnet/src/hooks/useFlashnetAuthenticate.ts @@ -1,9 +1,7 @@ 'use client' import { useMutation } from '@tanstack/react-query' -import { useContext } from 'react' -import { FlashnetContext } from '../context' -import { FlashnetProviderNotFoundError } from '../errors' +import { useFlashnetExtension } from '../context' import type { FlashnetAuthResult, FlashnetExtension } from '../types' /** Parameters for {@link useFlashnetAuthenticate}. */ @@ -33,8 +31,8 @@ export type UseFlashnetAuthenticateReturnType = { /** * Mutation hook that triggers Flashnet challenge-response authentication. * - * Requires both an MBGA config (via `MbgaProvider`) and a Flashnet extension (via `FlashnetProvider`). - * The config must have a connected wallet to provide the signer. + * When the extension is registered via `createConfig({ extensions: { flashnet } })`, + * no config passing is needed — the extension captures the config reference automatically. * * @example * ```tsx @@ -52,26 +50,14 @@ export type UseFlashnetAuthenticateReturnType = { * ``` */ export function useFlashnetAuthenticate( - parameters: UseFlashnetAuthenticateParameters & { - /** The MBGA config. If not provided, you must pass it to authenticate(). */ - config?: Parameters[0] - } = {}, + parameters: UseFlashnetAuthenticateParameters = {}, ): UseFlashnetAuthenticateReturnType { - const contextExtension = useContext(FlashnetContext) + const contextExtension = useFlashnetExtension() const extension = parameters.extension ?? contextExtension - if (!extension) throw new FlashnetProviderNotFoundError() - const mutation = useMutation({ mutationKey: ['flashnet', 'authenticate'], - mutationFn: async () => { - if (!parameters.config) { - throw new Error( - 'MBGA config is required. Pass it via useFlashnetAuthenticate({ config }).', - ) - } - return extension.authenticate(parameters.config) - }, + mutationFn: () => extension.authenticate(), }) return { diff --git a/packages/flashnet/src/types.ts b/packages/flashnet/src/types.ts index fa2c67d..b4a71b6 100644 --- a/packages/flashnet/src/types.ts +++ b/packages/flashnet/src/types.ts @@ -67,9 +67,10 @@ export type FlashnetExtension = { subscribe(listener: (state: FlashnetState) => void): () => void /** * Authenticate using the connected wallet from the MBGA config. - * Gets the identity public key and signing capability from the current connector. + * When the extension is registered via `createConfig({ extensions: { flashnet } })`, + * the config is captured automatically and this can be called without arguments. */ - authenticate(config: Config): Promise + authenticate(config?: Config): Promise /** * Authenticate using an explicit signer (for standalone/server use). */ diff --git a/site/pages/flashnet/authentication.mdx b/site/pages/flashnet/authentication.mdx index 4892c23..ab70ffb 100644 --- a/site/pages/flashnet/authentication.mdx +++ b/site/pages/flashnet/authentication.mdx @@ -8,7 +8,7 @@ Authenticate with the Flashnet orchestration API using wallet-based challenge-re ## Extension Setup -Create a Flashnet extension and register it as a plugin: +Create a Flashnet extension and register it in your config: ```ts import { createFlashnetExtension } from '@mbga/flashnet' @@ -23,15 +23,15 @@ const flashnet = createFlashnetExtension({ const config = createConfig({ network: sparkMainnet, connectors: [sparkSdk({ mnemonic: '...' })], - plugins: [flashnet.plugin], + extensions: { flashnet }, // auto-runs plugin, captures config }) ``` -The plugin subscribes to wallet disconnect events and automatically clears the auth state. +The extension's plugin subscribes to wallet disconnect events and automatically clears auth state. Because it's registered via `extensions`, the config is captured — so `authenticate()` needs no arguments. ## Authenticating with MBGA Wallet -After a wallet is connected, call `authenticate(config)`: +After a wallet is connected, call `authenticate()`: ```ts import { connect } from '@mbga/core' @@ -39,8 +39,8 @@ import { connect } from '@mbga/core' // Connect wallet first await connect(config, { connector: config.connectors[0] }) -// Then authenticate with Flashnet -const { accessToken, publicKey } = await flashnet.authenticate(config) +// Then authenticate with Flashnet — config is captured automatically +const { accessToken, publicKey } = await flashnet.authenticate() ``` This internally: @@ -121,20 +121,14 @@ const unsub = flashnet.subscribe((state) => { ## React -### FlashnetProvider +When using `createConfig({ extensions: { flashnet } })`, hooks resolve the extension automatically from `MbgaProvider` — no `FlashnetProvider` needed: ```tsx -import { FlashnetProvider } from '@mbga/flashnet/react' -import { createFlashnetExtension } from '@mbga/flashnet' - -const flashnet = createFlashnetExtension() - +// Just MbgaProvider — no FlashnetProvider wrapper function App() { return ( - - - + ) } @@ -166,9 +160,7 @@ Mutation hook to trigger authentication. import { useFlashnetAuthenticate } from '@mbga/flashnet/react' function AuthButton() { - const { authenticate, isPending, isSuccess } = useFlashnetAuthenticate({ - config, // pass your MBGA config - }) + const { authenticate, isPending, isSuccess } = useFlashnetAuthenticate() return (