diff --git a/.changeset/chilly-kangaroos-train.md b/.changeset/chilly-kangaroos-train.md new file mode 100644 index 000000000..3a18cb772 --- /dev/null +++ b/.changeset/chilly-kangaroos-train.md @@ -0,0 +1,6 @@ +--- +"@onflow/react-sdk": minor +"@onflow/demo": minor +--- + +Added `useCrossVmBridgeTokenFromEvm` hook for bridging fungible tokens from Flow EVM to Cadence. This hook withdraws tokens from the signer's Cadence-Owned Account (COA) in EVM and deposits them into their Cadence vault, automatically configuring the vault if needed. diff --git a/packages/demo/src/components/content-section.tsx b/packages/demo/src/components/content-section.tsx index 598a6abc6..3671346b5 100644 --- a/packages/demo/src/components/content-section.tsx +++ b/packages/demo/src/components/content-section.tsx @@ -12,6 +12,8 @@ import {UseFlowMutateCard} from "./hook-cards/use-flow-mutate-card" import {UseFlowEventsCard} from "./hook-cards/use-flow-events-card" import {UseFlowTransactionStatusCard} from "./hook-cards/use-flow-transaction-status-card" import {UseFlowRevertibleRandomCard} from "./hook-cards/use-flow-revertible-random-card" +import {UseCrossVmBridgeTokenFromEvmCard} from "./hook-cards/use-cross-vm-bridge-token-from-evm-card" +import {UseCrossVmBridgeTokenToEvmCard} from "./hook-cards/use-cross-vm-bridge-token-to-evm-card" import {UseFlowNftMetadataCard} from "./hook-cards/use-flow-nft-metadata-card" // Import setup cards @@ -86,6 +88,8 @@ export function ContentSection() { + + diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 38d8182ce..832a2e492 100644 --- a/packages/demo/src/components/content-sidebar.tsx +++ b/packages/demo/src/components/content-sidebar.tsx @@ -110,6 +110,18 @@ const sidebarItems: SidebarItem[] = [ category: "hooks", description: "Track transaction status", }, + { + id: "usecrossvmbridgetokenfromevm", + label: "Bridge Token from EVM", + category: "hooks", + description: "Bridge tokens from EVM to Cadence", + }, + { + id: "usecrossvmbridgetokentoevm", + label: "Bridge Token to EVM", + category: "hooks", + description: "Bridge tokens from Cadence to EVM", + }, { id: "useflownftmetadata", label: "NFT Metadata", diff --git a/packages/demo/src/components/hook-cards/use-cross-vm-bridge-token-from-evm-card.tsx b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-token-from-evm-card.tsx new file mode 100644 index 000000000..74886c941 --- /dev/null +++ b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-token-from-evm-card.tsx @@ -0,0 +1,232 @@ +import {useCrossVmBridgeTokenFromEvm, useFlowConfig} from "@onflow/react-sdk" +import {useState, useMemo} from "react" +import {useDarkMode} from "../flow-provider-wrapper" +import {DemoCard} from "../ui/demo-card" +import {ResultsSection} from "../ui/results-section" +import {getContractAddress} from "../../constants" +import {PlusGridIcon} from "../ui/plus-grid" + +const IMPLEMENTATION_CODE = `import { useCrossVmBridgeTokenFromEvm } from "@onflow/react-sdk" + +const { + crossVmBridgeTokenFromEvm, + isPending, + error, + data: txId +} = useCrossVmBridgeTokenFromEvm() + +crossVmBridgeTokenFromEvm({ + vaultIdentifier: "A.dfc20aee650fcbdf.EVMVMBridgedToken_xxx.Vault", + amount: "1000000000000000000" +})` + +export function UseCrossVmBridgeTokenFromEvmCard() { + const {darkMode} = useDarkMode() + const config = useFlowConfig() + const currentNetwork = config.flowNetwork || "emulator" + const [vaultIdentifier, setVaultIdentifier] = useState("") + const [amount, setAmount] = useState("1") // User-friendly amount + const [decimals, setDecimals] = useState("18") // ERC20 decimals + + const { + crossVmBridgeTokenFromEvm, + isPending, + data: transactionId, + error, + } = useCrossVmBridgeTokenFromEvm() + + const clickTokenData = useMemo(() => { + if (currentNetwork !== "testnet") return null + + const clickTokenAddress = getContractAddress("ClickToken", currentNetwork) + return { + name: "ClickToken", + vaultIdentifier: `A.${clickTokenAddress.replace("0x", "")}.EVMVMBridgedToken_a7cf2260e501952c71189d04fad17c704dfb36e6.Vault`, + amount: "1", // 1 ClickToken + decimals: "18", // ClickToken has 18 decimals + } + }, [currentNetwork]) + + // Set default vault identifier when network changes to testnet + useMemo(() => { + if (clickTokenData && !vaultIdentifier) { + setVaultIdentifier(clickTokenData.vaultIdentifier) + setAmount(clickTokenData.amount) + setDecimals(clickTokenData.decimals) + } + }, [clickTokenData, vaultIdentifier]) + + // Convert user-friendly amount to Wei (UInt256) + const convertToWei = (value: string, tokenDecimals: string): string => { + try { + // Remove any non-numeric characters except decimal point + const cleaned = value.replace(/[^\d.]/g, "") + if (!cleaned || cleaned === ".") return "0" + + const decimalPlaces = parseInt(tokenDecimals) || 18 + const [whole = "0", fraction = ""] = cleaned.split(".") + + // Pad or truncate fraction to match decimals + const paddedFraction = fraction + .padEnd(decimalPlaces, "0") + .slice(0, decimalPlaces) + + // Combine whole and fraction parts + const weiValue = (whole || "0") + paddedFraction + + // Remove leading zeros but keep at least one digit + const result = weiValue.replace(/^0+/, "") || "0" + + return result + } catch { + return "0" + } + } + + const handleBridgeToken = () => { + const weiAmount = convertToWei(amount, decimals) + console.log("Bridging token:", { + amount, + decimals, + weiAmount, + vaultIdentifier, + }) + crossVmBridgeTokenFromEvm({ + vaultIdentifier, + amount: weiAmount, + }) + } + + return ( + +
+ {clickTokenData && ( +
+ +

+ Note: Example prefilled with ClickToken (ERC20) + vault identifier for testnet +

+
+ )} + +
+ + setVaultIdentifier(e.target.value)} + placeholder={ + clickTokenData + ? clickTokenData.vaultIdentifier + : "e.g., A.dfc20aee650fcbdf.EVMVMBridgedToken_xxx.Vault" + } + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="e.g., 1 or 1.5" + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +
+ +
+ + setDecimals(e.target.value)} + placeholder="e.g., 18" + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +

