Skip to content

Commit

Permalink
Create COA on request accounts (#2120)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
chasefleming and chasefleming authored Feb 6, 2025
1 parent d11dd4e commit e37469b
Show file tree
Hide file tree
Showing 8 changed files with 215 additions and 75 deletions.
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

0 comments on commit e37469b

Please sign in to comment.