From e37469b2f1d86d6ff2e09391cca72edf6f3f879e Mon Sep 17 00:00:00 2001 From: Chase Fleming Date: Thu, 6 Feb 2025 13:32:12 -0800 Subject: [PATCH] Create COA on request accounts (#2120) * Create COA on request accounts * Add tests * Fix test * Remove * Update test * Refactor * Add events * Fix tests * Run prettier * Fix * Move tx * Move cadence --------- Co-authored-by: Chase Fleming <1666730+chasefleming@users.noreply.github.com> --- .../src/accounts/account-manager.test.ts | 60 ++++++-- .../src/accounts/account-manager.ts | 143 +++++++++++------- packages/fcl-ethereum-provider/src/cadence.ts | 62 ++++++++ .../fcl-ethereum-provider/src/constants.ts | 5 + .../src/create-provider.ts | 2 +- .../src/rpc/handlers/eth-accounts.test.ts | 9 +- .../src/rpc/handlers/eth-accounts.ts | 7 +- .../src/rpc/rpc-processor.ts | 2 +- 8 files changed, 215 insertions(+), 75 deletions(-) create mode 100644 packages/fcl-ethereum-provider/src/cadence.ts 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 af911ef9c..a15147d8e 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.test.ts @@ -3,8 +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" +import {NetworkManager} from "../network/network-manager" +import {BehaviorSubject} from "../util/observable" jest.mock("@onflow/fcl", () => { const fcl = jest.requireActual("@onflow/fcl") @@ -30,7 +30,7 @@ describe("AccountManager", () => { let userMock: ReturnType beforeEach(() => { - jest.clearAllMocks() + jest.resetAllMocks() const chainId$ = new BehaviorSubject(747) networkManager = { @@ -118,6 +118,51 @@ describe("AccountManager", () => { await expect(accountManager.getCOAAddress()).rejects.toThrow("Fetch failed") }) + it("getAndCreateAccounts should get a COA address if it already exists", async () => { + mockQuery.mockResolvedValue("0x123") + + const accountManager = new AccountManager(userMock.mock, networkManager) + + // Trigger the state update + await userMock.set!({addr: "0x1"} as CurrentUser) + + // Call getAndCreateAccounts. Since the COA already exists, it should just return it. + const accounts = await accountManager.getAndCreateAccounts(646) + + expect(accounts).toEqual(["0x123"]) + // Should not have created a new COA + expect(fcl.mutate).not.toHaveBeenCalled() + }) + + it("getAndCreateAccounts should create a COA if it does not exist", async () => { + const mockTxResult = { + onceExecuted: jest.fn().mockResolvedValue({ + events: [ + { + type: "A.e467b9dd11fa00df.EVM.CadenceOwnedAccountCreated", + data: { + address: "0x123", + }, + }, + ], + }), + } as any as jest.Mocked> + + jest.mocked(fcl.tx).mockReturnValue(mockTxResult) + jest.mocked(fcl.mutate).mockResolvedValue("1111") + + // For the subscription, simulate that initially no COA is found, then after creation the query returns "0x123" + mockQuery.mockResolvedValueOnce(null).mockResolvedValueOnce("0x123") + + const accountManager = new AccountManager(userMock.mock, networkManager) + + await userMock.set!({addr: "0x1"} as CurrentUser) + + const accounts = await accountManager.getAndCreateAccounts(747) + expect(accounts).toEqual(["0x123"]) + expect(fcl.mutate).toHaveBeenCalled() + }) + it("should handle user changes correctly", async () => { mockQuery .mockResolvedValueOnce("0x123") // for user 0x1 @@ -130,7 +175,6 @@ describe("AccountManager", () => { await userMock.set({addr: "0x2"} as CurrentUser) - await new Promise(setImmediate) expect(await accountManager.getCOAAddress()).toBe("0x456") }) @@ -142,9 +186,7 @@ describe("AccountManager", () => { const callback = jest.fn() accountManager.subscribe(callback) - userMock.set({addr: "0x1"} as CurrentUser) - - await new Promise(setImmediate) + await userMock.set({addr: "0x1"} as CurrentUser) expect(callback).toHaveBeenCalledWith(["0x123"]) }) @@ -205,7 +247,7 @@ describe("send transaction", () => { getChainId: () => $mockChainId.getValue(), } as any as jest.Mocked - jest.clearAllMocks() + jest.resetAllMocks() }) test("send transaction mainnet", async () => { @@ -364,7 +406,7 @@ describe("signMessage", () => { let updateUser: ReturnType["set"] beforeEach(() => { - jest.clearAllMocks() + jest.resetAllMocks() ;({mock: user, set: updateUser} = mockUser({addr: "0x123"} as CurrentUser)) jest.mocked(fcl.query).mockResolvedValue("0xCOA1") const $mockChainId = new BehaviorSubject(747) diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts index 804d00ca7..07aa81c2f 100644 --- a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts +++ b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts @@ -26,6 +26,7 @@ import { import {EthSignatureResponse} from "../types/eth" import {NetworkManager} from "../network/network-manager" import {formatChainId, getContractAddress} from "../util/eth" +import {createCOATx, getCOAScript, sendTransactionTx} from "../cadence" export class AccountManager { private $addressStore = new BehaviorSubject<{ @@ -95,36 +96,32 @@ export class AccountManager { await this.user.unauthenticate() } + private async waitForTxResult( + txId: string, + eventType: string, + errorMsg: string = `${eventType} event not found` + ): Promise { + const txResult = await fcl.tx(txId).onceExecuted() + + const event = txResult.events.find(e => e.type === eventType) + if (!event) { + throw new Error(errorMsg) + } + return event + } + private async fetchCOAFromFlowAddress(flowAddr: string): Promise { const chainId = await this.networkManager.getChainId() if (!chainId) { throw new Error("No active chain") } - const cadenceScript = ` - import EVM from ${getContractAddress(ContractType.EVM, chainId)} - - access(all) - fun main(address: Address): String? { - if let coa = getAuthAccount(address) - .storage - .borrow<&EVM.CadenceOwnedAccount>(from: /storage/evm) { - return coa.address().toString() - } - return nil - } - ` - const response = await fcl.query({ - cadence: cadenceScript, + return await fcl.query({ + cadence: getCOAScript(chainId), args: (arg: typeof fcl.arg, t: typeof fcl.t) => [ arg(flowAddr, t.Address), ], }) - - if (!response) { - throw new Error("COA account not found for the authenticated user") - } - return response as string } public async getCOAAddress(): Promise { @@ -142,6 +139,68 @@ export class AccountManager { return coaAddress ? [coaAddress] : [] } + /** + * Get the COA address and create it if it doesn't exist + */ + public async getAndCreateAccounts(chainId: number): Promise { + const accounts = await this.getAccounts() + + if (accounts.length === 0) { + const coaAddress = await this.createCOA(chainId) + return [coaAddress] + } + + if (accounts.length === 0) { + throw new Error("COA address is still missing after creation.") + } + + return accounts + } + + public async createCOA(chainId: number): Promise { + // 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") + } + + // Validate the chain ID + const currentChainId = await this.networkManager.getChainId() + if (chainId !== currentChainId) { + throw new Error( + `Chain ID does not match the current network. Expected: ${currentChainId}, Received: ${chainId}` + ) + } + + const txId = await fcl.mutate({ + cadence: createCOATx(chainId), + limit: 9999, + authz: this.user, + }) + + const event = await this.waitForTxResult( + txId, + EVENT_IDENTIFIERS[EventType.CADENCE_OWNED_ACCOUNT_CREATED][flowNetwork], + "Failed to create COA: COACreated event not found" + ) + + const coaAddress = event.data.address + if (!coaAddress) { + throw new Error("COA created event did not include an address") + } + + this.$addressStore.next({ + isLoading: false, + address: coaAddress, + error: null, + }) + + return coaAddress + } + public subscribe(callback: (accounts: string[]) => void): Subscription { return this.$addressStore .pipe(filter(x => !x.isLoading && !x.error)) @@ -192,33 +251,7 @@ export class AccountManager { } const txId = await fcl.mutate({ - cadence: `import EVM from ${getContractAddress(ContractType.EVM, parsedChainId)} - - /// Executes the calldata from the signer's COA - /// - transaction(evmContractAddressHex: String, calldata: String, gasLimit: UInt64, value: UInt256) { - - let evmAddress: EVM.EVMAddress - let coa: auth(EVM.Call) &EVM.CadenceOwnedAccount - - prepare(signer: auth(BorrowValue) &Account) { - self.evmAddress = EVM.addressFromString(evmContractAddressHex) - - self.coa = signer.storage.borrow(from: /storage/evm) - ?? panic("Could not borrow COA from provided gateway address") - } - - execute { - let valueBalance = EVM.Balance(attoflow: value) - let callResult = self.coa.call( - to: self.evmAddress, - data: calldata.decodeHex(), - gasLimit: gasLimit, - value: valueBalance - ) - assert(callResult.status == EVM.Status.successful, message: "Call failed") - } - }`, + cadence: sendTransactionTx(parsedChainId), limit: 9999, args: (arg: typeof fcl.arg, t: typeof fcl.t) => [ arg(to, t.String), @@ -229,19 +262,13 @@ export class AccountManager { authz: this.user, }) - const result = await fcl.tx(txId).onceExecuted() - const {events} = result - - const evmTxExecutedEvent = events.find( - event => - event.type === - EVENT_IDENTIFIERS[EventType.TRANSACTION_EXECUTED][flowNetwork] + const event = await this.waitForTxResult( + txId, + EVENT_IDENTIFIERS[EventType.TRANSACTION_EXECUTED][flowNetwork], + "EVM transaction hash not found" ) - if (!evmTxExecutedEvent) { - throw new Error("EVM transaction hash not found") - } - const eventData: TransactionExecutedEvent = evmTxExecutedEvent.data + const eventData: TransactionExecutedEvent = event.data const evmTxHash = eventData.hash .map(h => parseInt(h, 16).toString().padStart(2, "0")) .join("") diff --git a/packages/fcl-ethereum-provider/src/cadence.ts b/packages/fcl-ethereum-provider/src/cadence.ts new file mode 100644 index 000000000..fc821101f --- /dev/null +++ b/packages/fcl-ethereum-provider/src/cadence.ts @@ -0,0 +1,62 @@ +import {getContractAddress} from "./util/eth" +import {ContractType} from "./constants" + +export const getCOAScript = (chainId: number) => ` +import EVM from ${getContractAddress(ContractType.EVM, chainId)} + +access(all) +fun main(address: Address): String? { + if let coa = getAuthAccount(address) + .storage + .borrow<&EVM.CadenceOwnedAccount>(from: /storage/evm) { + return coa.address().toString() + } + return nil +} +` + +export const createCOATx = (chainId: number) => ` +import EVM from ${getContractAddress(ContractType.EVM, chainId)} + +transaction() { + prepare(signer: auth(SaveValue, IssueStorageCapabilityController, PublishCapability) &Account) { + let storagePath = /storage/evm + let publicPath = /public/evm + + let coa: @EVM.CadenceOwnedAccount <- EVM.createCadenceOwnedAccount() + signer.storage.save(<-coa, to: storagePath) + + let cap = signer.capabilities.storage.issue<&EVM.CadenceOwnedAccount>(storagePath) + signer.capabilities.publish(cap, at: publicPath) + } +} +` + +export const sendTransactionTx = (chainId: number) => ` +import EVM from ${getContractAddress(ContractType.EVM, chainId)} + +/// Executes the calldata from the signer's COA +transaction(evmContractAddressHex: String, calldata: String, gasLimit: UInt64, value: UInt256) { + + let evmAddress: EVM.EVMAddress + let coa: auth(EVM.Call) &EVM.CadenceOwnedAccount + + prepare(signer: auth(BorrowValue) &Account) { + self.evmAddress = EVM.addressFromString(evmContractAddressHex) + + self.coa = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow COA from provided gateway address") + } + + execute { + let valueBalance = EVM.Balance(attoflow: value) + let callResult = self.coa.call( + to: self.evmAddress, + data: calldata.decodeHex(), + gasLimit: gasLimit, + value: valueBalance + ) + assert(callResult.status == EVM.Status.successful, message: "Call failed") + } +} +` diff --git a/packages/fcl-ethereum-provider/src/constants.ts b/packages/fcl-ethereum-provider/src/constants.ts index c9c05a4eb..9996971d4 100644 --- a/packages/fcl-ethereum-provider/src/constants.ts +++ b/packages/fcl-ethereum-provider/src/constants.ts @@ -19,6 +19,7 @@ export enum ContractType { } export enum EventType { + CADENCE_OWNED_ACCOUNT_CREATED = "CADENCE_OWNED_ACCOUNT_CREATED", TRANSACTION_EXECUTED = "TRANSACTION_EXECUTED", } @@ -27,6 +28,10 @@ export const EVENT_IDENTIFIERS = { [FlowNetwork.TESTNET]: "A.8c5303eaa26202d6.EVM.TransactionExecuted", [FlowNetwork.MAINNET]: "A.e467b9dd11fa00df.EVM.TransactionExecuted", }, + [EventType.CADENCE_OWNED_ACCOUNT_CREATED]: { + [FlowNetwork.TESTNET]: "A.8c5303eaa26202d6.EVM.CadenceOwnedAccountCreated", + [FlowNetwork.MAINNET]: "A.e467b9dd11fa00df.EVM.CadenceOwnedAccountCreated", + }, } export const FLOW_CONTRACTS = { diff --git a/packages/fcl-ethereum-provider/src/create-provider.ts b/packages/fcl-ethereum-provider/src/create-provider.ts index da1f783df..185f83668 100644 --- a/packages/fcl-ethereum-provider/src/create-provider.ts +++ b/packages/fcl-ethereum-provider/src/create-provider.ts @@ -43,7 +43,7 @@ export function createProvider(config: { ) const networkManager = new NetworkManager(config.config) - const accountManager = new AccountManager(config.user) + const accountManager = new AccountManager(config.user, networkManager) const gateway = new Gateway({ ...defaultRpcUrls, ...(config.rpcUrls || {}), 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 c849a9cd2..523138234 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 @@ -38,28 +38,29 @@ describe("ethRequestAccounts handler", () => { accountManagerMock = { authenticate: jest.fn(), getAccounts: jest.fn(), + getAndCreateAccounts: jest.fn(), updateCOAAddress: jest.fn(), subscribe: jest.fn(), } as unknown as jest.Mocked }) it("should call authenticate, updateCOAAddress, and return the manager's accounts", async () => { - accountManagerMock.getAccounts.mockResolvedValue(["0x1234..."]) + accountManagerMock.getAndCreateAccounts.mockResolvedValue(["0x1234..."]) const accounts = await ethRequestAccounts(accountManagerMock) expect(accountManagerMock.authenticate).toHaveBeenCalled() - expect(accountManagerMock.getAccounts).toHaveBeenCalled() + expect(accountManagerMock.getAndCreateAccounts).toHaveBeenCalled() expect(accounts).toEqual(["0x1234..."]) }) it("should handle empty accounts scenario", async () => { - accountManagerMock.getAccounts.mockResolvedValue([]) + accountManagerMock.getAndCreateAccounts.mockResolvedValue([]) const accounts = await ethRequestAccounts(accountManagerMock) expect(accountManagerMock.authenticate).toHaveBeenCalled() - expect(accountManagerMock.getAccounts).toHaveBeenCalled() + expect(accountManagerMock.getAndCreateAccounts).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 54508fdaf..0e83bd4a4 100644 --- a/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts +++ b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts @@ -6,8 +6,11 @@ export async function ethAccounts( return await accountManager.getAccounts() } -export async function ethRequestAccounts(accountManager: AccountManager) { +export async function ethRequestAccounts( + accountManager: AccountManager, + chainId: number +): Promise { await accountManager.authenticate() - return await accountManager.getAccounts() + return await accountManager.getAndCreateAccounts(chainId) } diff --git a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts index 31516a576..f1f621dac 100644 --- a/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts +++ b/packages/fcl-ethereum-provider/src/rpc/rpc-processor.ts @@ -32,7 +32,7 @@ export class RpcProcessor { case "eth_accounts": return ethAccounts(this.accountManager) case "eth_requestAccounts": - return ethRequestAccounts(this.accountManager) + return ethRequestAccounts(this.accountManager, chainId) case "eth_sendTransaction": return await ethSendTransaction(this.accountManager, params) case "eth_signTypedData":