From de4b7c1e19e681aa26e667257ea8b2728d3b4cd3 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 24 Jul 2025 10:35:10 -0700 Subject: [PATCH 1/6] Decouple FCL from global serviceRegistry and pluginRegistry --- .changeset/two-meals-allow.md | 6 + docs/architecture/api-design-discussion.md | 174 ++++++++++++++++++ docs/architecture/plugin-system-decoupling.md | 163 ++++++++++++++++ package-lock.json | 2 +- packages/fcl-core/src/client.ts | 7 + packages/fcl-core/src/context/global.ts | 4 +- packages/fcl-core/src/context/index.ts | 21 +++ .../src/current-user/exec-service/index.ts | 28 ++- .../src/current-user/exec-service/plugins.ts | 23 ++- .../src/current-user/exec-service/wc-check.ts | 8 +- packages/fcl-core/src/current-user/index.ts | 29 ++- packages/fcl-core/src/discovery/services.ts | 7 +- .../fcl-core/src/discovery/services/authn.ts | 15 +- packages/fcl-core/src/discovery/utils.ts | 8 +- .../fcl-core/src/test-utils/mock-context.ts | 55 ++++++ 15 files changed, 510 insertions(+), 40 deletions(-) create mode 100644 .changeset/two-meals-allow.md create mode 100644 docs/architecture/api-design-discussion.md create mode 100644 docs/architecture/plugin-system-decoupling.md diff --git a/.changeset/two-meals-allow.md b/.changeset/two-meals-allow.md new file mode 100644 index 000000000..7bd3c9de9 --- /dev/null +++ b/.changeset/two-meals-allow.md @@ -0,0 +1,6 @@ +--- +"@onflow/fcl-core": minor +"@onflow/fcl": minor +--- + +Decouple library from global `serviceRegistry` and `pluginRegistry` diff --git a/docs/architecture/api-design-discussion.md b/docs/architecture/api-design-discussion.md new file mode 100644 index 000000000..0f0f7b266 --- /dev/null +++ b/docs/architecture/api-design-discussion.md @@ -0,0 +1,174 @@ +# FCL API Design Discussion: Instance vs Context-Passing + +**Date:** July 24, 2025 +**Context:** Migration away from global state, evaluating instance-based vs context-passing patterns + +## Background + +FCL was migrated from global state to an instance-based approach: + +```javascript +// Old global approach +mutate(transaction) + +// New instance approach +const fcl = createFCL(config) +fcl.mutate(transaction) +``` + +The global functions still exist for backward compatibility, but now there are also context-bound functions in the instance. + +## Question: Was this the right move vs tree-shakable context-passing? + +## Option 1: Instance-Based Approach (Current) + +```javascript +import { createFCL } from 'fcl-js' +const fcl = createFCL(config) +fcl.mutate(params) +fcl.query(script) +fcl.authenticate() +``` + +**Benefits:** +- ✅ Better developer experience (DX) +- ✅ Cleaner, more intuitive API +- ✅ Context binding prevents errors +- ✅ Better IDE autocomplete/TypeScript support +- ✅ Backward compatibility maintained +- ✅ Methods are discoverable on the instance + +**Drawbacks:** +- ❌ Potentially larger bundles (imports entire instance) +- ❌ Less tree-shakable + +## Option 2: Context-Passing Functions + +```javascript +import { mutate, query, authenticate, createContext } from 'fcl-js' +const context = createContext(config) +mutate(context, params) +query(context, script) +authenticate(context) +``` + +**Benefits:** +- ✅ Better tree-shaking (only import what you use) +- ✅ Functional programming style +- ✅ Better bundle size optimization + +**Drawbacks:** +- ❌ More verbose API +- ❌ Easy to forget context parameter +- ❌ Context gets passed everywhere +- ❌ Harder to discover available methods +- ❌ More imports needed + +## Option 3: Overloaded Functions (Hybrid) + +Support both patterns with function overloads: + +```javascript +export function mutate(contextOrFirstArg, ...args) { + // Check if first arg is a context object + if (contextOrFirstArg && typeof contextOrFirstArg === 'object' && contextOrFirstArg._isContext) { + // New API: mutate(context, ...params) + return mutateFn(contextOrFirstArg, ...args) + } else { + // Legacy API: mutate(...params) - use global context + return mutateFn(getGlobalContext(), contextOrFirstArg, ...args) + } +} +``` + +**Usage Examples:** +```javascript +// Legacy (still works) +import { mutate } from 'fcl-js' +mutate(transaction, options) + +// New context-passing (tree-shakable) +import { mutate, createContext } from 'fcl-js' +const context = createContext(config) +mutate(context, transaction, options) + +// Instance-based (also still works) +const fcl = createFCL(config) +fcl.mutate(transaction, options) +``` + +**Benefits:** +- ✅ Zero breaking changes +- ✅ Tree-shakable for new users +- ✅ Clear migration path +- ✅ Single function handles both patterns +- ✅ Single API surface + +## Option 4: Both Patterns Side-by-Side + +```javascript +// Instance-based API +export function createFCL(config) { + const context = createContext(config) + + return { + mutate: (...params) => mutate(context, ...params), + query: (...params) => query(context, ...params), + authenticate: (...params) => authenticate(context, ...params), + } +} + +// Context-passing API (tree-shakable) +export { mutate, query, authenticate } from './core' +export { createContext } from './context' +``` + +## Analysis & Recommendation + +### Tree-Shaking Considerations +- **Context-passing** is more tree-shakable +- **Instance-based** potentially bundles all methods +- For SDK-style libraries like FCL, users typically use multiple functions anyway + +### Developer Experience (DX) +- **Instance-based** has significantly better DX +- **Context-passing** is more verbose and error-prone +- IDE support and discoverability favor instance approach + +### Library Comparisons +- **React Query**: Uses hooks + context provider pattern +- **Zustand**: Primarily instance-based, supports context for SSR +- **Jotai**: Supports both global and explicit store patterns + +### Final Assessment + +**For FCL specifically:** + +1. **Instance-based approach is better for DX** - the primary consideration for an SDK +2. **Context-passing has minimal tree-shaking benefit** since FCL users typically use multiple functions +3. **Overloaded functions provide best of both worlds** but add complexity +4. **Current instance approach strikes the right balance** + +## Decision + +**Stick with the instance-based approach** because: +- FCL is a cohesive SDK, not a utility library +- Developer experience is crucial for adoption +- Most users will use several functions together anyway +- Bundle size trade-off is acceptable for the DX benefits +- Maintains clean, intuitive API surface + +The migration away from global state to instances was the right architectural choice. + +## DX vs Tree-Shaking Trade-off Summary + +| Aspect | Instance-Based | Context-Passing | +|--------|---------------|-----------------| +| **DX** | ✅ Excellent | ❌ Verbose | +| **Tree-shaking** | ❌ Limited | ✅ Excellent | +| **Discoverability** | ✅ Great | ❌ Poor | +| **Error-prone** | ✅ Low | ❌ High | +| **Bundle size** | ❌ Larger | ✅ Smaller | +| **API simplicity** | ✅ Clean | ❌ Complex | + +**Conclusion:** For FCL, DX wins over tree-shaking optimization. diff --git a/docs/architecture/plugin-system-decoupling.md b/docs/architecture/plugin-system-decoupling.md new file mode 100644 index 000000000..5cd160069 --- /dev/null +++ b/docs/architecture/plugin-system-decoupling.md @@ -0,0 +1,163 @@ +# Plugin System Decoupling - Implementation Summary + +## Overview + +The FCL plugin system has been successfully decoupled from global state while maintaining full backward compatibility. The system now supports both global (legacy) and context-aware (new) usage patterns. + +## Changes Made + +### 1. Core Plugin Functions Refactored + +**File: `packages/fcl-core/src/current-user/exec-service/plugins.ts`** + +- `ServiceRegistry` → `createServiceRegistry`: Now creates isolated service registries +- `PluginRegistry` → `createPluginRegistry`: Now creates isolated plugin registries +- Added `createRegistries()`: Factory function for context-aware registry pairs +- Maintained global registries for backward compatibility + +### 2. Context Integration + +**File: `packages/fcl-core/src/context/index.ts`** + +- Added `serviceRegistry` and `pluginRegistry` to `FCLContext` interface +- Updated `createFCLContext()` to create context-specific registries +- Added optional `coreStrategies` parameter to configuration + +**File: `packages/fcl-core/src/client.ts`** + +- Added `coreStrategies` to `FlowClientCoreConfig` interface +- Exposed `serviceRegistry` and `pluginRegistry` on the client instance + +### 3. Execution Service Updates + +**File: `packages/fcl-core/src/current-user/exec-service/index.ts`** + +- Added optional `serviceRegistry` parameter to `execService()` and `execStrategy()` +- Functions fall back to global registry when context registry not provided + +### 4. Current User Integration + +**File: `packages/fcl-core/src/current-user/index.ts`** + +- Updated all `execService()` calls to pass context-aware `serviceRegistry` +- Added `serviceRegistry` parameter to current user context interfaces + +## Usage Patterns + +### 1. Global Registry (Backward Compatible) + +```javascript +import { pluginRegistry } from '@onflow/fcl-core' + +// This still works exactly as before +pluginRegistry.add({ + name: "MyWalletPlugin", + f_type: "ServicePlugin", + type: "discovery-service", + services: [...], + serviceStrategy: { method: "CUSTOM/RPC", exec: customExecFunction } +}) +``` + +### 2. Context-Aware Registries (New) + +```javascript +import { createFlowClientCore } from '@onflow/fcl-core' + +// Create client with custom core strategies +const fcl = createFlowClientCore({ + accessNodeUrl: "https://rest-testnet.onflow.org", + platform: "web", + storage: myStorage, + computeLimit: 1000, + coreStrategies: { + "HTTP/POST": httpPostStrategy, + "IFRAME/RPC": iframeRpcStrategy, + "CUSTOM/RPC": myCustomStrategy + } +}) + +// Add plugins to this specific instance +fcl.pluginRegistry.add({ + name: "InstanceSpecificPlugin", + f_type: "ServicePlugin", + type: "discovery-service", + services: [...], + serviceStrategy: { method: "INSTANCE/RPC", exec: instanceExecFunction } +}) + +// This plugin only affects this FCL instance, not others +``` + +### 3. Multiple Isolated Instances + +```javascript +import { createFlowClientCore } from '@onflow/fcl-core' + +// Testnet instance with its own plugins +const testnetFcl = createFlowClientCore({ + accessNodeUrl: "https://rest-testnet.onflow.org", + platform: "web", + storage: testnetStorage, + computeLimit: 1000, + coreStrategies: testnetStrategies +}) + +// Mainnet instance with different plugins +const mainnetFcl = createFlowClientCore({ + accessNodeUrl: "https://rest-mainnet.onflow.org", + platform: "web", + storage: mainnetStorage, + computeLimit: 1000, + coreStrategies: mainnetStrategies +}) + +// Add different plugins to each instance +testnetFcl.pluginRegistry.add(testnetSpecificPlugin) +mainnetFcl.pluginRegistry.add(mainnetSpecificPlugin) + +// Each instance operates independently +``` + +### 4. Direct Registry Creation + +```javascript +import { createRegistries } from '@onflow/fcl-core' + +// Create registries directly for advanced use cases +const { serviceRegistry, pluginRegistry } = createRegistries({ + coreStrategies: { + "HTTP/POST": myHttpStrategy, + "WEBSOCKET/RPC": myWebSocketStrategy + } +}) + +// Use the isolated registries +pluginRegistry.add(myPlugin) +const services = serviceRegistry.getServices() +``` + +## Benefits Achieved + +1. **Zero Breaking Changes**: All existing code continues to work +2. **Instance Isolation**: Multiple FCL instances can have different plugin configurations +3. **Better Testing**: Each test can use isolated registries +4. **Reduced Global State**: Context-aware usage avoids global pollution +5. **Enhanced Flexibility**: Advanced users can create custom registry configurations + +## Backward Compatibility + +- Global `pluginRegistry` and `getServiceRegistry()` functions remain unchanged +- All existing plugin code will continue to work without modifications +- Legacy patterns are maintained while new patterns are available + +## Migration Path + +Developers can gradually migrate from global to context-aware patterns: + +1. **Immediate**: Continue using global registries (no changes needed) +2. **Phase 1**: Start using `createFlowClientCore()` with instance-specific plugins +3. **Phase 2**: Gradually move plugin registrations to context-aware patterns +4. **Long-term**: Consider deprecating global registry usage in favor of context-aware patterns + +This implementation provides the foundation for advanced FCL usage while maintaining the simplicity that makes FCL accessible to all developers. diff --git a/package-lock.json b/package-lock.json index d33a8cf2a..6806a10c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33106,7 +33106,7 @@ }, "packages/fcl-rainbowkit-adapter": { "name": "@onflow/fcl-rainbowkit-adapter", - "version": "0.2.3.alpha.1", + "version": "0.2.3.alpha-1", "license": "Apache-2.0", "dependencies": { "@babel/runtime": "^7.25.7", diff --git a/packages/fcl-core/src/client.ts b/packages/fcl-core/src/client.ts index 7e29c915f..5a78cee5a 100644 --- a/packages/fcl-core/src/client.ts +++ b/packages/fcl-core/src/client.ts @@ -54,6 +54,9 @@ export interface FlowClientCoreConfig { transport?: SdkTransport customResolver?: any customDecoders?: any + + // Core strategies for plugin system + coreStrategies?: any } export function createFlowClientCore(params: FlowClientCoreConfig) { @@ -85,6 +88,10 @@ export function createFlowClientCore(params: FlowClientCoreConfig) { // Utility methods serialize: createSerialize(context), + // Plugin system (context-aware) + serviceRegistry: context.serviceRegistry, + pluginRegistry: context.pluginRegistry, + // Re-export the SDK methods ...context.sdk, } diff --git a/packages/fcl-core/src/context/global.ts b/packages/fcl-core/src/context/global.ts index 43388cb6e..43b2a9cf0 100644 --- a/packages/fcl-core/src/context/global.ts +++ b/packages/fcl-core/src/context/global.ts @@ -9,6 +9,7 @@ import { block, resolve, } from "@onflow/sdk" +import {getServiceRegistry} from "../current-user/exec-service/plugins" /** * Note to self: @@ -24,7 +25,7 @@ import { */ export function createPartialGlobalFCLContext(): Pick< FCLContext, - "config" | "sdk" + "config" | "sdk" | "serviceRegistry" > { return { config: _config(), @@ -37,5 +38,6 @@ export function createPartialGlobalFCLContext(): Pick< block, resolve, }, + serviceRegistry: getServiceRegistry(), } } diff --git a/packages/fcl-core/src/context/index.ts b/packages/fcl-core/src/context/index.ts index 96cc0c7fc..c5b7b5f01 100644 --- a/packages/fcl-core/src/context/index.ts +++ b/packages/fcl-core/src/context/index.ts @@ -3,6 +3,11 @@ import {StorageProvider} from "../fcl-core" import {createSdkClient, SdkClientOptions} from "@onflow/sdk" import {getContracts} from "@onflow/config" import {invariant} from "@onflow/util-invariant" +import { + createPluginRegistry, + createServiceRegistry, +} from "../current-user/exec-service/plugins" + interface FCLConfig { accessNodeUrl: string transport: SdkClientOptions["transport"] @@ -29,6 +34,8 @@ interface FCLConfig { appDetailUrl?: string // Service configuration serviceOpenIdScopes?: string[] + // Core strategies for plugin system + coreStrategies?: any } // Define a compatibility config interface for backward compatibility @@ -59,6 +66,10 @@ export interface FCLContext { /** Legacy config compatibility layer */ config: ConfigService platform: string + /** Service registry for managing wallet communication strategies */ + serviceRegistry: ReturnType + /** Plugin registry for managing FCL plugins */ + pluginRegistry: ReturnType } /** @@ -98,6 +109,13 @@ export function createFCLContext(config: FCLConfig): FCLContext { const configService = createConfigService(config) + const serviceRegistry = createServiceRegistry({ + coreStrategies: config.coreStrategies || {}, + }) + const pluginRegistry = createPluginRegistry({ + getServiceRegistry: () => serviceRegistry, + }) + const currentUser = createUser({ platform: config.platform, storage: config.storage, @@ -106,6 +124,7 @@ export function createFCLContext(config: FCLConfig): FCLContext { execStrategy: config.discovery?.execStrategy, }, sdk, + serviceRegistry, }) return { @@ -114,6 +133,8 @@ export function createFCLContext(config: FCLConfig): FCLContext { sdk: sdk, config: configService, platform: config.platform, + serviceRegistry, + pluginRegistry, } } diff --git a/packages/fcl-core/src/current-user/exec-service/index.ts b/packages/fcl-core/src/current-user/exec-service/index.ts index 2463a19e2..1194311dc 100644 --- a/packages/fcl-core/src/current-user/exec-service/index.ts +++ b/packages/fcl-core/src/current-user/exec-service/index.ts @@ -30,6 +30,7 @@ export interface ExecServiceParams { abortSignal?: AbortSignal execStrategy?: (params: ExecStrategyParams) => Promise user?: CurrentUser + serviceRegistry?: any // Optional service registry for context-aware usage } export interface StrategyResponse { @@ -63,16 +64,26 @@ export type StrategyFunction = ( * It's used internally by FCL to handle different communication methods with wallet services. * * @param params The parameters object containing service details and execution context + * @param params.serviceRegistry Optional service registry to use (falls back to global if not provided) * @returns Promise resolving to the strategy response * * @example - * // Execute a service strategy (internal usage) + * // Execute a service strategy (internal usage with global registry) * const response = await execStrategy({ * service: { method: "HTTP/POST", endpoint: "https://wallet.example.com/authz" }, * body: { transaction: "..." }, * config: execConfig, * abortSignal: controller.signal * }) + * + * // Execute with context-aware registry + * const response = await execStrategy({ + * service: { method: "HTTP/POST", endpoint: "https://wallet.example.com/authz" }, + * body: { transaction: "..." }, + * config: execConfig, + * abortSignal: controller.signal, + * serviceRegistry: myContextRegistry + * }) */ export const execStrategy = async ({ service, @@ -82,10 +93,10 @@ export const execStrategy = async ({ customRpc, user, opts, -}: ExecStrategyParams): Promise => { - const strategy = getServiceRegistry().getStrategy( - service.method - ) as StrategyFunction + serviceRegistry, +}: ExecStrategyParams & {serviceRegistry?: any}): Promise => { + const registry = serviceRegistry || getServiceRegistry() + const strategy = registry.getStrategy(service.method) as StrategyFunction return strategy({service, body, config, abortSignal, customRpc, opts, user}) } @@ -106,7 +117,7 @@ export const execStrategy = async ({ * }) */ export async function execService( - context: Pick, + context: Pick, { service, msg = {}, @@ -116,10 +127,11 @@ export async function execService( abortSignal = new AbortController().signal, execStrategy: _execStrategy, user, + serviceRegistry, }: ExecServiceParams ): Promise { // Notify the developer if WalletConnect is not enabled - checkWalletConnectEnabled() + checkWalletConnectEnabled(context) msg.data = service.data const execConfig: ExecConfig = { @@ -143,6 +155,7 @@ export async function execService( opts, user, abortSignal, + serviceRegistry, }) if (res.status === "REDIRECT") { @@ -158,6 +171,7 @@ export async function execService( abortSignal, platform, user, + serviceRegistry, }) } else { return res diff --git a/packages/fcl-core/src/current-user/exec-service/plugins.ts b/packages/fcl-core/src/current-user/exec-service/plugins.ts index 314f985e9..7ac8efb47 100644 --- a/packages/fcl-core/src/current-user/exec-service/plugins.ts +++ b/packages/fcl-core/src/current-user/exec-service/plugins.ts @@ -51,7 +51,11 @@ const validateDiscoveryPlugin = (servicePlugin: any) => { return {discoveryServices: services, serviceStrategy} } -const ServiceRegistry = ({coreStrategies}: {coreStrategies: any}) => { +export const createServiceRegistry = ({ + coreStrategies, +}: { + coreStrategies: any +}) => { let services = new Set() let strategies = new Map(Object.entries(coreStrategies)) @@ -114,7 +118,11 @@ const validatePlugins = (plugins: any[]) => { return pluginsArray } -const PluginRegistry = () => { +export const createPluginRegistry = ({ + getServiceRegistry, +}: { + getServiceRegistry?: () => ReturnType +}) => { const pluginsMap = new Map() const getPlugins = () => pluginsMap @@ -124,7 +132,7 @@ const PluginRegistry = () => { for (const p of pluginsArray) { pluginsMap.set(p.name, p) if (p.f_type === "ServicePlugin") { - serviceRegistry.add(p) + getServiceRegistry?.().add(p) } } } @@ -135,7 +143,8 @@ const PluginRegistry = () => { }) } -let serviceRegistry: ReturnType +// Global state management (for backward compatibility) +let serviceRegistry: ReturnType const getIsServiceRegistryInitialized = () => typeof serviceRegistry !== "undefined" @@ -166,7 +175,7 @@ export const initServiceRegistry = ({ if (getIsServiceRegistryInitialized()) { return serviceRegistry } - const _serviceRegistry = ServiceRegistry({coreStrategies}) + const _serviceRegistry = createServiceRegistry({coreStrategies}) serviceRegistry = _serviceRegistry return _serviceRegistry @@ -212,4 +221,6 @@ export const getServiceRegistry = () => { * serviceStrategy: { method: "CUSTOM/RPC", exec: customExecFunction } * }) */ -export const pluginRegistry = PluginRegistry() +export const pluginRegistry = createPluginRegistry({ + getServiceRegistry, +}) diff --git a/packages/fcl-core/src/current-user/exec-service/wc-check.ts b/packages/fcl-core/src/current-user/exec-service/wc-check.ts index fe940093b..c1b451b68 100644 --- a/packages/fcl-core/src/current-user/exec-service/wc-check.ts +++ b/packages/fcl-core/src/current-user/exec-service/wc-check.ts @@ -1,5 +1,5 @@ import * as logger from "@onflow/util-logger" -import {getServiceRegistry} from "./plugins" +import {FCLContext} from "../../context" const FCL_WC_SERVICE_METHOD = "WC/RPC" @@ -25,10 +25,12 @@ const isServerSide = typeof window === "undefined" * }) */ // Utility to notify the user if the Walletconnect service plugin has not been loaded -export function checkWalletConnectEnabled() { +export function checkWalletConnectEnabled( + context: Pick +) { if (isServerSide) return - const serviceRegistry = getServiceRegistry() + const serviceRegistry = context.serviceRegistry const strategies = serviceRegistry.getStrategies() if (!strategies.includes(FCL_WC_SERVICE_METHOD)) { diff --git a/packages/fcl-core/src/current-user/index.ts b/packages/fcl-core/src/current-user/index.ts index 9918eed46..4d6f0c8e2 100644 --- a/packages/fcl-core/src/current-user/index.ts +++ b/packages/fcl-core/src/current-user/index.ts @@ -41,7 +41,8 @@ export interface CurrentUserConfig { getStorageProvider: () => Promise } -export interface CurrentUserContext extends Pick { +export interface CurrentUserContext + extends Pick { platform: string getStorageProvider: () => Promise discovery?: { @@ -257,17 +258,20 @@ async function getAccountProofData( return accountProofData } -const makeConfig = async ({ - discoveryAuthnInclude, - discoveryAuthnExclude, - discoveryFeaturesSuggested, -}: MakeConfigOptions): Promise> => { +const makeConfig = async ( + context: Pick, + { + discoveryAuthnInclude, + discoveryAuthnExclude, + discoveryFeaturesSuggested, + }: MakeConfigOptions +): Promise> => { return { client: { discoveryAuthnInclude, discoveryAuthnExclude, discoveryFeaturesSuggested, - clientServices: await makeDiscoveryServices(), + clientServices: await makeDiscoveryServices(context), supportedStrategies: getServiceRegistry().getStrategies(), }, } @@ -367,7 +371,7 @@ const createAuthenticate = const response: any = await execService(context, { service: discoveryService, msg: accountProofData, - config: await makeConfig(discoveryService), + config: await makeConfig(context, discoveryService), opts, platform: context.platform, execStrategy: (context.discovery as any)?.execStrategy, @@ -668,6 +672,7 @@ const createSignUserMessage = msg: makeSignable(msg), platform: context.platform, user, + serviceRegistry: context.serviceRegistry, }) if (Array.isArray(response)) { return response.map(compSigs => normalizeCompositeSignature(compSigs)) @@ -699,7 +704,10 @@ const _createUser = (context: CurrentUserContext): CurrentUserService => { } const createUser = ( - context: Pick & { + context: Pick< + FCLContext, + "config" | "sdk" | "storage" | "serviceRegistry" + > & { platform: string discovery?: { execStrategy?: (...args: any[]) => any @@ -807,6 +815,9 @@ const getCurrentUser = (cfg: CurrentUserConfig): CurrentUserService => { getStorageProvider, platform: cfg.platform, actorName: NAME, + get serviceRegistry() { + return getServiceRegistry() + }, }) } diff --git a/packages/fcl-core/src/discovery/services.ts b/packages/fcl-core/src/discovery/services.ts index 07a7550f2..d1a31788a 100644 --- a/packages/fcl-core/src/discovery/services.ts +++ b/packages/fcl-core/src/discovery/services.ts @@ -1,5 +1,4 @@ import {invariant} from "@onflow/util-invariant" -import {getServiceRegistry} from "../current-user/exec-service/plugins" import {getChainId} from "../utils" import {VERSION} from "../VERSION" import {makeDiscoveryServices} from "./utils" @@ -9,7 +8,7 @@ import {FCLContext} from "../context" export interface GetServicesParams { types: string[] - context: Pick + context: Pick } export interface DiscoveryRequestBody { @@ -66,8 +65,8 @@ export async function getServices({ features: { suggested: await context.config.get("discovery.features.suggested", []), }, - clientServices: await makeDiscoveryServices(), - supportedStrategies: getServiceRegistry().getStrategies(), + clientServices: await makeDiscoveryServices(context), + supportedStrategies: context.serviceRegistry.getStrategies(), userAgent: window?.navigator?.userAgent, network: await getChainId(), } as DiscoveryRequestBody), diff --git a/packages/fcl-core/src/discovery/services/authn.ts b/packages/fcl-core/src/discovery/services/authn.ts index 0f2724755..b28f88fef 100644 --- a/packages/fcl-core/src/discovery/services/authn.ts +++ b/packages/fcl-core/src/discovery/services/authn.ts @@ -54,7 +54,7 @@ const warn = (fact: boolean, msg: string): void => { } const fetchServicesFromDiscovery = async ( - context: Pick + context: Pick ): Promise => { try { const services = await getServices({ @@ -73,7 +73,9 @@ const fetchServicesFromDiscovery = async ( } } -function createHandlers(context: Pick) { +function createHandlers( + context: Pick +) { return { [INIT]: async (ctx: ActorContext) => { warn( @@ -110,8 +112,9 @@ function createHandlers(context: Pick) { } } -const spawnProviders = (context: Pick) => - spawn(createHandlers(context) as any, SERVICE_ACTOR_KEYS.AUTHN) +const spawnProviders = ( + context: Pick +) => spawn(createHandlers(context) as any, SERVICE_ACTOR_KEYS.AUTHN) /** * Discovery authn service for interacting with Flow compatible wallets. @@ -162,7 +165,9 @@ const spawnProviders = (context: Pick) => * 'discovery.authn.exclude': ['0x123456789abcdef01'], // Example of excluding a wallet by address * }); */ -function createAuthn(context: Pick): Authn { +function createAuthn( + context: Pick +): Authn { /** * @description Discovery methods for interacting with Authn. */ diff --git a/packages/fcl-core/src/discovery/utils.ts b/packages/fcl-core/src/discovery/utils.ts index 5e3abfb8f..abcb9f140 100644 --- a/packages/fcl-core/src/discovery/utils.ts +++ b/packages/fcl-core/src/discovery/utils.ts @@ -1,6 +1,4 @@ -import {config} from "@onflow/config" import {invariant} from "@onflow/util-invariant" -import {getServiceRegistry} from "../current-user/exec-service/plugins" import {Service} from "@onflow/typedefs" import {FCLContext} from "../context" @@ -25,11 +23,13 @@ export interface DiscoveryService extends Service { * console.log(`Service: ${service.provider?.name}, Type: ${service.type}`) * }) */ -export const makeDiscoveryServices = async (): Promise => { +export const makeDiscoveryServices = async ( + context: Pick +): Promise => { const extensionServices = ((window as any)?.fcl_extensions || []) as Service[] return [ ...extensionServices, - ...(getServiceRegistry().getServices() as Service[]), + ...(context.serviceRegistry.getServices() as Service[]), ] } diff --git a/packages/fcl-core/src/test-utils/mock-context.ts b/packages/fcl-core/src/test-utils/mock-context.ts index c06ea08d5..45131996a 100644 --- a/packages/fcl-core/src/test-utils/mock-context.ts +++ b/packages/fcl-core/src/test-utils/mock-context.ts @@ -3,6 +3,10 @@ import type {StorageProvider} from "../utils/storage" import type {CurrentUserServiceApi} from "../current-user" import * as sdk from "@onflow/sdk" import type {createSdkClient} from "@onflow/sdk" +import { + createPluginRegistry, + createServiceRegistry, +} from "../current-user/exec-service/plugins" /** * Creates a mock SDK client for testing @@ -162,6 +166,47 @@ export function createMockCurrentUser( } } +/** + * Creates a mock service registry for testing + */ +export function createMockServiceRegistry( + overrides: any = {} +): ReturnType { + const mockServices = new Set() + + return { + add: jest.fn().mockImplementation(servicePlugin => { + // Mock implementation that just stores the service + mockServices.add(servicePlugin) + }), + getServices: jest.fn().mockReturnValue([]), + getStrategy: jest.fn().mockReturnValue(null), + getStrategies: jest.fn().mockReturnValue([]), + ...overrides, + } +} + +/** + * Creates a mock plugin registry for testing + */ +export function createMockPluginRegistry( + overrides: any = {} +): ReturnType { + const mockPlugins = new Map() + + return { + add: jest.fn().mockImplementation(plugins => { + if (Array.isArray(plugins)) { + plugins.forEach(plugin => mockPlugins.set(plugin.name, plugin)) + } else { + mockPlugins.set(plugins.name, plugins) + } + }), + getPlugins: jest.fn().mockReturnValue(mockPlugins), + ...overrides, + } +} + /** * Creates a fully mocked FCL context for testing * @@ -208,17 +253,27 @@ export function createMockContext( currentUser?: Partial storage?: StorageProvider sdkOverrides?: Partial> + serviceRegistryOverrides?: any + pluginRegistryOverrides?: any } = {} ) { const storage = options.storage || createMockStorage() const config = createMockConfigService(options.configValues || {}) const currentUser = createMockCurrentUser(options.currentUser || {}) const sdk = createMockSdkClient(options.sdkOverrides || {}) + const serviceRegistry = createMockServiceRegistry( + options.serviceRegistryOverrides || {} + ) + const pluginRegistry = createMockPluginRegistry( + options.pluginRegistryOverrides || {} + ) return { storage, config, currentUser, sdk, + serviceRegistry, + pluginRegistry, } } From 0e1ca8ff0aefaacdf0a175cccda1a36d6b9307db Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 24 Jul 2025 11:01:50 -0700 Subject: [PATCH 2/6] remove exports --- packages/fcl-core/src/client.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/fcl-core/src/client.ts b/packages/fcl-core/src/client.ts index 5a78cee5a..15234486f 100644 --- a/packages/fcl-core/src/client.ts +++ b/packages/fcl-core/src/client.ts @@ -88,10 +88,6 @@ export function createFlowClientCore(params: FlowClientCoreConfig) { // Utility methods serialize: createSerialize(context), - // Plugin system (context-aware) - serviceRegistry: context.serviceRegistry, - pluginRegistry: context.pluginRegistry, - // Re-export the SDK methods ...context.sdk, } From 73a106350eed8921ce3c10f148939fda63ae9490 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 24 Jul 2025 11:37:13 -0700 Subject: [PATCH 3/6] fix build --- .../fcl-core/src/current-user/exec-service/index.ts | 10 ++++------ packages/fcl/src/client.ts | 2 ++ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/fcl-core/src/current-user/exec-service/index.ts b/packages/fcl-core/src/current-user/exec-service/index.ts index 1194311dc..ff23647ff 100644 --- a/packages/fcl-core/src/current-user/exec-service/index.ts +++ b/packages/fcl-core/src/current-user/exec-service/index.ts @@ -19,6 +19,7 @@ export interface ExecStrategyParams { customRpc?: string user?: CurrentUser opts?: Record + serviceRegistry: ReturnType } export interface ExecServiceParams { @@ -94,9 +95,8 @@ export const execStrategy = async ({ user, opts, serviceRegistry, -}: ExecStrategyParams & {serviceRegistry?: any}): Promise => { - const registry = serviceRegistry || getServiceRegistry() - const strategy = registry.getStrategy(service.method) as StrategyFunction +}: ExecStrategyParams): Promise => { + const strategy = serviceRegistry.getStrategy(service.method) as any return strategy({service, body, config, abortSignal, customRpc, opts, user}) } @@ -127,7 +127,6 @@ export async function execService( abortSignal = new AbortController().signal, execStrategy: _execStrategy, user, - serviceRegistry, }: ExecServiceParams ): Promise { // Notify the developer if WalletConnect is not enabled @@ -155,7 +154,7 @@ export async function execService( opts, user, abortSignal, - serviceRegistry, + serviceRegistry: context.serviceRegistry, }) if (res.status === "REDIRECT") { @@ -171,7 +170,6 @@ export async function execService( abortSignal, platform, user, - serviceRegistry, }) } else { return res diff --git a/packages/fcl/src/client.ts b/packages/fcl/src/client.ts index 4a4d6ed31..524df1776 100644 --- a/packages/fcl/src/client.ts +++ b/packages/fcl/src/client.ts @@ -5,6 +5,7 @@ import { } from "@onflow/fcl-core" import {LOCAL_STORAGE} from "./fcl" import {execStrategyHook} from "./discovery/exec-hook" +import {coreStrategies} from "./utils/web" const PLATFORM = "web" @@ -70,6 +71,7 @@ export function createFlowClient(params: FlowClientConfig) { appDetailDescription: params.appDetailDescription, appDetailUrl: params.appDetailUrl, serviceOpenIdScopes: params.serviceOpenIdScopes, + coreStrategies: coreStrategies, }) return { From 6cd578bcef564ac8d9c0c59e8bb50d079cbb772c Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 24 Jul 2025 11:44:21 -0700 Subject: [PATCH 4/6] fix walletconnect --- packages/fcl/src/client.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/fcl/src/client.ts b/packages/fcl/src/client.ts index 524df1776..61ffe2046 100644 --- a/packages/fcl/src/client.ts +++ b/packages/fcl/src/client.ts @@ -6,6 +6,7 @@ import { import {LOCAL_STORAGE} from "./fcl" import {execStrategyHook} from "./discovery/exec-hook" import {coreStrategies} from "./utils/web" +import {initLazy as initFclWcLazy} from "@onflow/fcl-wc" const PLATFORM = "web" @@ -49,6 +50,25 @@ export interface FlowClientConfig { } export function createFlowClient(params: FlowClientConfig) { + const strategies: Record = { + ...coreStrategies, + } + + if (params.walletconnectProjectId) { + const wc = initFclWcLazy({ + projectId: params.walletconnectProjectId, + metadata: { + name: params.appDetailTitle || document.title, + description: params.appDetailDescription || "", + url: params.appDetailUrl || window.location.origin, + icons: params.appDetailIcon ? [params.appDetailIcon] : [], + }, + disableNotifications: params.walletconnectDisableNotifications, + }) + const serviceStrategy = wc.FclWcServicePlugin.serviceStrategy + strategies[serviceStrategy.method] = serviceStrategy.exec + } + const fclCore = createFlowClientCore({ flowNetwork: params.flowNetwork, flowJson: params.flowJson, From a59a91f353f2762242998f06e85359e98d3e15cf Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 24 Jul 2025 12:52:55 -0700 Subject: [PATCH 5/6] stash --- packages/demo/src/components/flow-provider-wrapper.tsx | 1 + packages/fcl-core/src/current-user/exec-service/index.ts | 2 +- packages/fcl-core/src/current-user/index.ts | 2 +- packages/fcl/src/client.ts | 4 +--- 4 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/demo/src/components/flow-provider-wrapper.tsx b/packages/demo/src/components/flow-provider-wrapper.tsx index f6785548e..f001b497d 100644 --- a/packages/demo/src/components/flow-provider-wrapper.tsx +++ b/packages/demo/src/components/flow-provider-wrapper.tsx @@ -66,6 +66,7 @@ export default function FlowProviderWrapper({ appDetailUrl: "https://yourapp.com", appDetailDescription: "Your app description", computeLimit: 1000, + walletconnectProjectId: "9b70cfa398b2355a5eb9b1cf99f4a981", }} flowJson={flowJSON} colorMode={darkMode ? "dark" : "light"} diff --git a/packages/fcl-core/src/current-user/exec-service/index.ts b/packages/fcl-core/src/current-user/exec-service/index.ts index ff23647ff..70b4077c4 100644 --- a/packages/fcl-core/src/current-user/exec-service/index.ts +++ b/packages/fcl-core/src/current-user/exec-service/index.ts @@ -1,6 +1,6 @@ import {invariant} from "@onflow/util-invariant" import {log, LEVELS} from "@onflow/util-logger" -import {getServiceRegistry} from "./plugins" +import {type getServiceRegistry} from "./plugins" import {createGetChainId} from "../../utils" import {VERSION} from "../../VERSION" import {configLens} from "../../default-config" diff --git a/packages/fcl-core/src/current-user/index.ts b/packages/fcl-core/src/current-user/index.ts index 4d6f0c8e2..ced77884a 100644 --- a/packages/fcl-core/src/current-user/index.ts +++ b/packages/fcl-core/src/current-user/index.ts @@ -272,7 +272,7 @@ const makeConfig = async ( discoveryAuthnExclude, discoveryFeaturesSuggested, clientServices: await makeDiscoveryServices(context), - supportedStrategies: getServiceRegistry().getStrategies(), + supportedStrategies: context.serviceRegistry.getStrategies(), }, } } diff --git a/packages/fcl/src/client.ts b/packages/fcl/src/client.ts index 61ffe2046..65f839b36 100644 --- a/packages/fcl/src/client.ts +++ b/packages/fcl/src/client.ts @@ -84,14 +84,12 @@ export function createFlowClient(params: FlowClientConfig) { customResolver: params.customResolver, customDecoders: params.customDecoders, discoveryWallet: params.discoveryWallet, - walletconnectProjectId: params.walletconnectProjectId, - walletconnectDisableNotifications: params.walletconnectDisableNotifications, appDetailTitle: params.appDetailTitle, appDetailIcon: params.appDetailIcon, appDetailDescription: params.appDetailDescription, appDetailUrl: params.appDetailUrl, serviceOpenIdScopes: params.serviceOpenIdScopes, - coreStrategies: coreStrategies, + coreStrategies: strategies, }) return { From 6a097c6e986b1f36f99b4290be1f4450771c4081 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink Date: Thu, 24 Jul 2025 13:25:16 -0700 Subject: [PATCH 6/6] Fix getChainId bug --- .../src/components/flow-provider-wrapper.tsx | 2 +- packages/demo/vite.config.ts | 3 ++ packages/fcl-wc/src/service.ts | 4 ++ packages/fcl-wc/src/session.ts | 6 ++- packages/fcl/src/discovery/rpc/client.ts | 1 + .../discovery/rpc/handlers/request-wc-qr.ts | 39 ++++++++++++------- 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/demo/src/components/flow-provider-wrapper.tsx b/packages/demo/src/components/flow-provider-wrapper.tsx index f001b497d..c0292fc39 100644 --- a/packages/demo/src/components/flow-provider-wrapper.tsx +++ b/packages/demo/src/components/flow-provider-wrapper.tsx @@ -62,8 +62,8 @@ export default function FlowProviderWrapper({ config={{ ...flowConfig[flowNetwork], appDetailTitle: "Demo App", + appDetailUrl: window.location.origin, appDetailIcon: "https://avatars.githubusercontent.com/u/62387156?v=4", - appDetailUrl: "https://yourapp.com", appDetailDescription: "Your app description", computeLimit: 1000, walletconnectProjectId: "9b70cfa398b2355a5eb9b1cf99f4a981", diff --git a/packages/demo/vite.config.ts b/packages/demo/vite.config.ts index 11cb83c51..36012a3ea 100644 --- a/packages/demo/vite.config.ts +++ b/packages/demo/vite.config.ts @@ -4,4 +4,7 @@ import react from "@vitejs/plugin-react" // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + allowedHosts: true, + }, }) diff --git a/packages/fcl-wc/src/service.ts b/packages/fcl-wc/src/service.ts index b3950a150..e0c818936 100644 --- a/packages/fcl-wc/src/service.ts +++ b/packages/fcl-wc/src/service.ts @@ -117,6 +117,7 @@ const makeExec = ( wcRequestHook, pairingModalOverride, abortSignal, + network: opts.config.client.network, }).then(resolve, reject) }) } @@ -188,6 +189,7 @@ function connectWc( wcRequestHook, pairingModalOverride, abortSignal, + network, }: { service: any onClose: any @@ -198,6 +200,7 @@ function connectWc( wcRequestHook: any pairingModalOverride: any abortSignal?: AbortSignal + network: string }): Promise => { const projectId = provider.providerOpts.projectId invariant( @@ -212,6 +215,7 @@ function connectWc( const {uri, approval} = await createSessionProposal({ provider, existingPairing: pairing, + network, }) if (wcRequestHook && wcRequestHook instanceof Function) { diff --git a/packages/fcl-wc/src/session.ts b/packages/fcl-wc/src/session.ts index ef9b8f15e..8db31a78b 100644 --- a/packages/fcl-wc/src/session.ts +++ b/packages/fcl-wc/src/session.ts @@ -8,11 +8,13 @@ import {Service} from "@onflow/typedefs" export async function createSessionProposal({ provider, existingPairing, + network, }: { provider: InstanceType existingPairing?: PairingTypes.Struct + network?: string }) { - const network = await fclCore.getChainId() + const _network = network || (await fclCore.getChainId()) const requiredNamespaces = { flow: { @@ -22,7 +24,7 @@ export async function createSessionProposal({ FLOW_METHODS.FLOW_AUTHZ, FLOW_METHODS.FLOW_USER_SIGN, ], - chains: [`flow:${network}`], + chains: [`flow:${_network}`], events: ["chainChanged", "accountsChanged"], }, } diff --git a/packages/fcl/src/discovery/rpc/client.ts b/packages/fcl/src/discovery/rpc/client.ts index 17cb89d5c..ccf49c416 100644 --- a/packages/fcl/src/discovery/rpc/client.ts +++ b/packages/fcl/src/discovery/rpc/client.ts @@ -24,6 +24,7 @@ export function createDiscoveryRpcClient({ rpc.on( FclRequest.REQUEST_WALLETCONNECT_QRCODE, wcRequestHandlerFactory({ + network: opts.config.client.network, rpc, onExecResult, authnBody: body, diff --git a/packages/fcl/src/discovery/rpc/handlers/request-wc-qr.ts b/packages/fcl/src/discovery/rpc/handlers/request-wc-qr.ts index 5e97f119d..9944bc8d6 100644 --- a/packages/fcl/src/discovery/rpc/handlers/request-wc-qr.ts +++ b/packages/fcl/src/discovery/rpc/handlers/request-wc-qr.ts @@ -9,11 +9,13 @@ import {DiscoveryNotification, DiscoveryRpc} from "../requests" // RPC handler for handling WalletConnect QR code requests export const wcRequestHandlerFactory = ({ rpc, + network, onExecResult, authnBody, abortSignal, }: { rpc: DiscoveryRpc + network: string onExecResult: (result: any) => void authnBody: any abortSignal: AbortSignal @@ -24,25 +26,31 @@ export const wcRequestHandlerFactory = ({ }) return async ({}) => { - if (abortSignal.aborted) { - throw new Error("Handler has been terminated") - } + try { + if (abortSignal.aborted) { + throw new Error("Handler has been terminated") + } - const provider = await getProvider() + const provider = await getProvider() - // Execute WC bypass if session is approved - const {uri, approval} = await createSessionProposal({ - provider, - }) + // Execute WC bypass if session is approved + const {uri, approval} = await createSessionProposal({ + provider, + network, + }) - // Watch for QR code connection asynchronously - watchQr({ - uri, - approval, - onExecResult, - }) + // Watch for QR code connection asynchronously + watchQr({ + uri, + approval, + onExecResult, + }) - return {uri} + return {uri} + } catch (error: any) { + console.error("Error in WalletConnect QR request handler:", error) + throw error + } } } @@ -83,6 +91,7 @@ export function watchQrFactory({ }) onExecResult(result) } catch (e: any) { + console.error("Error during WC QR code connection:", e) rpc.notify(DiscoveryNotification.NOTIFY_QRCODE_ERROR, { uri, error: e?.message,