Repository: onflow/fcl-js
Pull request #2120 (2025-02-05 22:27:29)

Create COA on request accounts
https://github.com/onflow/fcl-js/issues/2112

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 8d6d1d9e67f49b02c8b2b8434de92666c1b85e25

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/onflow/fcl-js/new/cf/create-coa?filename=.changeset/tidy-knives-battle.md&value=---%0A%22%40onflow%2Ffcl-ethereum-provider%22%3A%20patch%0A---%0A%0ACreate%20COA%20on%20request%20accounts%0A)



Patch:
diff --git a/packages/fcl-ethereum-provider/src/accounts/account-manager.ts b/packages/fcl-ethereum-provider/src/accounts/account-manager.ts
index 804d00ca..07aa81c2 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<any> {
+    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<string> {
     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<string | null> {
@@ -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<string[]> {
+    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<string> {
+    // 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<auth(EVM.Call) &EVM.CadenceOwnedAccount>(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 00000000..fc821101
--- /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<auth(EVM.Call) &EVM.CadenceOwnedAccount>(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 c9c05a4e..9996971d 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 da1f783d..185f8366 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.ts b/packages/fcl-ethereum-provider/src/rpc/handlers/eth-accounts.ts
index 54508fda..0e83bd4a 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<string[]> {
   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 31516a57..f1f621da 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":


Test patch:
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 af911ef9..a15147d8 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<typeof mockUser>
 
   beforeEach(() => {
-    jest.clearAllMocks()
+    jest.resetAllMocks()
 
     const chainId$ = new BehaviorSubject<number | null>(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<ReturnType<typeof fcl.tx>>
+
+    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<NetworkManager>
 
-    jest.clearAllMocks()
+    jest.resetAllMocks()
   })
 
   test("send transaction mainnet", async () => {
@@ -364,7 +406,7 @@ describe("signMessage", () => {
   let updateUser: ReturnType<typeof mockUser>["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<number | null>(747)
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 c849a9cd..52313823 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<AccountManager>
   })
 
   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([])
   })
 })