diff --git a/packages/fcl-ethereum-provider/src/__mocks__/fcl.ts b/packages/fcl-ethereum-provider/src/__mocks__/fcl.ts index ad62d70b7..ac83de332 100644 --- a/packages/fcl-ethereum-provider/src/__mocks__/fcl.ts +++ b/packages/fcl-ethereum-provider/src/__mocks__/fcl.ts @@ -1,20 +1,90 @@ import * as fcl from "@onflow/fcl" +import {CurrentUser} from "@onflow/typedefs" -export function mockUser(): jest.Mocked { +export function mockUser(initialValue?: CurrentUser | null) { + if (!initialValue) { + initialValue = { + loggedIn: false, + } as CurrentUser + } + let value: CurrentUser = initialValue + let subscribers: ((cfg: CurrentUser, err: Error | null) => void)[] = [] const currentUser = { authenticate: jest.fn(), unauthenticate: jest.fn(), authorization: jest.fn(), signUserMessage: jest.fn(), - subscribe: jest.fn(), + subscribe: jest.fn().mockImplementation(cb => { + cb(value) + subscribers.push(cb) + return () => { + subscribers = subscribers.filter(s => s !== cb) + } + }), snapshot: jest.fn(), resolveArgument: jest.fn(), } - return Object.assign( + const mock: jest.Mocked = Object.assign( () => { return {...currentUser} }, {...currentUser} ) + + return { + mock, + set: async (cfg: CurrentUser) => { + value = cfg + subscribers.forEach(s => s(cfg, null)) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +export function mockConfig( + { + initialValue, + }: { + initialValue: Record | null + } = {initialValue: null} +) { + let value = initialValue + let subscribers: ((cfg: Record, err: Error | null) => void)[] = + [] + + const config = { + put: jest.fn(), + get: jest.fn(), + all: jest.fn(), + first: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + where: jest.fn(), + subscribe: jest.fn().mockImplementation(cb => { + cb(value, null) + subscribers.push(cb) + return () => { + subscribers = subscribers.filter(s => s !== cb) + } + }), + overload: jest.fn(), + load: jest.fn(), + } + + const cfg: jest.Mocked = Object.assign( + () => { + return {...config} + }, + {...config} + ) + + return { + mock: cfg, + set: async (cfg: Record) => { + value = cfg + subscribers.forEach(s => s(cfg, null)) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } } diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts index ab21c7eaa..42b87a035 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts @@ -24,85 +24,66 @@ const mockFcl = jest.mocked(fcl) const mockQuery = jest.mocked(fcl.query) describe("AccountManager", () => { - let accountManager: AccountManager - let user: jest.Mocked - beforeEach(() => { - user = mockUser() - accountManager = new AccountManager(user) - }) - - afterEach(() => { jest.clearAllMocks() }) - it("should initialize with null COA address", () => { - expect(accountManager.getCOAAddress()).toBeNull() - expect(accountManager.getAccounts()).toEqual([]) + it("should initialize with null COA address", async () => { + const user = mockUser() + const accountManager = new AccountManager(user.mock) + expect(await accountManager.getCOAAddress()).toBeNull() + expect(await accountManager.getAccounts()).toEqual([]) }) it("should reset state when the user is not logged in", async () => { - user.snapshot.mockResolvedValueOnce({addr: undefined} as CurrentUser) + const user = mockUser() - await accountManager.updateCOAAddress() + const accountManager = new AccountManager(user.mock) - expect(accountManager.getCOAAddress()).toBeNull() - expect(accountManager.getAccounts()).toEqual([]) + expect(await accountManager.getCOAAddress()).toBeNull() + expect(await accountManager.getAccounts()).toEqual([]) }) it("should fetch and update COA address when user logs in", async () => { - user.snapshot.mockResolvedValue({addr: "0x1"} as CurrentUser) + const user = mockUser() mockQuery.mockResolvedValue("0x123") - await accountManager.updateCOAAddress() + const accountManager = new AccountManager(user.mock) + + expect(await accountManager.getCOAAddress()).toBe(null) - expect(accountManager.getCOAAddress()).toBe("0x123") - expect(accountManager.getAccounts()).toEqual(["0x123"]) + user.set!({addr: "0x1"} as CurrentUser) + + expect(await accountManager.getCOAAddress()).toBe("0x123") + expect(await accountManager.getAccounts()).toEqual(["0x123"]) expect(fcl.query).toHaveBeenCalledWith({ cadence: expect.any(String), args: expect.any(Function), }) }) - it("should not update COA address if user has not changed and force is false", async () => { - user.snapshot.mockResolvedValue({addr: "0x1"} as CurrentUser) + it("should not update COA address if user has not changed", async () => { + const user = mockUser() mockQuery.mockResolvedValue("0x123") - user.subscribe.mockImplementation(fn => { - fn({addr: "0x1"}) - return () => {} - }) - await accountManager.updateCOAAddress() - expect(accountManager.getCOAAddress()).toBe("0x123") - expect(fcl.query).toHaveBeenCalledTimes(1) + const accountManager = new AccountManager(user.mock) - // Re-run without changing the address - await accountManager.updateCOAAddress() - expect(accountManager.getCOAAddress()).toBe("0x123") - expect(fcl.query).toHaveBeenCalledTimes(1) // Should not have fetched again - }) + user.set!({addr: "0x1"} as CurrentUser) - it("should force update COA address even if user has not changed", async () => { - user.snapshot.mockResolvedValue({addr: "0x1"} as CurrentUser) - mockQuery.mockResolvedValue("0x123") - user.subscribe.mockImplementation(fn => { - fn({addr: "0x1"}) - return () => {} - }) + await new Promise(setImmediate) - await accountManager.updateCOAAddress() + expect(await accountManager.getCOAAddress()).toBe("0x123") expect(fcl.query).toHaveBeenCalledTimes(1) - // Force update - await accountManager.updateCOAAddress(true) - expect(fcl.query).toHaveBeenCalledTimes(2) + user.set!({addr: "0x1"} as CurrentUser) + + expect(await accountManager.getCOAAddress()).toBe("0x123") + expect(fcl.query).toHaveBeenCalledTimes(1) // Should not have fetched again }) it("should not update COA address if fetch is outdated when user changes", async () => { - // Simulates the user address changing from 0x1 to 0x2 - user.snapshot - .mockResolvedValueOnce({addr: "0x1"} as CurrentUser) // for 1st call - .mockResolvedValueOnce({addr: "0x2"} as CurrentUser) // for 2nd call + const user = mockUser() + mockQuery.mockResolvedValue("0x123") mockQuery // 1st fetch: delayed @@ -112,88 +93,85 @@ describe("AccountManager", () => { // 2nd fetch: immediate .mockResolvedValueOnce("0x456") - const updatePromise1 = accountManager.updateCOAAddress() - const updatePromise2 = accountManager.updateCOAAddress() - await Promise.all([updatePromise1, updatePromise2]) + const accountManager = new AccountManager(user.mock) + + await user.set!({addr: "0x1"} as CurrentUser) + await user.set!({addr: "0x2"} as CurrentUser) // The second fetch (for address 0x2) is the latest, so "0x456" - expect(accountManager.getCOAAddress()).toBe("0x456") + expect(await accountManager.getCOAAddress()).toBe("0x456") }) - it("should clear COA address if fetch fails and is the latest", async () => { - user.snapshot.mockResolvedValueOnce({addr: "0x1"} as CurrentUser) + it("should throw if COA address fetch fails", async () => { + const user = mockUser() mockQuery.mockRejectedValueOnce(new Error("Fetch failed")) - await expect(accountManager.updateCOAAddress()).rejects.toThrow( - "Fetch failed" - ) + const accountManager = new AccountManager(user.mock) - expect(await accountManager.getCOAAddress()).toBeNull() + await user.set!({addr: "0x1"} as CurrentUser) + + await expect(accountManager.getCOAAddress()).rejects.toThrow("Fetch failed") }) it("should handle user changes correctly", async () => { - user.snapshot - .mockResolvedValueOnce({addr: "0x1"} as CurrentUser) - .mockResolvedValueOnce({addr: "0x2"} as CurrentUser) + const user = mockUser() mockQuery .mockResolvedValueOnce("0x123") // for user 0x1 .mockResolvedValueOnce("0x456") // for user 0x2 - await accountManager.updateCOAAddress() - expect(accountManager.getCOAAddress()).toBe("0x123") + const accountManager = new AccountManager(user.mock) + + await user.set({addr: "0x1"} as CurrentUser) + expect(await accountManager.getCOAAddress()).toBe("0x123") - await accountManager.updateCOAAddress() - expect(accountManager.getCOAAddress()).toBe("0x456") + await user.set({addr: "0x2"} as CurrentUser) + + await new Promise(setImmediate) + expect(await accountManager.getCOAAddress()).toBe("0x456") }) it("should call the callback with updated accounts in subscribe", async () => { - user.snapshot.mockResolvedValue({addr: "0x1"} as CurrentUser) mockQuery.mockResolvedValue("0x123") - const callback = jest.fn() - user.subscribe.mockImplementation(fn => { - fn({addr: "0x1"}) - return () => {} - }) + const user = mockUser() - mockQuery.mockResolvedValueOnce("0x123") + const accountManager = new AccountManager(user.mock) + const callback = jest.fn() accountManager.subscribe(callback) + user.set({addr: "0x1"} as CurrentUser) + await new Promise(setImmediate) expect(callback).toHaveBeenCalledWith(["0x123"]) }) - it("should reset accounts in subscribe if user is not authenticated", () => { + it("should reset accounts in subscribe if user is not authenticated", async () => { mockQuery.mockResolvedValue("0x123") - user.snapshot.mockResolvedValue({addr: undefined} as CurrentUser) + const user = mockUser() const callback = jest.fn() - user.subscribe.mockImplementation(fn => { - fn({addr: null}) - return () => {} - }) + const accountManager = new AccountManager(user.mock) accountManager.subscribe(callback) + await new Promise(setImmediate) + expect(callback).toHaveBeenCalledWith([]) }) it("should call the callback when COA address is updated", async () => { const callback = jest.fn() - user.snapshot.mockResolvedValueOnce({addr: "0x1"} as CurrentUser) - - user.subscribe.mockImplementation(fn => { - fn({addr: "0x1"} as CurrentUser) - return () => {} - }) + const user = mockUser({addr: "0x1"} as CurrentUser) mockQuery.mockResolvedValueOnce("0x123") + const accountManager = new AccountManager(user.mock) + accountManager.subscribe(callback) await new Promise(setImmediate) @@ -201,16 +179,19 @@ describe("AccountManager", () => { expect(callback).toHaveBeenCalledWith(["0x123"]) }) - it("should return an empty array when COA address is null", () => { - expect(accountManager.getAccounts()).toEqual([]) + it("should return an empty array when COA address is null", async () => { + const {mock: user} = mockUser() + const accountManager = new AccountManager(user) + expect(await accountManager.getAccounts()).toEqual([]) }) it("should return COA address array when available", async () => { - user.snapshot.mockResolvedValueOnce({addr: "0x1"} as CurrentUser) mockQuery.mockResolvedValueOnce("0x123") + const {mock: user} = mockUser({addr: "0x1"} as CurrentUser) + + const accountManager = new AccountManager(user) - await accountManager.updateCOAAddress() - expect(accountManager.getAccounts()).toEqual(["0x123"]) + expect(await accountManager.getAccounts()).toEqual(["0x123"]) }) }) @@ -220,7 +201,7 @@ describe("send transaction", () => { }) test("send transaction mainnet", async () => { - const user = mockUser() + const user = mockUser().mock const accountManager = new AccountManager(user) const mockTxResult = { @@ -267,7 +248,7 @@ describe("send transaction", () => { test("send transaction testnet", async () => { const user = mockUser() - const accountManager = new AccountManager(user) + const accountManager = new AccountManager(user.mock) const mockTxResult = { onceExecuted: jest.fn().mockResolvedValue({ @@ -302,7 +283,7 @@ describe("send transaction", () => { expect(mockFcl.mutate.mock.calls[0][0]).toMatchObject({ cadence: expect.any(String), args: expect.any(Function), - authz: user, + authz: user.mock, limit: 9999, }) @@ -313,7 +294,7 @@ describe("send transaction", () => { test("throws error if no executed event not found", async () => { const user = mockUser() - const accountManager = new AccountManager(user) + const accountManager = new AccountManager(user.mock) const mockTxResult = { onceExecuted: jest.fn().mockResolvedValue({ @@ -342,18 +323,18 @@ describe("send transaction", () => { describe("signMessage", () => { let accountManager: AccountManager - let user: jest.Mocked + let user: ReturnType["mock"] + let updateUser: ReturnType["set"] beforeEach(() => { - user = mockUser() - accountManager = new AccountManager(user) - }) - - afterEach(() => { jest.clearAllMocks() + ;({mock: user, set: updateUser} = mockUser({addr: "0x123"} as CurrentUser)) + jest.mocked(fcl.query).mockResolvedValue("0xCOA1") + accountManager = new AccountManager(user) }) it("should throw an error if the COA address is not available", async () => { + await updateUser({addr: undefined} as CurrentUser) accountManager["coaAddress"] = null await expect( @@ -364,21 +345,18 @@ describe("signMessage", () => { }) it("should throw an error if the signer address does not match the COA address", async () => { - accountManager["coaAddress"] = "0xCOA1" - await expect( accountManager.signMessage("Test message", "0xDIFFERENT") ).rejects.toThrow("Signer address does not match authenticated COA address") }) it("should successfully sign a message and return an RLP-encoded proof", async () => { - accountManager["coaAddress"] = "0xCOA1" const mockSignature = "0xabcdef1234567890" const mockRlpEncoded = "f86a808683abcdef682f73746f726167652f65766d" - user.signUserMessage = jest - .fn() - .mockResolvedValue([{addr: "0xCOA1", keyId: 0, signature: mockSignature}]) + user.signUserMessage.mockResolvedValue([ + {addr: "0xCOA1", keyId: 0, signature: mockSignature} as any, + ]) jest.mocked(rlp.encode).mockReturnValue(Buffer.from(mockRlpEncoded, "hex")) @@ -407,8 +385,6 @@ describe("signMessage", () => { }) it("should throw an error if signUserMessage fails", async () => { - accountManager["coaAddress"] = "0xCOA1" - user.signUserMessage = jest .fn() .mockRejectedValue(new Error("Signing failed")) diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts index a1fcbaf76..154e1dea1 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts @@ -10,22 +10,76 @@ import { FlowNetwork, } from "../constants" import {TransactionExecutedEvent} from "../types/events" +import { + BehaviorSubject, + concat, + distinctUntilChanged, + filter, + firstValueFrom, + from, + map, + Observable, + of, + Subscription, + switchMap, +} from "../util/observable" import {EthSignatureResponse} from "../types/eth" export class AccountManager { - private user: typeof fcl.currentUser - - // For race-condition checks: - private currentFetchId = 0 - - // Track the last Flow address we fetched for - private lastFlowAddr: string | null = null - - // The COA address (or null if none/not fetched) - private coaAddress: string | null = null + private $addressStore = new BehaviorSubject<{ + isLoading: boolean + address: string | null + error: Error | null + }>({ + isLoading: true, + address: null, + error: null, + }) + + constructor(private user: typeof fcl.currentUser) { + // Create an observable from the user + const $user = new Observable(subscriber => { + return this.user.subscribe((currentUser: CurrentUser, error?: Error) => { + if (error) { + subscriber.error?.(error) + } else { + subscriber.next(currentUser) + } + }) as Subscription + }) - constructor(user: typeof fcl.currentUser) { - this.user = user + // Bind the address store to the user observable + $user + .pipe( + map(snapshot => snapshot.addr || null), + distinctUntilChanged(), + switchMap(addr => + concat( + of({isLoading: true} as { + isLoading: boolean + address: string | null + error: Error | null + }), + from( + (async () => { + try { + if (!addr) { + return {isLoading: false, address: null, error: null} + } + return { + isLoading: false, + address: await this.fetchCOAFromFlowAddress(addr), + error: null, + } + } catch (error: any) { + return {isLoading: false, address: null, error} + } + })() + ) + ) + ) + ) + .subscribe(this.$addressStore) } private async fetchCOAFromFlowAddress(flowAddr: string): Promise { @@ -55,60 +109,27 @@ export class AccountManager { return response as string } - public async updateCOAAddress(force = false): Promise { - const snapshot = await this.user.snapshot() - const currentFlowAddr = snapshot?.addr - - // If user not logged in, reset everything - if (!currentFlowAddr) { - this.lastFlowAddr = null - this.coaAddress = null - return - } - - const userChanged = this.lastFlowAddr !== currentFlowAddr - if (force || userChanged) { - this.lastFlowAddr = currentFlowAddr - const fetchId = ++this.currentFetchId - - try { - const address = await this.fetchCOAFromFlowAddress(currentFlowAddr) - // Only update if this fetch is still the latest - if (fetchId === this.currentFetchId) { - this.coaAddress = address - } - } catch (error) { - // If this fetch is the latest, clear - if (fetchId === this.currentFetchId) { - this.coaAddress = null - } - throw error - } + public async getCOAAddress(): Promise { + const {address, error} = await firstValueFrom( + this.$addressStore.pipe(filter(x => !x.isLoading)) + ) + if (error) { + throw error } + return address } - public getCOAAddress(): string | null { - return this.coaAddress + public async getAccounts(): Promise { + const coaAddress = await this.getCOAAddress() + return coaAddress ? [coaAddress] : [] } - public getAccounts(): string[] { - return this.coaAddress ? [this.coaAddress] : [] - } - - public subscribe(callback: (accounts: string[]) => void): () => void { - const unsubscribe = this.user.subscribe(async (snapshot: CurrentUser) => { - if (!snapshot.addr) { - this.lastFlowAddr = null - this.coaAddress = null - callback(this.getAccounts()) - return - } - - await this.updateCOAAddress() - callback(this.getAccounts()) - }) as () => void - - return unsubscribe + public subscribe(callback: (accounts: string[]) => void): Subscription { + return this.$addressStore + .pipe(filter(x => !x.isLoading && !x.error)) + .subscribe(({address}) => { + callback(address ? [address] : []) + }) } async sendTransaction({ @@ -201,13 +222,14 @@ export class AccountManager { message: string, from: string ): Promise { - if (!this.coaAddress) { + const coaAddress = await this.getCOAAddress() + if (!coaAddress) { throw new Error( "COA address is not available. User might not be authenticated." ) } - if (from.toLowerCase() !== this.coaAddress.toLowerCase()) { + if (from.toLowerCase() !== coaAddress.toLowerCase()) { throw new Error("Signer address does not match authenticated COA address") } diff --git a/packages/fcl-ethereum-provider/src/create-provider.ts b/packages/fcl-ethereum-provider/src/create-provider.ts index 505ddbde9..a20325e68 100644 --- a/packages/fcl-ethereum-provider/src/create-provider.ts +++ b/packages/fcl-ethereum-provider/src/create-provider.ts @@ -29,6 +29,7 @@ import {Gateway} from "./gateway/gateway" */ export function createProvider(config: { user: typeof fcl.currentUser + config: typeof fcl.config service?: Service rpcUrls?: {[chainId: string]: number} }): Eip1193Provider { @@ -48,5 +49,6 @@ export function createProvider(config: { const rpcProcessor = new RpcProcessor(gateway, accountManager) const eventProcessor = new EventDispatcher(accountManager) 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 41ad945e8..b86472c5e 100644 --- a/packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts +++ b/packages/fcl-ethereum-provider/src/events/event-dispatcher.test.ts @@ -8,9 +8,12 @@ describe("event dispatcher", () => { const accountManager: jest.Mocked = new (AccountManager as any)() - let mockSubscribeCallback: (accounts: string[]) => void + let subs: ((accounts: string[]) => void)[] = [] accountManager.subscribe.mockImplementation(cb => { - mockSubscribeCallback = cb + subs.push(cb) + return () => { + subs = subs.filter(sub => sub !== cb) + } }) const listener = jest.fn() @@ -22,7 +25,7 @@ describe("event dispatcher", () => { expect(accountManager.subscribe).toHaveBeenCalledWith(expect.any(Function)) // Simulate account change from account manager - mockSubscribeCallback!(["0x1234"]) + subs.forEach(sub => sub(["0x1234"])) expect(listener).toHaveBeenCalled() expect(listener).toHaveBeenCalledTimes(1) @@ -31,7 +34,7 @@ describe("event dispatcher", () => { eventDispatcher.off("accountsChanged", listener) // Simulate account change from account manager - mockSubscribeCallback!(["0x5678"]) + subs.forEach(sub => sub(["0x5678"])) expect(listener).toHaveBeenCalledTimes(1) }) @@ -40,9 +43,10 @@ describe("event dispatcher", () => { const accountManager: jest.Mocked = new (AccountManager as any)() - let mockSubscribeCallback: (accounts: string[]) => void + let mockMgrSubCb: (accounts: string[]) => void accountManager.subscribe.mockImplementation(cb => { - mockSubscribeCallback = cb + mockMgrSubCb = cb + return () => {} }) const listener = jest.fn() @@ -54,7 +58,7 @@ describe("event dispatcher", () => { expect(accountManager.subscribe).toHaveBeenCalledWith(expect.any(Function)) // Simulate account change from account manager - mockSubscribeCallback!(["0x1234"]) + mockMgrSubCb!(["0x1234"]) expect(listener).toHaveBeenCalled() expect(listener).toHaveBeenCalledTimes(1) @@ -65,9 +69,10 @@ describe("event dispatcher", () => { const accountManager: jest.Mocked = new (AccountManager as any)() - let mockSubscribeCallback: (accounts: string[]) => void + let mockMgrSubCb: (accounts: string[]) => void accountManager.subscribe.mockImplementation(cb => { - mockSubscribeCallback = cb + mockMgrSubCb = cb + return () => {} }) const listener = jest.fn() @@ -79,8 +84,8 @@ describe("event dispatcher", () => { expect(accountManager.subscribe).toHaveBeenCalledWith(expect.any(Function)) // Simulate account change from account manager - mockSubscribeCallback!(["0x1234"]) - mockSubscribeCallback!(["0x5678"]) + mockMgrSubCb!(["0x1234"]) + mockMgrSubCb!(["0x5678"]) expect(listener).toHaveBeenCalled() expect(listener).toHaveBeenCalledTimes(2) diff --git a/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts b/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts index 019b21e4f..18642daf8 100644 --- a/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts +++ b/packages/fcl-ethereum-provider/src/events/event-dispatcher.ts @@ -1,14 +1,45 @@ -import {AccountManager} from "../accounts/account-manager" import {EventCallback, ProviderEvents} from "../types/provider" -import EventEmitter from "events" +import {AccountManager} from "../accounts/account-manager" +import {Observable, Subscription} from "../util/observable" export class EventDispatcher { - private eventEmitter = new EventEmitter() + private $emitters: { + [E in keyof ProviderEvents]: Observable + } + private subscriptions: { + [E in keyof ProviderEvents]: Map< + EventCallback, + Subscription + > + } constructor(accountManager: AccountManager) { - accountManager.subscribe(accounts => { - this.emit("accountsChanged", accounts) - }) + this.$emitters = { + accountsChanged: new Observable(subscriber => { + return accountManager.subscribe(accounts => { + subscriber.next(accounts) + }) + }), + chainChanged: new Observable(subscriber => { + subscriber.complete?.() + return () => {} + }), + connect: new Observable(subscriber => { + subscriber.complete?.() + return () => {} + }), + disconnect: new Observable(subscriber => { + subscriber.complete?.() + return () => {} + }), + } + + this.subscriptions = { + accountsChanged: new Map(), + chainChanged: new Map(), + connect: new Map(), + disconnect: new Map(), + } } // Listen to events @@ -16,7 +47,8 @@ export class EventDispatcher { event: E, listener: EventCallback ): void { - this.eventEmitter.on(event, listener) + const unsub = this.$emitters[event].subscribe(listener) + this.subscriptions[event].set(listener, unsub) } // Remove event listeners @@ -24,14 +56,7 @@ export class EventDispatcher { event: E, listener: EventCallback ): void { - this.eventEmitter.off(event, listener) - } - - // Emit events (to be called internally) - private emit( - event: E, - data: ProviderEvents[E] - ) { - this.eventEmitter.emit(event, data) + this.subscriptions[event].get(listener)?.() + this.subscriptions[event].delete(listener) } } diff --git a/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.test.ts b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.test.ts index 2df9449e1..181916eef 100644 --- a/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.test.ts +++ b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.test.ts @@ -25,18 +25,18 @@ describe("ethAccounts handler", () => { }) it("should return accounts from the AccountManager", async () => { - accountManagerMock.getAccounts.mockReturnValue(["0x1234...", "0x5678..."]) + accountManagerMock.getAccounts.mockResolvedValue(["0x1234...", "0x5678..."]) - const accounts = ethAccounts(accountManagerMock) + const accounts = await ethAccounts(accountManagerMock) expect(accounts).toEqual(["0x1234...", "0x5678..."]) expect(accountManagerMock.getAccounts).toHaveBeenCalled() }) it("should return an empty array if no accounts are available", async () => { - accountManagerMock.getAccounts.mockReturnValue([]) + accountManagerMock.getAccounts.mockResolvedValue([]) - const accounts = ethAccounts(accountManagerMock) + const accounts = await ethAccounts(accountManagerMock) expect(accounts).toEqual([]) expect(accountManagerMock.getAccounts).toHaveBeenCalled() @@ -60,23 +60,21 @@ describe("ethRequestAccounts handler", () => { }) it("should call authenticate, updateCOAAddress, and return the manager's accounts", async () => { - accountManagerMock.getAccounts.mockReturnValue(["0x1234..."]) + accountManagerMock.getAccounts.mockResolvedValue(["0x1234..."]) const accounts = await ethRequestAccounts(accountManagerMock) expect(userMock.authenticate).toHaveBeenCalled() - expect(accountManagerMock.updateCOAAddress).toHaveBeenCalled() expect(accountManagerMock.getAccounts).toHaveBeenCalled() expect(accounts).toEqual(["0x1234..."]) }) it("should handle empty accounts scenario", async () => { - accountManagerMock.getAccounts.mockReturnValue([]) + accountManagerMock.getAccounts.mockResolvedValue([]) const accounts = await ethRequestAccounts(accountManagerMock) expect(userMock.authenticate).toHaveBeenCalled() - expect(accountManagerMock.updateCOAAddress).toHaveBeenCalled() expect(accountManagerMock.getAccounts).toHaveBeenCalled() expect(accounts).toEqual([]) }) diff --git a/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts index 6aa1e5696..9ed4b2a9f 100644 --- a/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts +++ b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts @@ -1,14 +1,14 @@ import * as fcl from "@onflow/fcl" import {AccountManager} from "../../accounts/account-manager" -export function ethAccounts(accountManager: AccountManager): string[] { - return accountManager.getAccounts() +export async function ethAccounts( + accountManager: AccountManager +): Promise { + return await accountManager.getAccounts() } export async function ethRequestAccounts(accountManager: AccountManager) { await fcl.currentUser().authenticate() - await accountManager.updateCOAAddress() - - return accountManager.getAccounts() + return await accountManager.getAccounts() } diff --git a/packages/fcl-ethereum-provider/src/util/observable.ts b/packages/fcl-ethereum-provider/src/util/observable.ts new file mode 100644 index 000000000..f34421b9d --- /dev/null +++ b/packages/fcl-ethereum-provider/src/util/observable.ts @@ -0,0 +1,309 @@ +/******************************* + * Core Observable/Types + *******************************/ + +export class Observable { + private _subscribe: (subscriber: Observer) => Subscription + + constructor(subscribe: (subscriber: Observer) => Subscription) { + this._subscribe = subscribe + } + + subscribe(observerOrNext: ObserverOrNext): Subscription { + const observer = normalizeObserver(observerOrNext) + return this._subscribe(observer) + } + + /** + * Pipe overloads — remove error type parameter + */ + pipe(op1: (source: Observable) => Observable): Observable + pipe( + op1: (source: Observable) => Observable, + op2: (source: Observable) => Observable + ): Observable + pipe( + op1: (source: Observable) => Observable, + op2: (source: Observable) => Observable, + op3: (source: Observable) => Observable + ): Observable + pipe( + op1: (source: Observable) => Observable, + op2: (source: Observable) => Observable, + op3: (source: Observable) => Observable, + op4: (source: Observable) => Observable + ): Observable + pipe( + op1: (source: Observable) => Observable, + op2: (source: Observable) => Observable, + op3: (source: Observable) => Observable, + op4: (source: Observable) => Observable, + op5: (source: Observable) => Observable + ): Observable + pipe( + op1: (source: Observable) => Observable, + op2: (source: Observable) => Observable, + op3: (source: Observable) => Observable, + op4: (source: Observable) => Observable, + op5: (source: Observable) => Observable, + op6: (source: Observable) => Observable + ): Observable + + pipe( + ...operators: Array<(input: Observable) => Observable> + ): Observable { + return operators.reduce( + (prev, operator) => operator(prev), + this as Observable + ) + } +} + +export type Subscription = () => void + +export type Observer = { + next: (value: T) => void + complete?: () => void + error?: (error: any) => void // In RxJS, `error` is usually typed as `any` +} + +// A type for either an Observer or a next callback +export type ObserverOrNext = Observer | ((value: T) => void) + +/******************************* + * Subjects + *******************************/ + +export class Subject extends Observable { + private subscribers: Observer[] = [] + + constructor() { + super(subscriber => { + this.subscribers.push(subscriber) + return () => { + this.subscribers = this.subscribers.filter(s => s !== subscriber) + } + }) + } + + next(value: T) { + this.subscribers.forEach(subscriber => subscriber.next(value)) + } + + error(error: any) { + this.subscribers.forEach(subscriber => subscriber.error?.(error)) + } + + complete() { + this.subscribers.forEach(subscriber => subscriber.complete?.()) + this.subscribers = [] + } +} + +export class BehaviorSubject extends Subject { + private value: T + + constructor(initialValue: T) { + super() + this.value = initialValue + } + + next(value: T) { + this.value = value + super.next(value) + } + + getValue() { + return this.value + } + + subscribe(observerOrNext: ObserverOrNext): Subscription { + const observer = normalizeObserver(observerOrNext) + // Emit the current value immediately + observer.next(this.value) + return super.subscribe(observer) + } +} + +/******************************* + * Operators + *******************************/ + +/** switchMap */ +export function switchMap( + project: (value: T) => Observable +): (source: Observable) => Observable { + return (source: Observable) => { + return new Observable(subscriber => { + let activeSubscription: Subscription | null = null + + const subscription = source.subscribe({ + next: value => { + if (activeSubscription) { + activeSubscription() + } + const innerObservable = project(value) + activeSubscription = innerObservable.subscribe({ + next: subscriber.next.bind(subscriber), + error: subscriber.error?.bind(subscriber), + complete: () => { + activeSubscription = null + }, + }) + }, + error: subscriber.error?.bind(subscriber), + complete: subscriber.complete?.bind(subscriber), + }) + + return () => { + if (activeSubscription) { + activeSubscription() + } + subscription() + } + }) + } +} + +/** map */ +export function map( + project: (value: T) => R +): (source: Observable) => Observable { + return (source: Observable) => { + return new Observable(subscriber => { + return source.subscribe({ + next: value => subscriber.next(project(value)), + error: subscriber.error?.bind(subscriber), + complete: subscriber.complete?.bind(subscriber), + }) + }) + } +} + +/** from (promise) */ +export function from(promise: Promise): Observable { + return new Observable(subscriber => { + let isCancelled = false + + promise + .then(value => { + if (!isCancelled) { + subscriber.next(value) + subscriber.complete?.() + } + }) + .catch(error => { + if (!isCancelled) { + subscriber.error?.(error) + } + }) + + return () => { + isCancelled = true + } + }) +} + +/** firstValueFrom */ +export async function firstValueFrom(source: Observable): Promise { + return await new Promise((resolve, reject) => { + const unsub = source.subscribe({ + next: value => { + resolve(value) + // wait until the next tick for unsub to be defined + setTimeout(() => unsub(), 0) + }, + error: reject, + complete: () => { + reject(new Error("Observable completed without emitting a value")) + }, + }) + }) +} + +/** distinctUntilChanged */ +export function distinctUntilChanged(): ( + source: Observable +) => Observable { + return source => { + return new Observable(subscriber => { + let lastValue: T | undefined + return source.subscribe({ + next: value => { + if (value !== lastValue) { + lastValue = value + subscriber.next(value) + } + }, + error: subscriber.error?.bind(subscriber), + complete: subscriber.complete?.bind(subscriber), + }) + }) + } +} + +/** concat */ +export function concat(...sources: Observable[]): Observable { + return new Observable(subscriber => { + let activeSubscription: Subscription | null = null + + function subscribeNext() { + if (sources.length === 0) { + subscriber.complete?.() + return + } + + const source = sources.shift()! + activeSubscription = source.subscribe({ + next: subscriber.next.bind(subscriber), + error: subscriber.error?.bind(subscriber), + complete: () => { + activeSubscription = null + subscribeNext() + }, + }) + } + + subscribeNext() + + return () => { + activeSubscription?.() + } + }) +} + +/** filter */ +export function filter( + predicate: (value: T) => boolean +): (source: Observable) => Observable { + return source => { + return new Observable(subscriber => { + return source.subscribe({ + next: value => { + if (predicate(value)) { + subscriber.next(value) + } + }, + error: subscriber.error?.bind(subscriber), + complete: subscriber.complete?.bind(subscriber), + }) + }) + } +} + +/** of */ +export function of(value: T): Observable { + return new Observable(subscriber => { + subscriber.next(value) + subscriber.complete?.() + return () => {} + }) +} + +/******************************* + * Internal utility + *******************************/ + +function normalizeObserver(observer: ObserverOrNext): Observer { + return typeof observer === "function" ? {next: observer} : observer +}