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

Create COA on request accounts #2120

Merged
merged 13 commits into from
Feb 6, 2025
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -30,7 +30,7 @@ describe("AccountManager", () => {
let userMock: ReturnType<typeof mockUser>

beforeEach(() => {
jest.clearAllMocks()
jest.resetAllMocks()

const chainId$ = new BehaviorSubject<number | null>(747)
networkManager = {
Expand Down Expand Up @@ -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
Expand All @@ -130,7 +175,6 @@ describe("AccountManager", () => {

await userMock.set({addr: "0x2"} as CurrentUser)

await new Promise(setImmediate)
expect(await accountManager.getCOAAddress()).toBe("0x456")
})

Expand All @@ -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"])
})
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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)
Expand Down
143 changes: 85 additions & 58 deletions packages/fcl-ethereum-provider/src/accounts/account-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<{
Expand Down Expand Up @@ -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> {
Expand All @@ -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))
Expand Down Expand Up @@ -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),
Expand All @@ -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("")
Expand Down
62 changes: 62 additions & 0 deletions packages/fcl-ethereum-provider/src/cadence.ts
Original file line number Diff line number Diff line change
@@ -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")
}
}
`
5 changes: 5 additions & 0 deletions packages/fcl-ethereum-provider/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export enum ContractType {
}

export enum EventType {
CADENCE_OWNED_ACCOUNT_CREATED = "CADENCE_OWNED_ACCOUNT_CREATED",
TRANSACTION_EXECUTED = "TRANSACTION_EXECUTED",
}

Expand All @@ -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 = {
Expand Down
2 changes: 1 addition & 1 deletion packages/fcl-ethereum-provider/src/create-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {}),
Expand Down
Loading