From b2f90264ee3ca16d6075b001dfce9a55d9977662 Mon Sep 17 00:00:00 2001 From: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> Date: Mon, 3 Feb 2025 13:08:15 -0800 Subject: [PATCH] Implement FCL Ethereum Provider `chainChanged` Event (#2096) --- .../fcl-ethereum-provider/src/constants.ts | 2 + .../src/create-provider.ts | 6 +- .../src/events/event-dispatcher.test.ts | 46 +++++- .../src/events/event-dispatcher.ts | 9 +- .../src/network/network-manager.test.ts | 135 ++++++++++++++++++ .../src/network/network-manager.ts | 88 ++++++++++++ .../src/rpc/rpc-processor.test.ts | 26 ++-- .../src/rpc/rpc-processor.ts | 15 +- 8 files changed, 303 insertions(+), 24 deletions(-) create mode 100644 packages/fcl-ethereum-provider/src/network/network-manager.test.ts create mode 100644 packages/fcl-ethereum-provider/src/network/network-manager.ts diff --git a/packages/fcl-ethereum-provider/src/constants.ts b/packages/fcl-ethereum-provider/src/constants.ts index 311b8e8d5..c9c05a4eb 100644 --- a/packages/fcl-ethereum-provider/src/constants.ts +++ b/packages/fcl-ethereum-provider/src/constants.ts @@ -51,3 +51,5 @@ export interface TransactionExecutedEvent { precompiledCalls: string[] stateUpdateChecksum: string } + +export const ACCESS_NODE_API_KEY = "accessNode.api" diff --git a/packages/fcl-ethereum-provider/src/create-provider.ts b/packages/fcl-ethereum-provider/src/create-provider.ts index a20325e68..73a7f9e34 100644 --- a/packages/fcl-ethereum-provider/src/create-provider.ts +++ b/packages/fcl-ethereum-provider/src/create-provider.ts @@ -7,6 +7,7 @@ import {EventDispatcher} from "./events/event-dispatcher" import {AccountManager} from "./accounts/account-manager" import {FLOW_CHAINS} from "./constants" import {Gateway} from "./gateway/gateway" +import {NetworkManager} from "./network/network-manager" /** * Create a new FCL Ethereum provider @@ -41,13 +42,14 @@ export function createProvider(config: { {} as {[chainId: number]: string} ) + const networkManager = new NetworkManager(config.config) const accountManager = new AccountManager(config.user) const gateway = new Gateway({ ...defaultRpcUrls, ...(config.rpcUrls || {}), }) - const rpcProcessor = new RpcProcessor(gateway, accountManager) - const eventProcessor = new EventDispatcher(accountManager) + const rpcProcessor = new RpcProcessor(gateway, accountManager, networkManager) + const eventProcessor = new EventDispatcher(accountManager, networkManager) const provider = new FclEthereumProvider(rpcProcessor, eventProcessor) return provider diff --git a/packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts b/packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts index b86472c5e..2bdf35b44 100644 --- a/packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts +++ b/packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts @@ -1,13 +1,19 @@ import {AccountManager} from "../accounts/account-manager" +import {NetworkManager} from "../network/network-manager" +import {BehaviorSubject, Subject} from "../util/observable" import {EventDispatcher} from "./event-dispatcher" jest.mock("../accounts/account-manager") +jest.mock("../network/network-manager") describe("event dispatcher", () => { test("unsubscribe should remove listener", () => { const accountManager: jest.Mocked = new (AccountManager as any)() + const networkManager: jest.Mocked = + new (NetworkManager as any)() + let subs: ((accounts: string[]) => void)[] = [] accountManager.subscribe.mockImplementation(cb => { subs.push(cb) @@ -17,7 +23,7 @@ describe("event dispatcher", () => { }) const listener = jest.fn() - const eventDispatcher = new EventDispatcher(accountManager) + const eventDispatcher = new EventDispatcher(accountManager, networkManager) eventDispatcher.on("accountsChanged", listener) expect(accountManager.subscribe).toHaveBeenCalled() @@ -43,6 +49,9 @@ describe("event dispatcher", () => { const accountManager: jest.Mocked = new (AccountManager as any)() + const networkManager: jest.Mocked = + new (NetworkManager as any)() + let mockMgrSubCb: (accounts: string[]) => void accountManager.subscribe.mockImplementation(cb => { mockMgrSubCb = cb @@ -50,7 +59,7 @@ describe("event dispatcher", () => { }) const listener = jest.fn() - const eventDispatcher = new EventDispatcher(accountManager) + const eventDispatcher = new EventDispatcher(accountManager, networkManager) eventDispatcher.on("accountsChanged", listener) expect(accountManager.subscribe).toHaveBeenCalled() @@ -69,6 +78,9 @@ describe("event dispatcher", () => { const accountManager: jest.Mocked = new (AccountManager as any)() + const networkManager: jest.Mocked = + new (NetworkManager as any)() + let mockMgrSubCb: (accounts: string[]) => void accountManager.subscribe.mockImplementation(cb => { mockMgrSubCb = cb @@ -76,7 +88,7 @@ describe("event dispatcher", () => { }) const listener = jest.fn() - const eventDispatcher = new EventDispatcher(accountManager) + const eventDispatcher = new EventDispatcher(accountManager, networkManager) eventDispatcher.on("accountsChanged", listener) expect(accountManager.subscribe).toHaveBeenCalled() @@ -92,4 +104,32 @@ describe("event dispatcher", () => { expect(listener).toHaveBeenNthCalledWith(1, ["0x1234"]) expect(listener).toHaveBeenNthCalledWith(2, ["0x5678"]) }) + + test("should emit chainChanged", () => { + const accountManager: jest.Mocked = + new (AccountManager as any)() + + const networkManager: jest.Mocked = + new (NetworkManager as any)() + + let mockSubject = new Subject() + networkManager.subscribe.mockImplementation(cb => { + return mockSubject.subscribe(cb) + }) + const listener = jest.fn() + + const eventDispatcher = new EventDispatcher(accountManager, networkManager) + eventDispatcher.on("chainChanged", listener) + + expect(networkManager.subscribe).toHaveBeenCalled() + expect(networkManager.subscribe).toHaveBeenCalledTimes(1) + expect(networkManager.subscribe).toHaveBeenCalledWith(expect.any(Function)) + + // Simulate network change from network manager + mockSubject.next(0x2eb) + + expect(listener).toHaveBeenCalled() + expect(listener).toHaveBeenCalledTimes(1) + expect(listener).toHaveBeenCalledWith("0x2eb") + }) }) diff --git a/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts b/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts index 18642daf8..f7ccbf85e 100644 --- a/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts +++ b/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts @@ -1,5 +1,6 @@ import {EventCallback, ProviderEvents} from "../types/provider" import {AccountManager} from "../accounts/account-manager" +import {NetworkManager} from "../network/network-manager" import {Observable, Subscription} from "../util/observable" export class EventDispatcher { @@ -13,7 +14,7 @@ export class EventDispatcher { > } - constructor(accountManager: AccountManager) { + constructor(accountManager: AccountManager, networkManager: NetworkManager) { this.$emitters = { accountsChanged: new Observable(subscriber => { return accountManager.subscribe(accounts => { @@ -21,8 +22,10 @@ export class EventDispatcher { }) }), chainChanged: new Observable(subscriber => { - subscriber.complete?.() - return () => {} + return networkManager.subscribe(chainId => { + if (!chainId) return + subscriber.next(`0x${chainId.toString(16)}`) + }) }), connect: new Observable(subscriber => { subscriber.complete?.() diff --git a/packages/fcl-ethereum-provider/src/network/network-manager.test.ts b/packages/fcl-ethereum-provider/src/network/network-manager.test.ts new file mode 100644 index 000000000..e0dbcf0a9 --- /dev/null +++ b/packages/fcl-ethereum-provider/src/network/network-manager.test.ts @@ -0,0 +1,135 @@ +import {mockConfig} from "../__mocks__/fcl" +import {NetworkManager} from "./network-manager" +import * as fcl from "@onflow/fcl" + +jest.mock("@onflow/fcl", () => { + return { + getChainId: jest.fn(), + } +}) + +describe("network manager", () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test("getChainId should return correct chain id mainnet", async () => { + jest.mocked(fcl.getChainId).mockResolvedValue("mainnet") + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + const manager = new NetworkManager(config.mock) + const chainId = await manager.getChainId() + + expect(chainId).toBe(747) + }) + + test("getChainId should return correct chain id testnet", async () => { + jest.mocked(fcl.getChainId).mockResolvedValue("testnet") + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + const manager = new NetworkManager(config.mock) + const chainId = await manager.getChainId() + + expect(chainId).toBe(646) + }) + + test("getChainId should throw error on unknown network", async () => { + jest.mocked(fcl.getChainId).mockResolvedValue("unknown") + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + + const manager = new NetworkManager(config.mock) + await expect(manager.getChainId()).rejects.toThrow("Unknown network") + }) + + test("getChainId should throw error on error", async () => { + jest.mocked(fcl.getChainId).mockRejectedValue(new Error("error")) + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + + const manager = new NetworkManager(config.mock) + await expect(manager.getChainId()).rejects.toThrow("error") + }) + + test("subscribe should return correct chain id", async () => { + jest.mocked(fcl.getChainId).mockResolvedValue("mainnet") + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + + const manager = new NetworkManager(config.mock) + const chainId = await new Promise(resolve => { + const unsub = manager.subscribe(id => { + if (id) { + resolve(id) + unsub() + } + }) + }) + + expect(chainId).toBe(747) + }) + + test("subscribe should update chain id", async () => { + jest.mocked(fcl.getChainId).mockResolvedValue("mainnet") + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + + const manager = new NetworkManager(config.mock) + + const chainIds: number[] = [] + + const unsub = manager.subscribe(id => { + if (id) { + chainIds.push(id) + } + }) + + await config.set({ + "accessNode.api": "https://example2.com", + }) + + await config.set({ + "accessNode.api": "https://example3.com", + }) + + unsub() + + expect(chainIds).toEqual([747, 747, 747]) + expect(fcl.getChainId).toHaveBeenCalledTimes(3) + }) + + test("should not query chain id multiple times for same access node", async () => { + jest.mocked(fcl.getChainId).mockResolvedValue("mainnet") + + const config = mockConfig() + await config.set({ + "accessNode.api": "https://example.com", + }) + + const manager = new NetworkManager(config.mock) + + await manager.getChainId() + await manager.getChainId() + await manager.getChainId() + + expect(fcl.getChainId).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/fcl-ethereum-provider/src/network/network-manager.ts b/packages/fcl-ethereum-provider/src/network/network-manager.ts new file mode 100644 index 000000000..1bd920210 --- /dev/null +++ b/packages/fcl-ethereum-provider/src/network/network-manager.ts @@ -0,0 +1,88 @@ +import {ACCESS_NODE_API_KEY, FLOW_CHAINS, FlowNetwork} from "../constants" +import { + BehaviorSubject, + concat, + distinctUntilChanged, + filter, + firstValueFrom, + from, + map, + of, + Subscription, + switchMap, +} from "../util/observable" +import * as fcl from "@onflow/fcl" + +type ChainIdStore = { + isLoading: boolean + chainId: number | null + error: unknown | null +} +export class NetworkManager { + private $chainIdStore = new BehaviorSubject({ + isLoading: true, + chainId: null, + error: null, + }) + + constructor(config: typeof fcl.config) { + // Map FCL config to behavior subject + const $config = new BehaviorSubject | null>(null) + config.subscribe((cfg, err) => { + if (err) { + $config.error(err) + } else { + $config.next(cfg) + } + }) + + // Bind $network to chainId + $config + .pipe( + map(cfg => cfg?.[ACCESS_NODE_API_KEY]), + distinctUntilChanged(), + switchMap(accessNode => + concat( + of({isLoading: true} as ChainIdStore), + from( + (async () => { + try { + const flowNetwork = (await fcl.getChainId({ + node: accessNode, + })) as FlowNetwork + if (!(flowNetwork in FLOW_CHAINS)) { + throw new Error("Unknown network") + } + const {eip155ChainId: chainId} = FLOW_CHAINS[flowNetwork] + return {isLoading: false, chainId, error: null} + } catch (error) { + return {isLoading: false, chainId: null, error} + } + })() + ) + ) + ) + ) + .subscribe(this.$chainIdStore) + } + + public subscribe(callback: (chainId: number | null) => void): Subscription { + return this.$chainIdStore + .pipe( + filter(x => !x.isLoading && !x.error), + map(x => x.chainId) + ) + .subscribe(callback) + } + + public async getChainId(): Promise { + const {chainId, error} = await firstValueFrom( + this.$chainIdStore.pipe(filter(x => !x.isLoading)) + ) + + if (error) { + throw error + } + return chainId + } +} diff --git a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.test.ts b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.test.ts index ea18f7e6d..eb3749c2e 100644 --- a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.test.ts +++ b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.test.ts @@ -1,23 +1,27 @@ import {AccountManager} from "../accounts/account-manager" import {Gateway} from "../gateway/gateway" +import {NetworkManager} from "../network/network-manager" import {RpcProcessor} from "./rpc-processor" -import * as fcl from "@onflow/fcl" jest.mock("../gateway/gateway") jest.mock("../accounts/account-manager") -jest.mock("@onflow/fcl", () => ({ - getChainId: jest.fn(), -})) +jest.mock("../network/network-manager") describe("rpc processor", () => { test("fallback to gateway mainnet", async () => { const gateway: jest.Mocked = new (Gateway as any)() const accountManager: jest.Mocked = new (AccountManager as any)() - const rpcProcessor = new RpcProcessor(gateway, accountManager) + const networkManager: jest.Mocked = + new (NetworkManager as any)() + const rpcProcessor = new RpcProcessor( + gateway, + accountManager, + networkManager + ) jest.mocked(gateway).request.mockResolvedValue("0x0") - jest.mocked(fcl.getChainId).mockResolvedValue("mainnet") + networkManager.getChainId.mockResolvedValue(747) const response = await rpcProcessor.handleRequest({ method: "eth_blockNumber", @@ -38,10 +42,16 @@ describe("rpc processor", () => { const gateway: jest.Mocked = new (Gateway as any)() const accountManager: jest.Mocked = new (AccountManager as any)() - const rpcProcessor = new RpcProcessor(gateway, accountManager) + const networkManager: jest.Mocked = + new (NetworkManager as any)() + const rpcProcessor = new RpcProcessor( + gateway, + accountManager, + networkManager + ) jest.mocked(gateway).request.mockResolvedValue("0x0") - jest.mocked(fcl.getChainId).mockResolvedValue("testnet") + networkManager.getChainId.mockResolvedValue(646) const response = await rpcProcessor.handleRequest({ method: "eth_blockNumber", diff --git a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts index b6fa22db1..f86bf7212 100644 --- a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts +++ b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts @@ -2,24 +2,23 @@ import {ProviderRequest} from "../types/provider" import {ethAccounts, ethRequestAccounts} from "./handlers/eth-accounts" import {Gateway} from "../gateway/gateway" import {AccountManager} from "../accounts/account-manager" -import * as fcl from "@onflow/fcl" -import {FLOW_CHAINS, FlowNetwork} from "../constants" import {ethSendTransaction} from "./handlers/eth-send-transaction" +import {NetworkManager} from "../network/network-manager" import {personalSign} from "./handlers/personal-sign" import {PersonalSignParams} from "../types/eth" export class RpcProcessor { constructor( private gateway: Gateway, - private accountManager: AccountManager + private accountManager: AccountManager, + private networkManager: NetworkManager ) {} async handleRequest({method, params}: ProviderRequest): Promise { - const flowNetwork = await fcl.getChainId() - if (!(flowNetwork in FLOW_CHAINS)) { - throw new Error(`Unsupported chainId ${flowNetwork}`) + const chainId = await this.networkManager.getChainId() + if (!chainId) { + throw new Error("No active chain") } - const {eip155ChainId} = FLOW_CHAINS[flowNetwork as FlowNetwork] switch (method) { case "eth_accounts": @@ -35,7 +34,7 @@ export class RpcProcessor { ) default: return await this.gateway.request({ - chainId: eip155ChainId, + chainId, method, params, })