Skip to content

Commit

Permalink
Implement FCL Ethereum Provider chainChanged Event (#2096)
Browse files Browse the repository at this point in the history
  • Loading branch information
jribbink authored Feb 3, 2025
1 parent 167277e commit b2f9026
Show file tree
Hide file tree
Showing 8 changed files with 303 additions and 24 deletions.
2 changes: 2 additions & 0 deletions packages/fcl-ethereum-provider/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,5 @@ export interface TransactionExecutedEvent {
precompiledCalls: string[]
stateUpdateChecksum: string
}

export const ACCESS_NODE_API_KEY = "accessNode.api"
6 changes: 4 additions & 2 deletions packages/fcl-ethereum-provider/src/create-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
46 changes: 43 additions & 3 deletions packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts
Original file line number Diff line number Diff line change
@@ -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<AccountManager> =
new (AccountManager as any)()

const networkManager: jest.Mocked<NetworkManager> =
new (NetworkManager as any)()

let subs: ((accounts: string[]) => void)[] = []
accountManager.subscribe.mockImplementation(cb => {
subs.push(cb)
Expand All @@ -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()
Expand All @@ -43,14 +49,17 @@ describe("event dispatcher", () => {
const accountManager: jest.Mocked<AccountManager> =
new (AccountManager as any)()

const networkManager: jest.Mocked<NetworkManager> =
new (NetworkManager as any)()

let mockMgrSubCb: (accounts: string[]) => void
accountManager.subscribe.mockImplementation(cb => {
mockMgrSubCb = cb
return () => {}
})
const listener = jest.fn()

const eventDispatcher = new EventDispatcher(accountManager)
const eventDispatcher = new EventDispatcher(accountManager, networkManager)
eventDispatcher.on("accountsChanged", listener)

expect(accountManager.subscribe).toHaveBeenCalled()
Expand All @@ -69,14 +78,17 @@ describe("event dispatcher", () => {
const accountManager: jest.Mocked<AccountManager> =
new (AccountManager as any)()

const networkManager: jest.Mocked<NetworkManager> =
new (NetworkManager as any)()

let mockMgrSubCb: (accounts: string[]) => void
accountManager.subscribe.mockImplementation(cb => {
mockMgrSubCb = cb
return () => {}
})
const listener = jest.fn()

const eventDispatcher = new EventDispatcher(accountManager)
const eventDispatcher = new EventDispatcher(accountManager, networkManager)
eventDispatcher.on("accountsChanged", listener)

expect(accountManager.subscribe).toHaveBeenCalled()
Expand All @@ -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<AccountManager> =
new (AccountManager as any)()

const networkManager: jest.Mocked<NetworkManager> =
new (NetworkManager as any)()

let mockSubject = new Subject<number | null>()
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")
})
})
9 changes: 6 additions & 3 deletions packages/fcl-ethereum-provider/src/events/event-dispatcher.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -13,16 +14,18 @@ export class EventDispatcher {
>
}

constructor(accountManager: AccountManager) {
constructor(accountManager: AccountManager, networkManager: NetworkManager) {
this.$emitters = {
accountsChanged: new Observable(subscriber => {
return accountManager.subscribe(accounts => {
subscriber.next(accounts)
})
}),
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?.()
Expand Down
135 changes: 135 additions & 0 deletions packages/fcl-ethereum-provider/src/network/network-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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<number>(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)
})
})
88 changes: 88 additions & 0 deletions packages/fcl-ethereum-provider/src/network/network-manager.ts
Original file line number Diff line number Diff line change
@@ -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<ChainIdStore>({
isLoading: true,
chainId: null,
error: null,
})

constructor(config: typeof fcl.config) {
// Map FCL config to behavior subject
const $config = new BehaviorSubject<Record<string, unknown> | 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<number | null> {
const {chainId, error} = await firstValueFrom(
this.$chainIdStore.pipe(filter(x => !x.isLoading))
)

if (error) {
throw error
}
return chainId
}
}
Loading

0 comments on commit b2f9026

Please sign in to comment.