+ ERC20 decimals (typically 18). Amount will be converted to Wei + automatically. +

+
+ +
+ +
+ + +
+
+ ) +} diff --git a/packages/demo/src/components/hook-cards/use-cross-vm-bridge-token-to-evm-card.tsx b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-token-to-evm-card.tsx new file mode 100644 index 000000000..84dff1bcf --- /dev/null +++ b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-token-to-evm-card.tsx @@ -0,0 +1,187 @@ +import {useCrossVmBridgeTokenToEvm, useFlowConfig} from "@onflow/react-sdk" +import {useState, useMemo} from "react" +import {useDarkMode} from "../flow-provider-wrapper" +import {DemoCard} from "../ui/demo-card" +import {ResultsSection} from "../ui/results-section" +import {getContractAddress} from "../../constants" +import {PlusGridIcon} from "../ui/plus-grid" + +const IMPLEMENTATION_CODE = `import { useCrossVmBridgeTokenToEvm } from "@onflow/react-sdk" + +const { + crossVmBridgeTokenToEvm, + isPending, + error, + data: txId +} = useCrossVmBridgeTokenToEvm() + +crossVmBridgeTokenToEvm({ + vaultIdentifier: "A.dfc20aee650fcbdf.EVMVMBridgedToken_xxx.Vault", + amount: "1.0", + calls: [] +})` + +export function UseCrossVmBridgeTokenToEvmCard() { + const {darkMode} = useDarkMode() + const config = useFlowConfig() + const currentNetwork = config.flowNetwork || "emulator" + const [vaultIdentifier, setVaultIdentifier] = useState("") + const [amount, setAmount] = useState("1") // UFix64 in Cadence (8 decimals) + + const { + crossVmBridgeTokenToEvm, + isPending, + data: transactionId, + error, + } = useCrossVmBridgeTokenToEvm() + + const clickTokenData = useMemo(() => { + if (currentNetwork !== "testnet") return null + + const clickTokenAddress = getContractAddress("ClickToken", currentNetwork) + return { + name: "ClickToken", + vaultIdentifier: `A.${clickTokenAddress.replace("0x", "")}.EVMVMBridgedToken_a7cf2260e501952c71189d04fad17c704dfb36e6.Vault`, + amount: "1", // 1 ClickToken (UFix64 with 8 decimals) + } + }, [currentNetwork]) + + // Set default vault identifier when network changes to testnet + useMemo(() => { + if (clickTokenData && !vaultIdentifier) { + setVaultIdentifier(clickTokenData.vaultIdentifier) + setAmount(clickTokenData.amount) + } + }, [clickTokenData, vaultIdentifier]) + + // Normalize amount to ensure it has a decimal point for UFix64 + const normalizeAmount = (value: string): string => { + // Remove any non-numeric characters except decimal point + const cleaned = value.replace(/[^\d.]/g, "") + // If it's just a number without decimal, add .0 + if (cleaned && !cleaned.includes(".")) { + return `${cleaned}.0` + } + return cleaned + } + + const handleBridgeToken = () => { + const normalizedAmount = normalizeAmount(amount) + crossVmBridgeTokenToEvm({ + vaultIdentifier, + amount: normalizedAmount, + calls: [], // No EVM calls, just bridging + }) + } + + return ( + +
+ {clickTokenData && ( +
+ +

+ Note: Example prefilled with ClickToken (ERC20) + vault identifier for testnet +

+
+ )} + +
+ + setVaultIdentifier(e.target.value)} + placeholder={ + clickTokenData + ? clickTokenData.vaultIdentifier + : "e.g., A.dfc20aee650fcbdf.EVMVMBridgedToken_xxx.Vault" + } + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +
+ +
+ + setAmount(e.target.value)} + placeholder="e.g., 1 or 1.5" + className={`w-full px-4 py-3 rounded-lg border font-mono text-sm transition-all duration-200 + ${ + darkMode + ? `bg-gray-900/50 border-white/10 text-white placeholder-gray-500 + focus:border-flow-primary/50` + : `bg-white border-black/10 text-black placeholder-gray-400 + focus:border-flow-primary/50` + } outline-none`} + /> +

