From d11dd4ecd82b3f6ead56e38d576a3e849029df3e Mon Sep 17 00:00:00 2001 From: Jordan Ribbink <17958158+jribbink@users.noreply.github.com> Date: Wed, 5 Feb 2025 15:50:25 -0800 Subject: [PATCH] Add EVM contract address to scripts (#2121) --- .../src/accounts/account-manager.test.ts | 110 ++++++++++-------- .../src/accounts/account-manager.ts | 29 +++-- .../fcl-ethereum-provider/src/util/eth.ts | 27 +++++ 3 files changed, 111 insertions(+), 55 deletions(-) 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 c0492a14c..af911ef9c 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts @@ -3,6 +3,8 @@ import {mockUser} from "../__mocks__/fcl" import * as fcl from "@onflow/fcl" import * as rlp from "@onflow/rlp" import {CurrentUser} from "@onflow/typedefs" +import {ChainIdStore, NetworkManager} from "../network/network-manager" +import {BehaviorSubject, Subject} from "../util/observable" jest.mock("@onflow/fcl", () => { const fcl = jest.requireActual("@onflow/fcl") @@ -24,35 +26,41 @@ const mockFcl = jest.mocked(fcl) const mockQuery = jest.mocked(fcl.query) describe("AccountManager", () => { + let networkManager: jest.Mocked + let userMock: ReturnType + beforeEach(() => { jest.clearAllMocks() + + const chainId$ = new BehaviorSubject(747) + networkManager = { + $chainId: chainId$, + getChainId: () => chainId$.getValue(), + } as any as jest.Mocked + userMock = mockUser() }) it("should initialize with null COA address", async () => { - const user = mockUser() - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) expect(await accountManager.getCOAAddress()).toBeNull() expect(await accountManager.getAccounts()).toEqual([]) }) it("should reset state when the user is not logged in", async () => { - const user = mockUser() - - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) expect(await accountManager.getCOAAddress()).toBeNull() expect(await accountManager.getAccounts()).toEqual([]) }) it("should fetch and update COA address when user logs in", async () => { - const user = mockUser() mockQuery.mockResolvedValue("0x123") - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) expect(await accountManager.getCOAAddress()).toBe(null) - user.set!({addr: "0x1"} as CurrentUser) + userMock.set!({addr: "0x1"} as CurrentUser) expect(await accountManager.getCOAAddress()).toBe("0x123") expect(await accountManager.getAccounts()).toEqual(["0x123"]) @@ -63,26 +71,24 @@ describe("AccountManager", () => { }) it("should not update COA address if user has not changed", async () => { - const user = mockUser() mockQuery.mockResolvedValue("0x123") - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) - user.set!({addr: "0x1"} as CurrentUser) + userMock.set!({addr: "0x1"} as CurrentUser) await new Promise(setImmediate) expect(await accountManager.getCOAAddress()).toBe("0x123") expect(fcl.query).toHaveBeenCalledTimes(1) - user.set!({addr: "0x1"} as CurrentUser) + userMock.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 () => { - const user = mockUser() mockQuery.mockResolvedValue("0x123") mockQuery @@ -93,39 +99,36 @@ describe("AccountManager", () => { // 2nd fetch: immediate .mockResolvedValueOnce("0x456") - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) - await user.set!({addr: "0x1"} as CurrentUser) - await user.set!({addr: "0x2"} as CurrentUser) + await userMock.set!({addr: "0x1"} as CurrentUser) + await userMock.set!({addr: "0x2"} as CurrentUser) // The second fetch (for address 0x2) is the latest, so "0x456" expect(await accountManager.getCOAAddress()).toBe("0x456") }) it("should throw if COA address fetch fails", async () => { - const user = mockUser() mockQuery.mockRejectedValueOnce(new Error("Fetch failed")) - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) - await user.set!({addr: "0x1"} as CurrentUser) + await userMock.set!({addr: "0x1"} as CurrentUser) await expect(accountManager.getCOAAddress()).rejects.toThrow("Fetch failed") }) it("should handle user changes correctly", async () => { - const user = mockUser() - mockQuery .mockResolvedValueOnce("0x123") // for user 0x1 .mockResolvedValueOnce("0x456") // for user 0x2 - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) - await user.set({addr: "0x1"} as CurrentUser) + await userMock.set({addr: "0x1"} as CurrentUser) expect(await accountManager.getCOAAddress()).toBe("0x123") - await user.set({addr: "0x2"} as CurrentUser) + await userMock.set({addr: "0x2"} as CurrentUser) await new Promise(setImmediate) expect(await accountManager.getCOAAddress()).toBe("0x456") @@ -134,14 +137,12 @@ describe("AccountManager", () => { it("should call the callback with updated accounts in subscribe", async () => { mockQuery.mockResolvedValue("0x123") - const user = mockUser() - - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) const callback = jest.fn() accountManager.subscribe(callback) - user.set({addr: "0x1"} as CurrentUser) + userMock.set({addr: "0x1"} as CurrentUser) await new Promise(setImmediate) @@ -150,11 +151,10 @@ describe("AccountManager", () => { it("should reset accounts in subscribe if user is not authenticated", async () => { mockQuery.mockResolvedValue("0x123") - const user = mockUser() const callback = jest.fn() - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) accountManager.subscribe(callback) @@ -166,11 +166,11 @@ describe("AccountManager", () => { it("should call the callback when COA address is updated", async () => { const callback = jest.fn() - const user = mockUser({addr: "0x1"} as CurrentUser) - mockQuery.mockResolvedValueOnce("0x123") - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(userMock.mock, networkManager) + + userMock.set({addr: "0x1"} as CurrentUser) accountManager.subscribe(callback) @@ -180,23 +180,31 @@ describe("AccountManager", () => { }) it("should return an empty array when COA address is null", async () => { - const {mock: user} = mockUser() - const accountManager = new AccountManager(user) + const accountManager = new AccountManager(userMock.mock, networkManager) expect(await accountManager.getAccounts()).toEqual([]) }) it("should return COA address array when available", async () => { mockQuery.mockResolvedValueOnce("0x123") - const {mock: user} = mockUser({addr: "0x1"} as CurrentUser) + userMock.set({addr: "0x1"} as CurrentUser) - const accountManager = new AccountManager(user) + const accountManager = new AccountManager(userMock.mock, networkManager) expect(await accountManager.getAccounts()).toEqual(["0x123"]) }) }) describe("send transaction", () => { + let networkManager: jest.Mocked + let $mockChainId: BehaviorSubject + beforeEach(() => { + $mockChainId = new BehaviorSubject(747) + networkManager = { + $chainId: $mockChainId, + getChainId: () => $mockChainId.getValue(), + } as any as jest.Mocked + jest.clearAllMocks() }) @@ -219,7 +227,7 @@ describe("send transaction", () => { jest.mocked(fcl.query).mockResolvedValue("0x1234") const user = mockUser({addr: "0x4444"} as CurrentUser).mock - const accountManager = new AccountManager(user) + const accountManager = new AccountManager(user, networkManager) const tx = { to: "0x1234", @@ -248,6 +256,9 @@ describe("send transaction", () => { }) test("send transaction testnet", async () => { + // Set chainId to testnet + $mockChainId.next(646) + const mockTxResult = { onceExecuted: jest.fn().mockResolvedValue({ events: [ @@ -266,7 +277,7 @@ describe("send transaction", () => { jest.mocked(fcl.query).mockResolvedValue("0x1234") const user = mockUser({addr: "0x4444"} as CurrentUser) - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(user.mock, networkManager) const tx = { to: "0x1234", @@ -306,7 +317,7 @@ describe("send transaction", () => { jest.mocked(fcl.query).mockResolvedValue("0x1234") const user = mockUser({addr: "0x4444"} as CurrentUser) - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(user.mock, networkManager) const tx = { to: "0x1234", @@ -315,7 +326,7 @@ describe("send transaction", () => { data: "0x1234", nonce: "0", gas: "0", - chainId: "646", + chainId: "747", } await expect(accountManager.sendTransaction(tx)).rejects.toThrow( @@ -326,7 +337,7 @@ describe("send transaction", () => { test("throws error if from address does not match user address", async () => { jest.mocked(fcl.query).mockResolvedValue("0x1234") const user = mockUser({addr: "0x4444"} as CurrentUser) - const accountManager = new AccountManager(user.mock) + const accountManager = new AccountManager(user.mock, networkManager) const tx = { to: "0x1234", @@ -339,7 +350,7 @@ describe("send transaction", () => { } await expect(accountManager.sendTransaction(tx)).rejects.toThrow( - `From address does not match authenticated user address.\nUser: 0x1234\nFrom: 0x4567` + `Chain ID does not match the current network. Expected: 747, Received: 646` ) expect(fcl.mutate).not.toHaveBeenCalled() @@ -347,6 +358,7 @@ describe("send transaction", () => { }) describe("signMessage", () => { + let networkManager: jest.Mocked let accountManager: AccountManager let user: ReturnType["mock"] let updateUser: ReturnType["set"] @@ -355,7 +367,12 @@ describe("signMessage", () => { jest.clearAllMocks() ;({mock: user, set: updateUser} = mockUser({addr: "0x123"} as CurrentUser)) jest.mocked(fcl.query).mockResolvedValue("0xCOA1") - accountManager = new AccountManager(user) + const $mockChainId = new BehaviorSubject(747) + networkManager = { + $chainId: $mockChainId, + getChainId: () => $mockChainId.getValue(), + } as any as jest.Mocked + accountManager = new AccountManager(user, networkManager) }) it("should throw an error if the COA address is not available", async () => { @@ -400,10 +417,7 @@ describe("signMessage", () => { }) it("should throw an error if signUserMessage returns an empty array", async () => { - accountManager["coaAddress"] = "0xCOA1" - - user.signUserMessage = jest.fn().mockResolvedValue([]) - + user.signUserMessage.mockResolvedValue([]) await expect( accountManager.signMessage("Test message", "0xCOA1") ).rejects.toThrow("Failed to sign message") diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts index 4ff515efb..804d00ca7 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts @@ -24,6 +24,8 @@ import { switchMap, } from "../util/observable" import {EthSignatureResponse} from "../types/eth" +import {NetworkManager} from "../network/network-manager" +import {formatChainId, getContractAddress} from "../util/eth" export class AccountManager { private $addressStore = new BehaviorSubject<{ @@ -36,7 +38,10 @@ export class AccountManager { error: null, }) - constructor(private user: typeof fcl.currentUser) { + constructor( + private user: typeof fcl.currentUser, + private networkManager: NetworkManager + ) { // Create an observable from the user const $user = new Observable(subscriber => { return this.user.subscribe((currentUser: CurrentUser, error?: Error) => { @@ -91,8 +96,13 @@ export class AccountManager { } private async fetchCOAFromFlowAddress(flowAddr: string): Promise { + const chainId = await this.networkManager.getChainId() + if (!chainId) { + throw new Error("No active chain") + } + const cadenceScript = ` - import EVM + import EVM from ${getContractAddress(ContractType.EVM, chainId)} access(all) fun main(address: Address): String? { @@ -156,17 +166,22 @@ export class AccountManager { chainId: string }) { // Find the Flow network based on the chain ID + const parsedChainId = parseInt(chainId) const flowNetwork = Object.entries(FLOW_CHAINS).find( - ([, chain]) => chain.eip155ChainId === parseInt(chainId) + ([, chain]) => chain.eip155ChainId === parsedChainId )?.[0] as FlowNetwork | undefined if (!flowNetwork) { throw new Error("Flow network not found for chain ID") } - const evmContractAddress = fcl.withPrefix( - FLOW_CONTRACTS[ContractType.EVM][flowNetwork] - ) + // Validate the chain ID + const currentChainId = await this.networkManager.getChainId() + if (parsedChainId !== currentChainId) { + throw new Error( + `Chain ID does not match the current network. Expected: ${currentChainId}, Received: ${parsedChainId}` + ) + } // Check if the from address matches the authenticated COA address const expectedCOAAddress = await this.getCOAAddress() @@ -177,7 +192,7 @@ export class AccountManager { } const txId = await fcl.mutate({ - cadence: `import EVM from ${evmContractAddress} + cadence: `import EVM from ${getContractAddress(ContractType.EVM, parsedChainId)} /// Executes the calldata from the signer's COA /// diff --git a/packages/fcl-ethereum-provider/src/util/eth.ts b/packages/fcl-ethereum-provider/src/util/eth.ts index 8cc15491e..98ad6499b 100644 --- a/packages/fcl-ethereum-provider/src/util/eth.ts +++ b/packages/fcl-ethereum-provider/src/util/eth.ts @@ -1,5 +1,32 @@ +import { + ContractType, + FLOW_CHAINS, + FLOW_CONTRACTS, + FlowNetwork, +} from "../constants" +import * as fcl from "@onflow/fcl" + export function formatChainId(chainId: string | number): `0x${string}` { const numericChainId = typeof chainId === "string" ? parseInt(chainId) : chainId return `0x${numericChainId.toString(16)}` } + +export function getContractAddress( + contractType: ContractType, + chainId: number +) { + // Find the Flow network based on the chain ID + const flowNetwork = Object.entries(FLOW_CHAINS).find( + ([, chain]) => chain.eip155ChainId === chainId + )?.[0] as FlowNetwork | undefined + + if (!flowNetwork) { + throw new Error("Flow network not found for chain ID") + } + + const evmContractAddress = fcl.withPrefix( + FLOW_CONTRACTS[contractType][flowNetwork] + ) + return evmContractAddress +}