Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement FCL Ethereum Provider chainChanged Event #2096

Merged
merged 3 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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")
})
})
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you supposed to throw errors in observables vs do something like $config.next({ error: err })? Do you want it to terminate?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case errors are fatal, so we should propogate this through the observable chain

} 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