+ Enter amount as a number (e.g., "1" or "1.5"). Will be converted to + UFix64 format. +

+
+ +
+ +
+ + +
+
+ ) +} diff --git a/packages/demo/src/constants.ts b/packages/demo/src/constants.ts index 6fb1ad2a2..af84a73f5 100644 --- a/packages/demo/src/constants.ts +++ b/packages/demo/src/constants.ts @@ -21,6 +21,9 @@ export const CONTRACT_ADDRESSES: Record> = { testnet: "0x7e60df042a9c0868", mainnet: "0x1654653399040a61", }, + ClickToken: { + testnet: "0xdfc20aee650fcbdf", + }, FungibleToken: { local: "0xee82856bf20e2aa6", emulator: "0xee82856bf20e2aa6", diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index 4f51c9e69..a3ddf00b4 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -16,5 +16,7 @@ export {useFlowTransaction} from "./useFlowTransaction" export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" +export {useCrossVmBridgeTokenFromEvm} from "./useCrossVmBridgeTokenFromEvm" +export {useCrossVmBridgeTokenToEvm} from "./useCrossVmBridgeTokenToEvm" export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" export {useFlowNftMetadata, type NftViewResult} from "./useFlowNftMetadata" diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeTokenFromEvm.test.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenFromEvm.test.ts new file mode 100644 index 000000000..b75b38b0b --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenFromEvm.test.ts @@ -0,0 +1,168 @@ +import {renderHook, act, waitFor} from "@testing-library/react" +import * as fcl from "@onflow/fcl" +import {FlowProvider} from "../provider" +import { + getCrossVmBridgeTokenFromEvmTransaction, + useCrossVmBridgeTokenFromEvm, +} from "./useCrossVmBridgeTokenFromEvm" +import {useFlowChainId} from "./useFlowChainId" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useCrossVmBridgeTokenFromEvm", () => { + let mockFcl: MockFclInstance + + const mockTxId = "0x123" + const mockTxResult = { + events: [ + { + type: "TransactionExecuted", + data: { + hash: ["1", "2", "3"], + errorCode: "0", + errorMessage: "", + }, + }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + describe("getCrossVmBridgeTokenFromEvmTransaction", () => { + it("should return correct cadence for mainnet", () => { + const result = getCrossVmBridgeTokenFromEvmTransaction("mainnet") + expect(result).toContain("import EVM from 0xe467b9dd11fa00df") + }) + + it("should return correct cadence for testnet", () => { + const result = getCrossVmBridgeTokenFromEvmTransaction("testnet") + expect(result).toContain("import EVM from 0x8c5303eaa26202d6") + }) + + it("should throw error for unsupported chain", () => { + expect(() => + getCrossVmBridgeTokenFromEvmTransaction("unsupported") + ).toThrow("Unsupported chain: unsupported") + }) + }) + + describe("useCrossVmBridgeTokenFromEvmTx", () => { + test("should handle successful transaction", async () => { + mockFcl.mockFclInstance.mutate.mockResolvedValue(mockTxId) + mockFcl.mockTx.mockReturnValue({ + onceExecuted: jest.fn().mockResolvedValue(mockTxResult), + } as any) + + let result: any + let rerender: any + await act(async () => { + ;({result, rerender} = renderHook(useCrossVmBridgeTokenFromEvm, { + wrapper: FlowProvider, + })) + }) + + await act(async () => { + await result.current.crossVmBridgeTokenFromEvm({ + vaultIdentifier: "A.dfc20aee650fcbdf.ClickToken.Vault", + amount: "1000000000000000000", + }) + rerender() + }) + + await waitFor(() => result.current.isPending === false) + + expect(result.current.isError).toBe(false) + expect(result.current.data).toBe(mockTxId) + }) + + it("should handle missing chain ID", async () => { + ;(useFlowChainId as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + }) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeTokenFromEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeTokenFromEvm({ + vaultIdentifier: "A.dfc20aee650fcbdf.ClickToken.Vault", + amount: "1000000000000000000", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("No current chain found") + }) + + it("should handle loading chain ID", async () => { + ;(useFlowChainId as jest.Mock).mockReturnValue({ + data: null, + isLoading: true, + }) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeTokenFromEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeTokenFromEvm({ + vaultIdentifier: "A.dfc20aee650fcbdf.ClickToken.Vault", + amount: "1000000000000000000", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("No current chain found") + }) + + it("should handle mutation error", async () => { + mockFcl.mockFclInstance.mutate.mockRejectedValue( + new Error("Mutation failed") + ) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeTokenFromEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeTokenFromEvm({ + vaultIdentifier: "A.dfc20aee650fcbdf.ClickToken.Vault", + amount: "1000000000000000000", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("Mutation failed") + }) + }) +}) diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeTokenFromEvm.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenFromEvm.ts new file mode 100644 index 000000000..aebf007e1 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenFromEvm.ts @@ -0,0 +1,230 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationOptions, + UseMutationResult, +} from "@tanstack/react-query" +import {CONTRACT_ADDRESSES} from "../constants" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowClient} from "./useFlowClient" + +export interface UseCrossVmBridgeTokenFromEvmArgs { + mutation?: Omit< + UseMutationOptions, + "mutationFn" + > + flowClient?: ReturnType +} + +export interface UseCrossVmBridgeTokenFromEvmMutateArgs { + vaultIdentifier: string + amount: string +} + +export interface UseCrossVmBridgeTokenFromEvmResult + extends Omit, "mutate" | "mutateAsync"> { + crossVmBridgeTokenFromEvm: UseMutateFunction< + string, + Error, + UseCrossVmBridgeTokenFromEvmMutateArgs + > + crossVmBridgeTokenFromEvmAsync: UseMutateAsyncFunction< + string, + Error, + UseCrossVmBridgeTokenFromEvmMutateArgs + > +} + +// Takes a chain id and returns the cadence tx with addresses set +export const getCrossVmBridgeTokenFromEvmTransaction = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FungibleToken from ${contractAddresses.FungibleToken} +import ViewResolver from ${contractAddresses.ViewResolver} +import FungibleTokenMetadataViews from ${contractAddresses.FungibleTokenMetadataViews} +import FlowToken from ${contractAddresses.FlowToken} + +import ScopedFTProviders from ${contractAddresses.ScopedFTProviders} + +import EVM from ${contractAddresses.EVM} + +import FlowEVMBridge from ${contractAddresses.FlowEVMBridge} +import FlowEVMBridgeConfig from ${contractAddresses.FlowEVMBridgeConfig} +import FlowEVMBridgeUtils from ${contractAddresses.FlowEVMBridgeUtils} + +/// This transaction bridges fungible tokens from EVM to Cadence assuming it has already been onboarded to the +/// FlowEVMBridge. +/// +/// NOTE: The ERC20 must have first been onboarded to the bridge. This can be checked via the method +/// FlowEVMBridge.evmAddressRequiresOnboarding(address: self.evmContractAddress) +/// +/// @param vaultIdentifier: The Cadence type identifier of the FungibleToken Vault to bridge +/// - e.g. vault.getType().identifier +/// @param amount: The amount of tokens to bridge from EVM +/// +transaction(vaultIdentifier: String, amount: UInt256) { + + let vaultType: Type + let receiver: &{FungibleToken.Vault} + let scopedProvider: @ScopedFTProviders.ScopedFTProvider + let coa: auth(EVM.Bridge) &EVM.CadenceOwnedAccount + + prepare(signer: auth(BorrowValue, CopyValue, IssueStorageCapabilityController, PublishCapability, SaveValue, UnpublishCapability) &Account) { + + /* --- Reference the signer's CadenceOwnedAccount --- */ + // + // Borrow a reference to the signer's COA + self.coa = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow COA signer's account at path /storage/evm") + + /* --- Construct the Vault type --- */ + // + // Construct the Vault type from the provided identifier + self.vaultType = CompositeType(vaultIdentifier) + ?? panic("Could not construct Vault type from identifier: ".concat(vaultIdentifier)) + // Parse the Vault identifier into its components + let tokenContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: self.vaultType) + ?? panic("Could not get contract address from identifier: ".concat(vaultIdentifier)) + let tokenContractName = FlowEVMBridgeUtils.getContractName(fromType: self.vaultType) + ?? panic("Could not get contract name from identifier: ".concat(vaultIdentifier)) + + /* --- Reference the signer's Vault --- */ + // + // Borrow a reference to the FungibleToken Vault, configuring if necessary + let viewResolver = getAccount(tokenContractAddress).contracts.borrow<&{ViewResolver}>(name: tokenContractName) + ?? panic("Could not borrow ViewResolver from FungibleToken contract with name" + .concat(tokenContractName).concat(" and address ") + .concat(tokenContractAddress.toString())) + let vaultData = viewResolver.resolveContractView( + resourceType: self.vaultType, + viewType: Type() + ) as! FungibleTokenMetadataViews.FTVaultData? + ?? panic("Could not resolve FTVaultData view for Vault type ".concat(self.vaultType.identifier)) + // If the vault does not exist, create it and publish according to the contract's defined configuration + if signer.storage.borrow<&{FungibleToken.Vault}>(from: vaultData.storagePath) == nil { + signer.storage.save(<-vaultData.createEmptyVault(), to: vaultData.storagePath) + + signer.capabilities.unpublish(vaultData.receiverPath) + signer.capabilities.unpublish(vaultData.metadataPath) + + let receiverCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(vaultData.storagePath) + let metadataCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(vaultData.storagePath) + + signer.capabilities.publish(receiverCap, at: vaultData.receiverPath) + signer.capabilities.publish(metadataCap, at: vaultData.metadataPath) + } + self.receiver = signer.storage.borrow<&{FungibleToken.Vault}>(from: vaultData.storagePath) + ?? panic("Could not borrow FungibleToken Vault from storage path ".concat(vaultData.storagePath.toString())) + + /* --- Configure a ScopedFTProvider --- */ + // + // Set a cap on the withdrawable bridge fee + var approxFee = FlowEVMBridgeUtils.calculateBridgeFee( + bytes: 400_000 // 400 kB as upper bound on movable storage used in a single transaction + ) + + // Issue and store bridge-dedicated Provider Capability in storage if necessary + if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil { + let providerCap = signer.capabilities.storage.issue( + /storage/flowTokenVault + ) + signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath) + } + // Copy the stored Provider capability and create a ScopedFTProvider + let providerCapCopy = signer.storage.copy>( + from: FlowEVMBridgeConfig.providerCapabilityStoragePath + ) ?? panic("Invalid FungibleToken Provider Capability found in storage at path " + .concat(FlowEVMBridgeConfig.providerCapabilityStoragePath.toString())) + let providerFilter = ScopedFTProviders.AllowanceFilter(approxFee) + self.scopedProvider <- ScopedFTProviders.createScopedFTProvider( + provider: providerCapCopy, + filters: [ providerFilter ], + expiration: getCurrentBlock().timestamp + 1.0 + ) + } + + execute { + // Execute the bridge request + let vault: @{FungibleToken.Vault} <- self.coa.withdrawTokens( + type: self.vaultType, + amount: amount, + feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + // Ensure the bridged vault is the correct type + assert( + vault.getType() == self.vaultType, + message: "Bridged vault type mismatch - requested: ".concat(self.vaultType.identifier) + .concat(", received: ").concat(vault.getType().identifier) + ) + // Deposit the bridged token into the signer's vault + self.receiver.deposit(from: <-vault) + // Destroy the ScopedFTProvider + destroy self.scopedProvider + } +} +` +} + +/** + * Hook to bridge fungible tokens from Flow EVM to Cadence. This function will + * withdraw tokens from the signer's COA in EVM and deposit them into their Cadence vault. + * + * @returns The mutation object used to send the transaction. + */ +export function useCrossVmBridgeTokenFromEvm({ + mutation: mutationOptions = {}, + flowClient, +}: UseCrossVmBridgeTokenFromEvmArgs = {}): UseCrossVmBridgeTokenFromEvmResult { + const chainId = useFlowChainId() + const cadenceTx = chainId.data + ? getCrossVmBridgeTokenFromEvmTransaction(chainId.data) + : null + + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + const mutation = useMutation( + { + mutationFn: async ({ + vaultIdentifier, + amount, + }: UseCrossVmBridgeTokenFromEvmMutateArgs) => { + if (!cadenceTx) { + throw new Error("No current chain found") + } + + const txId = await fcl.mutate({ + cadence: cadenceTx, + args: (arg, t) => [ + arg(vaultIdentifier, t.String), + arg(amount, t.UInt256), + ], + limit: 9999, + }) + + return txId + }, + retry: false, + ...mutationOptions, + }, + queryClient + ) + + const { + mutate: crossVmBridgeTokenFromEvm, + mutateAsync: crossVmBridgeTokenFromEvmAsync, + ...rest + } = mutation + + return { + crossVmBridgeTokenFromEvm, + crossVmBridgeTokenFromEvmAsync, + ...rest, + } +} diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeTokenToEvm.test.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenToEvm.test.ts new file mode 100644 index 000000000..364df0f5d --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenToEvm.test.ts @@ -0,0 +1,183 @@ +import {renderHook, act, waitFor} from "@testing-library/react" +import * as fcl from "@onflow/fcl" +import {FlowProvider} from "../provider" +import { + getCrossVmBridgeTokenToEvmTransaction, + useCrossVmBridgeTokenToEvm, +} from "./useCrossVmBridgeTokenToEvm" +import {useFlowChainId} from "./useFlowChainId" +import {createMockFclInstance, MockFclInstance} from "../__mocks__/flow-client" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useCrossVmBridgeTokenToEvm", () => { + let mockFcl: MockFclInstance + + const mockCalls = [ + { + address: "0x123", + abi: [{type: "function", name: "test"}], + functionName: "test", + args: [1, 2], + gasLimit: BigInt(100000), + value: BigInt(0), + }, + ] + + const mockTxId = "0x123" + const mockTxResult = { + events: [ + { + type: "TransactionExecuted", + data: { + hash: ["1", "2", "3"], + errorCode: "0", + errorMessage: "", + }, + }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + jest.mocked(useFlowChainId).mockReturnValue({ + data: "mainnet", + isLoading: false, + } as any) + + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + }) + + describe("getCrossVmBridgeTokenToEvmTransaction", () => { + it("should return correct cadence for mainnet", () => { + const result = getCrossVmBridgeTokenToEvmTransaction("mainnet") + expect(result).toContain("import EVM from 0xe467b9dd11fa00df") + }) + + it("should return correct cadence for testnet", () => { + const result = getCrossVmBridgeTokenToEvmTransaction("testnet") + expect(result).toContain("import EVM from 0x8c5303eaa26202d6") + }) + + it("should throw error for unsupported chain", () => { + expect(() => + getCrossVmBridgeTokenToEvmTransaction("unsupported") + ).toThrow("Unsupported chain: unsupported") + }) + }) + + describe("useCrossVmBatchTransaction", () => { + test("should handle successful transaction", async () => { + jest.mocked(mockFcl.mockFclInstance.mutate).mockResolvedValue(mockTxId) + jest.mocked(mockFcl.mockFclInstance.tx).mockReturnValue({ + onceExecuted: jest.fn().mockResolvedValue(mockTxResult), + } as any) + + let result: any + let rerender: any + await act(async () => { + ;({result, rerender} = renderHook(useCrossVmBridgeTokenToEvm, { + wrapper: FlowProvider, + })) + }) + + await act(async () => { + await result.current.crossVmBridgeTokenToEvm({ + calls: mockCalls, + vaultIdentifier: "A.1234.Token.Vault", + amount: "100.0", + }) + rerender() + }) + + await waitFor(() => result.current.isPending === false) + + expect(result.current.isError).toBe(false) + expect(result.current.data).toBe(mockTxId) + }) + + it("should handle missing chain ID", async () => { + ;(useFlowChainId as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + }) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeTokenToEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeTokenToEvm({ + calls: mockCalls, + vaultIdentifier: "A.1234.Token.Vault", + amount: "100.0", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("No current chain found") + }) + + it("should handle loading chain ID", async () => { + ;(useFlowChainId as jest.Mock).mockReturnValue({ + data: null, + isLoading: true, + }) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeTokenToEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeTokenToEvm({ + calls: mockCalls, + vaultIdentifier: "A.1234.Token.Vault", + amount: "100.0", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("No current chain found") + }) + + it("should handle mutation error", async () => { + jest + .mocked(mockFcl.mockFclInstance.mutate) + .mockRejectedValue(new Error("Mutation failed")) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeTokenToEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeTokenToEvm({ + calls: mockCalls, + vaultIdentifier: "A.1234.Token.Vault", + amount: "100.0", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("Mutation failed") + }) + }) +}) diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeTokenToEvm.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenToEvm.ts new file mode 100644 index 000000000..a273dc2a1 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeTokenToEvm.ts @@ -0,0 +1,283 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationOptions, + UseMutationResult, +} from "@tanstack/react-query" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {encodeCalls, EvmBatchCall} from "./useCrossVmBatchTransaction" +import {CONTRACT_ADDRESSES} from "../constants" +import {useFlowClient} from "./useFlowClient" + +export interface UseCrossVmBridgeTokenToEvmArgs { + mutation?: Omit< + UseMutationOptions, + "mutationFn" + > + flowClient?: ReturnType +} + +export interface UseCrossVmBridgeTokenToEvmMutateArgs { + vaultIdentifier: string + amount: string + calls: EvmBatchCall[] +} + +export interface UseCrossVmBridgeTokenToEvmResult + extends Omit, "mutate" | "mutateAsync"> { + crossVmBridgeTokenToEvm: UseMutateFunction< + string, + Error, + UseCrossVmBridgeTokenToEvmMutateArgs + > + crossVmBridgeTokenToEvmAsync: UseMutateAsyncFunction< + string, + Error, + UseCrossVmBridgeTokenToEvmMutateArgs + > +} + +// Takes a chain id and returns the cadence tx with addresses set +export const getCrossVmBridgeTokenToEvmTransaction = (chainId: string) => { + const contractAddresses = + CONTRACT_ADDRESSES[chainId as keyof typeof CONTRACT_ADDRESSES] + if (!contractAddresses) { + throw new Error(`Unsupported chain: ${chainId}`) + } + + return ` +import FungibleToken from ${contractAddresses.FungibleToken} +import ViewResolver from ${contractAddresses.ViewResolver} +import FungibleTokenMetadataViews from ${contractAddresses.FungibleTokenMetadataViews} +import FlowToken from ${contractAddresses.FlowToken} + +import ScopedFTProviders from ${contractAddresses.ScopedFTProviders} + +import EVM from ${contractAddresses.EVM} + +import FlowEVMBridge from ${contractAddresses.FlowEVMBridge} +import FlowEVMBridgeConfig from ${contractAddresses.FlowEVMBridgeConfig} +import FlowEVMBridgeUtils from ${contractAddresses.FlowEVMBridgeUtils} + +/// Bridges a Vault from the signer's storage to the signer's COA in EVM.Account +/// and then executes an arbitrary number of EVM transactions. +/// +/// NOTE: This transaction also onboards the Vault to the bridge if necessary which may incur additional fees +/// than bridging an asset that has already been onboarded. +/// +/// @param vaultIdentifier: The Cadence type identifier of the FungibleToken Vault to bridge +/// - e.g. vault.getType().identifier +/// @param amount: The amount of tokens to bridge from EVM +/// @params evmContractAddressHexes, calldatas, gasLimits, values: Arrays of calldata +/// to be included in transaction calls to Flow EVM from the signer's COA. +/// The arrays are all expected to be of the same length +/// +/// +transaction( + vaultIdentifier: String, + amount: UFix64, + evmContractAddressHexes: [String], + calldatas: [String], + gasLimits: [UInt64], + values: [UInt] +) { + + let sentVault: @{FungibleToken.Vault} + let coa: auth(EVM.Bridge, EVM.Call) &EVM.CadenceOwnedAccount + let requiresOnboarding: Bool + let scopedProvider: @ScopedFTProviders.ScopedFTProvider + + prepare(signer: auth(CopyValue, BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue) &Account) { + pre { + (evmContractAddressHexes.length == calldatas.length) + && (calldatas.length == gasLimits.length) + && (gasLimits.length == values.length): + "Calldata array lengths must all be the same!" + } + + /* --- Reference the signer's CadenceOwnedAccount --- */ + // + // Borrow a reference to the signer's COA + self.coa = signer.storage.borrow(from: /storage/evm) + ?? panic("Could not borrow COA signer's account at path /storage/evm") + + /* --- Construct the Vault type --- */ + // + // Construct the Vault type from the provided identifier + let vaultType = CompositeType(vaultIdentifier) + ?? panic("Could not construct Vault type from identifier: ".concat(vaultIdentifier)) + // Parse the Vault identifier into its components + let tokenContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: vaultType) + ?? panic("Could not get contract address from identifier: ".concat(vaultIdentifier)) + let tokenContractName = FlowEVMBridgeUtils.getContractName(fromType: vaultType) + ?? panic("Could not get contract name from identifier: ".concat(vaultIdentifier)) + + /* --- Retrieve the funds --- */ + // + // Borrow a reference to the FungibleToken Vault + let viewResolver = getAccount(tokenContractAddress).contracts.borrow<&{ViewResolver}>(name: tokenContractName) + ?? panic("Could not borrow ViewResolver from FungibleToken contract with name" + .concat(tokenContractName).concat(" and address ") + .concat(tokenContractAddress.toString())) + let vaultData = viewResolver.resolveContractView( + resourceType: vaultType, + viewType: Type() + ) as! FungibleTokenMetadataViews.FTVaultData? + ?? panic("Could not resolve FTVaultData view for Vault type ".concat(vaultType.identifier)) + let vault = signer.storage.borrow( + from: vaultData.storagePath + ) ?? panic("Could not borrow FungibleToken Vault from storage path ".concat(vaultData.storagePath.toString())) + + // Withdraw the requested balance & set a cap on the withdrawable bridge fee + self.sentVault <- vault.withdraw(amount: amount) + var approxFee = FlowEVMBridgeUtils.calculateBridgeFee( + bytes: 400_000 // 400 kB as upper bound on movable storage used in a single transaction + ) + // Determine if the Vault requires onboarding - this impacts the fee required + self.requiresOnboarding = FlowEVMBridge.typeRequiresOnboarding(self.sentVault.getType()) + ?? panic("Bridge does not support the requested asset type ".concat(vaultIdentifier)) + if self.requiresOnboarding { + approxFee = approxFee + FlowEVMBridgeConfig.onboardFee + } + + /* --- Configure a ScopedFTProvider --- */ + // + // Issue and store bridge-dedicated Provider Capability in storage if necessary + if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil { + let providerCap = signer.capabilities.storage.issue( + /storage/flowTokenVault + ) + signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath) + } + // Copy the stored Provider capability and create a ScopedFTProvider + let providerCapCopy = signer.storage.copy>( + from: FlowEVMBridgeConfig.providerCapabilityStoragePath + ) ?? panic("Invalid FungibleToken Provider Capability found in storage at path " + .concat(FlowEVMBridgeConfig.providerCapabilityStoragePath.toString())) + let providerFilter = ScopedFTProviders.AllowanceFilter(approxFee) + self.scopedProvider <- ScopedFTProviders.createScopedFTProvider( + provider: providerCapCopy, + filters: [ providerFilter ], + expiration: getCurrentBlock().timestamp + 1.0 + ) + } + + pre { + self.sentVault.getType().identifier == vaultIdentifier: + "Attempting to send invalid vault type - requested: ".concat(vaultIdentifier) + .concat(", sending: ").concat(self.sentVault.getType().identifier) + } + + execute { + if self.requiresOnboarding { + // Onboard the Vault to the bridge + FlowEVMBridge.onboardByType( + self.sentVault.getType(), + feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + } + // Execute the bridge + self.coa.depositTokens( + vault: <-self.sentVault, + feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + // Destroy the ScopedFTProvider + destroy self.scopedProvider + + // Perform all the calls + for index, evmAddressHex in evmContractAddressHexes { + let evmAddress = EVM.addressFromString(evmAddressHex) + + let valueBalance = EVM.Balance(attoflow: values[index]) + let callResult = self.coa.call( + to: evmAddress, + data: calldatas[index].decodeHex(), + gasLimit: gasLimits[index], + value: valueBalance + ) + assert( + callResult.status == EVM.Status.successful, + message: "Call failed with address \(evmAddressHex) and calldata \(calldatas[index]) with error \(callResult.errorMessage)" + ) + } + } +} +` +} + +/** + * Hook to send a cross-VM FT spend transaction. This function will + * bundle multiple EVM calls into one atomic Cadence transaction and return the transaction ID. + * + * @returns The mutation object used to send the transaction. + */ +export function useCrossVmBridgeTokenToEvm({ + mutation: mutationOptions = {}, + flowClient, +}: UseCrossVmBridgeTokenToEvmArgs = {}): UseCrossVmBridgeTokenToEvmResult { + const chainId = useFlowChainId() + const cadenceTx = chainId.data + ? getCrossVmBridgeTokenToEvmTransaction(chainId.data) + : null + + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + const mutation = useMutation( + { + mutationFn: async ({ + vaultIdentifier, + amount, + calls, + }: UseCrossVmBridgeTokenToEvmMutateArgs) => { + if (!cadenceTx) { + throw new Error("No current chain found") + } + const encodedCalls = encodeCalls(calls) + + const txId = await fcl.mutate({ + cadence: cadenceTx, + args: (arg, t) => [ + arg(vaultIdentifier, t.String), + arg(amount, t.UFix64), + arg( + encodedCalls.map(call => call.to), + t.Array(t.String) + ), + arg( + encodedCalls.map(call => call.data), + t.Array(t.String) + ), + arg( + encodedCalls.map(call => call.gasLimit), + t.Array(t.UInt64) + ), + arg( + encodedCalls.map(call => call.value), + t.Array(t.UInt) + ), + ], + limit: 9999, + }) + + return txId + }, + retry: false, + ...mutationOptions, + }, + queryClient + ) + + const { + mutate: crossVmBridgeTokenToEvm, + mutateAsync: crossVmBridgeTokenToEvmAsync, + ...rest + } = mutation + + return { + crossVmBridgeTokenToEvm, + crossVmBridgeTokenToEvmAsync, + ...rest, + } +} diff --git a/packages/react-sdk/src/hooks/useCrossVmSpendToken.ts b/packages/react-sdk/src/hooks/useCrossVmSpendToken.ts index 35e43b2d1..07e782284 100644 --- a/packages/react-sdk/src/hooks/useCrossVmSpendToken.ts +++ b/packages/react-sdk/src/hooks/useCrossVmSpendToken.ts @@ -207,6 +207,9 @@ transaction( * Hook to send a cross-VM FT spend transaction. This function will * bundle multiple EVM calls into one atomic Cadence transaction and return the transaction ID. * + * @deprecated This hook has been renamed to `useCrossVmBridgeTokenToEvm` for better clarity. + * Please use `useCrossVmBridgeTokenToEvm` instead. This hook will be removed in a future version. + * * @returns The mutation object used to send the transaction. */ export function useCrossVmSpendToken({