diff --git a/.changeset/mighty-rocks-smell.md b/.changeset/mighty-rocks-smell.md new file mode 100644 index 000000000..0db7e2c96 --- /dev/null +++ b/.changeset/mighty-rocks-smell.md @@ -0,0 +1,6 @@ +--- +"@onflow/react-sdk": minor +"@onflow/demo": minor +--- + +Added `useCrossVmBridgeNftFromEvm` hook for bridging NFTs from Flow EVM to Cadence. This hook withdraws an NFT from the signer's Cadence-Owned Account (COA) in EVM and deposits it into their Cadence collection, automatically configuring the collection if needed. diff --git a/packages/demo/src/components/content-section.tsx b/packages/demo/src/components/content-section.tsx index 3671346b5..74a9f9e2d 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 {UseCrossVmBridgeNftFromEvmCard} from "./hook-cards/use-cross-vm-bridge-nft-from-evm-card" +import {UseCrossVmBridgeNftToEvmCard} from "./hook-cards/use-cross-vm-bridge-nft-to-evm-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" @@ -88,6 +90,8 @@ export function ContentSection() { + + diff --git a/packages/demo/src/components/content-sidebar.tsx b/packages/demo/src/components/content-sidebar.tsx index 832a2e492..536be1ce1 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: "usecrossvmbridgenftfromevm", + label: "Bridge NFT from EVM", + category: "hooks", + description: "Bridge NFTs from EVM to Cadence", + }, + { + id: "usecrossvmbridgenfttoevm", + label: "Bridge NFT to EVM", + category: "hooks", + description: "Bridge NFTs from Cadence to EVM", + }, { id: "usecrossvmbridgetokenfromevm", label: "Bridge Token from EVM", diff --git a/packages/demo/src/components/hook-cards/use-cross-vm-bridge-nft-from-evm-card.tsx b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-nft-from-evm-card.tsx new file mode 100644 index 000000000..7948ae25b --- /dev/null +++ b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-nft-from-evm-card.tsx @@ -0,0 +1,166 @@ +import {useCrossVmBridgeNftFromEvm, 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 { useCrossVmBridgeNftFromEvm } from "@onflow/react-sdk" + +const { + crossVmBridgeNftFromEvm, + isPending, + error, + data: txId +} = useCrossVmBridgeNftFromEvm() + +crossVmBridgeNftFromEvm({ + nftIdentifier: "A.dfc20aee650fcbdf.ExampleNFT.NFT", + nftId: "1" +})` + +export function UseCrossVmBridgeNftFromEvmCard() { + const {darkMode} = useDarkMode() + const config = useFlowConfig() + const currentNetwork = config.flowNetwork || "emulator" + const [nftIdentifier, setNftIdentifier] = useState("") + const [nftId, setNftId] = useState("1") + + const { + crossVmBridgeNftFromEvm, + isPending, + data: transactionId, + error, + } = useCrossVmBridgeNftFromEvm() + + const exampleNftData = useMemo(() => { + if (currentNetwork !== "testnet") return null + + const exampleNftAddress = getContractAddress("ExampleNFT", currentNetwork) + return { + name: "Example NFT", + nftIdentifier: `A.${exampleNftAddress.replace("0x", "")}.ExampleNFT.NFT`, + nftId: "1", + } + }, [currentNetwork]) + + // Set default NFT identifier when network changes to testnet + useMemo(() => { + if (exampleNftData && !nftIdentifier) { + setNftIdentifier(exampleNftData.nftIdentifier) + } + }, [exampleNftData, nftIdentifier]) + + const handleBridgeNft = () => { + crossVmBridgeNftFromEvm({ + nftIdentifier, + nftId, + }) + } + + return ( + +
+ {exampleNftData && ( +
+ +

+ Note: Example prefilled with ExampleNFT type + identifier for testnet +

+
+ )} + +
+ + setNftIdentifier(e.target.value)} + placeholder={ + exampleNftData + ? exampleNftData.nftIdentifier + : "e.g., A.dfc20aee650fcbdf.ExampleNFT.NFT" + } + 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`} + /> +
+ +
+ + setNftId(e.target.value)} + placeholder="e.g., 1" + 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`} + /> +
+ +
+ +
+ + +
+
+ ) +} diff --git a/packages/demo/src/components/hook-cards/use-cross-vm-bridge-nft-to-evm-card.tsx b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-nft-to-evm-card.tsx new file mode 100644 index 000000000..63ff8c86a --- /dev/null +++ b/packages/demo/src/components/hook-cards/use-cross-vm-bridge-nft-to-evm-card.tsx @@ -0,0 +1,171 @@ +import {useCrossVmBridgeNftToEvm, 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 { useCrossVmBridgeNftToEvm } from "@onflow/react-sdk" + +const { + crossVmBridgeNftToEvm, + isPending, + error, + data: txId +} = useCrossVmBridgeNftToEvm() + +crossVmBridgeNftToEvm({ + nftIdentifier: "A.012e4d204a60ac6f.ExampleNFT.NFT", + nftIds: ["1", "2", "3"], + calls: [] +})` + +export function UseCrossVmBridgeNftToEvmCard() { + const {darkMode} = useDarkMode() + const config = useFlowConfig() + const currentNetwork = config.flowNetwork || "emulator" + const [nftIdentifier, setNftIdentifier] = useState("") + const [nftIds, setNftIds] = useState("1") + + const { + crossVmBridgeNftToEvm, + isPending, + data: transactionId, + error, + } = useCrossVmBridgeNftToEvm() + + const exampleNftData = useMemo(() => { + if (currentNetwork !== "testnet") return null + + const exampleNftAddress = getContractAddress("ExampleNFT", currentNetwork) + return { + name: "Example NFT", + nftIdentifier: `A.${exampleNftAddress.replace("0x", "")}.ExampleNFT.NFT`, + nftIds: "1", + } + }, [currentNetwork]) + + // Set default NFT identifier when network changes to testnet + useMemo(() => { + if (exampleNftData && !nftIdentifier) { + setNftIdentifier(exampleNftData.nftIdentifier) + setNftIds(exampleNftData.nftIds) + } + }, [exampleNftData, nftIdentifier]) + + const handleBridgeNft = () => { + const nftIdArray = nftIds.split(",").map(id => id.trim()) + + crossVmBridgeNftToEvm({ + nftIdentifier, + nftIds: nftIdArray, + calls: [], // No EVM calls, just bridging + }) + } + + return ( + +
+ {exampleNftData && ( +
+ +

+ Note: Example prefilled with ExampleNFT type + identifier for testnet +

+
+ )} + +
+ + setNftIdentifier(e.target.value)} + placeholder={ + exampleNftData + ? exampleNftData.nftIdentifier + : "e.g., A.012e4d204a60ac6f.ExampleNFT.NFT" + } + 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`} + /> +
+ +
+ + setNftIds(e.target.value)} + placeholder="e.g., 1,2,3" + 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`} + /> +
+ +
+ +
+ + +
+
+ ) +} diff --git a/packages/demo/src/constants.ts b/packages/demo/src/constants.ts index af84a73f5..ec296911c 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", }, + ExampleNFT: { + testnet: "0x012e4d204a60ac6f", + }, ClickToken: { testnet: "0xdfc20aee650fcbdf", }, diff --git a/packages/react-sdk/src/hooks/index.ts b/packages/react-sdk/src/hooks/index.ts index a3ddf00b4..cf31f304b 100644 --- a/packages/react-sdk/src/hooks/index.ts +++ b/packages/react-sdk/src/hooks/index.ts @@ -16,6 +16,8 @@ export {useFlowTransaction} from "./useFlowTransaction" export {useFlowTransactionStatus} from "./useFlowTransactionStatus" export {useCrossVmSpendNft} from "./useCrossVmSpendNft" export {useCrossVmSpendToken} from "./useCrossVmSpendToken" +export {useCrossVmBridgeNftFromEvm} from "./useCrossVmBridgeNftFromEvm" +export {useCrossVmBridgeNftToEvm} from "./useCrossVmBridgeNftToEvm" export {useCrossVmBridgeTokenFromEvm} from "./useCrossVmBridgeTokenFromEvm" export {useCrossVmBridgeTokenToEvm} from "./useCrossVmBridgeTokenToEvm" export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus" diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeNftFromEvm.test.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeNftFromEvm.test.ts new file mode 100644 index 000000000..a25617a2d --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeNftFromEvm.test.ts @@ -0,0 +1,167 @@ +import {renderHook, act, waitFor} from "@testing-library/react" +import * as fcl from "@onflow/fcl" +import {FlowProvider} from "../provider" +import { + getCrossVmBridgeNftFromEvmTransaction, + useCrossVmBridgeNftFromEvm, +} from "./useCrossVmBridgeNftFromEvm" +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("useCrossVmBridgeNftFromEvm", () => { + let mockFcl: MockFclInstance + + const mockTxId = "0x123" + const mockTxResult = { + events: [ + { + type: "TransactionExecuted", + data: { + hash: ["1", "2", "3"], + errorCode: "0", + errorMessage: "", + }, + }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + mockFcl = createMockFclInstance() + jest.mocked(fcl.createFlowClient).mockReturnValue(mockFcl.mockFclInstance) + jest.mocked(useFlowChainId).mockReturnValue({ + data: "testnet", + isLoading: false, + } as any) + }) + + describe("getCrossVmBridgeNftFromEvmTransaction", () => { + it("should return correct cadence for mainnet", () => { + const result = getCrossVmBridgeNftFromEvmTransaction("mainnet") + expect(result).toContain("import EVM from 0xe467b9dd11fa00df") + }) + + it("should return correct cadence for testnet", () => { + const result = getCrossVmBridgeNftFromEvmTransaction("testnet") + expect(result).toContain("import EVM from 0x8c5303eaa26202d6") + }) + + it("should throw error for unsupported chain", () => { + expect(() => + getCrossVmBridgeNftFromEvmTransaction("unsupported") + ).toThrow("Unsupported chain: unsupported") + }) + }) + + describe("useCrossVmBridgeNftFromEvmTx", () => { + 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(useCrossVmBridgeNftFromEvm, { + wrapper: FlowProvider, + })) + }) + + await act(async () => { + await result.current.crossVmBridgeNftFromEvm({ + nftIdentifier: "A.dfc20aee650fcbdf.ExampleNFT.NFT", + nftId: "123", + }) + 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(() => useCrossVmBridgeNftFromEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeNftFromEvm({ + nftIdentifier: "A.dfc20aee650fcbdf.ExampleNFT.NFT", + nftId: "123", + }) + }) + + 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(() => useCrossVmBridgeNftFromEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeNftFromEvm({ + nftIdentifier: "A.dfc20aee650fcbdf.ExampleNFT.NFT", + nftId: "123", + }) + }) + + 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(() => useCrossVmBridgeNftFromEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeNftFromEvm({ + nftIdentifier: "A.dfc20aee650fcbdf.ExampleNFT.NFT", + nftId: "123", + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("Mutation failed") + }) + }) +}) diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeNftFromEvm.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeNftFromEvm.ts new file mode 100644 index 000000000..ae8737697 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeNftFromEvm.ts @@ -0,0 +1,224 @@ +import { + UseMutateAsyncFunction, + UseMutateFunction, + useMutation, + UseMutationOptions, + UseMutationResult, +} from "@tanstack/react-query" +import {useFlowChainId} from "./useFlowChainId" +import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {CONTRACT_ADDRESSES} from "../constants" +import {useFlowClient} from "./useFlowClient" + +export interface UseCrossVmBridgeNftFromEvmTxArgs { + mutation?: Omit< + UseMutationOptions, + "mutationFn" + > + flowClient?: ReturnType +} + +export interface UseCrossVmBridgeNftFromEvmTxMutateArgs { + nftIdentifier: string + /** The EVM NFT ID to bridge to Cadence as a string representation of a UInt256 (e.g., "123", "0x7b") */ + nftId: string +} + +export interface UseCrossVmBridgeNftFromEvmTxResult + extends Omit, "mutate" | "mutateAsync"> { + crossVmBridgeNftFromEvm: UseMutateFunction< + string, + Error, + UseCrossVmBridgeNftFromEvmTxMutateArgs + > + crossVmBridgeNftFromEvmAsync: UseMutateAsyncFunction< + string, + Error, + UseCrossVmBridgeNftFromEvmTxMutateArgs + > +} + +// Takes a chain id and returns the cadence tx with addresses set +export const getCrossVmBridgeNftFromEvmTransaction = (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 NonFungibleToken from ${contractAddresses.NonFungibleToken} +import ViewResolver from ${contractAddresses.ViewResolver} +import MetadataViews from ${contractAddresses.MetadataViews} +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 an NFT from the signer's COA in FlowEVM to their collection in Cadence +/// +/// @param nftIdentifier: The Cadence type identifier of the NFT to bridge - e.g. nft.getType().identifier +/// @param id: The EVM NFT ID to bridge to Cadence +/// +transaction(nftIdentifier: String, id: UInt256) { + let nftType: Type + let collection: &{NonFungibleToken.Collection} + 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 NFT type --- */ + // + // Construct the NFT type from the provided identifier + self.nftType = CompositeType(nftIdentifier) + ?? panic("Could not construct NFT type from identifier: ".concat(nftIdentifier)) + // Parse the NFT identifier into its components + let nftContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: self.nftType) + ?? panic("Could not get contract address from identifier: ".concat(nftIdentifier)) + let nftContractName = FlowEVMBridgeUtils.getContractName(fromType: self.nftType) + ?? panic("Could not get contract name from identifier: ".concat(nftIdentifier)) + + /* --- Configure the NFT Collection --- */ + // + // Borrow a reference to the NFT collection, configuring if necessary + let viewResolver = getAccount(nftContractAddress).contracts.borrow<&{ViewResolver}>(name: nftContractName) + ?? panic("Could not borrow ViewResolver from NFT contract with name " + .concat(nftContractName).concat(" and address ") + .concat(nftContractAddress.toString())) + let collectionData = viewResolver.resolveContractView( + resourceType: self.nftType, + viewType: Type() + ) as! MetadataViews.NFTCollectionData? + ?? panic("Could not resolve NFTCollectionData view for NFT type ".concat(self.nftType.identifier)) + + // Configure collection if it doesn't exist + if signer.storage.borrow<&{NonFungibleToken.Collection}>(from: collectionData.storagePath) == nil { + signer.storage.save(<-collectionData.createEmptyCollection(), to: collectionData.storagePath) + + signer.capabilities.unpublish(collectionData.publicPath) + + let collectionCap = signer.capabilities.storage.issue<&{NonFungibleToken.Collection}>(collectionData.storagePath) + signer.capabilities.publish(collectionCap, at: collectionData.publicPath) + } + + self.collection = signer.storage.borrow<&{NonFungibleToken.Collection}>(from: collectionData.storagePath) + ?? panic("Could not borrow a NonFungibleToken Collection from the signer's storage path " + .concat(collectionData.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 nft: @{NonFungibleToken.NFT} <- self.coa.withdrawNFT( + type: self.nftType, + id: id, + feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + // Ensure the bridged NFT is the correct type + assert( + nft.getType() == self.nftType, + message: "Bridged NFT type mismatch - requested: ".concat(self.nftType.identifier) + .concat(", received: ").concat(nft.getType().identifier) + ) + // Deposit the bridged NFT into the signer's collection + self.collection.deposit(token: <-nft) + // Destroy the ScopedFTProvider + destroy self.scopedProvider + } +} +` +} + +/** + * Hook to receive a cross-VM NFT transaction from EVM to Cadence. This function will + * withdraw an NFT from the signer's COA in EVM and deposit it into their Cadence collection. + * + * @returns The mutation object used to send the transaction. + */ +export function useCrossVmBridgeNftFromEvm({ + mutation: mutationOptions = {}, + flowClient, +}: UseCrossVmBridgeNftFromEvmTxArgs = {}): UseCrossVmBridgeNftFromEvmTxResult { + const chainId = useFlowChainId() + const cadenceTx = chainId.data + ? getCrossVmBridgeNftFromEvmTransaction(chainId.data) + : null + + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + const mutation = useMutation( + { + mutationFn: async ({ + nftIdentifier, + nftId, + }: UseCrossVmBridgeNftFromEvmTxMutateArgs) => { + if (!cadenceTx) { + throw new Error("No current chain found") + } + + const txId = await fcl.mutate({ + cadence: cadenceTx, + args: (arg, t) => [ + arg(nftIdentifier, t.String), + arg(nftId, t.UInt256), + ], + limit: 9999, + }) + + return txId + }, + retry: false, + ...mutationOptions, + }, + queryClient + ) + + const { + mutate: crossVmBridgeNftFromEvm, + mutateAsync: crossVmBridgeNftFromEvmAsync, + ...rest + } = mutation + + return { + crossVmBridgeNftFromEvm, + crossVmBridgeNftFromEvmAsync, + ...rest, + } +} diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeNftToEvm.test.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeNftToEvm.test.ts new file mode 100644 index 000000000..4e2078ee4 --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeNftToEvm.test.ts @@ -0,0 +1,175 @@ +import {renderHook, act, waitFor} from "@testing-library/react" +import * as fcl from "@onflow/fcl" +import {FlowProvider} from "../provider" +import { + getCrossVmBridgeNftToEvmTransaction, + useCrossVmBridgeNftToEvm, +} from "./useCrossVmBridgeNftToEvm" +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("useCrossVmBridgeNftToEvm", () => { + 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("getCrossVmBridgeNftToEvmTransaction", () => { + it("should return correct cadence for mainnet", () => { + const result = getCrossVmBridgeNftToEvmTransaction("mainnet") + expect(result).toContain("import EVM from 0xe467b9dd11fa00df") + }) + + it("should return correct cadence for testnet", () => { + const result = getCrossVmBridgeNftToEvmTransaction("testnet") + expect(result).toContain("import EVM from 0x8c5303eaa26202d6") + }) + + it("should throw error for unsupported chain", () => { + expect(() => getCrossVmBridgeNftToEvmTransaction("unsupported")).toThrow( + "Unsupported chain: unsupported" + ) + }) + }) + + describe("useCrossVmBridgeNftToEvmTx", () => { + 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(useCrossVmBridgeNftToEvm, { + wrapper: FlowProvider, + })) + }) + + await act(async () => { + await result.current.crossVmBridgeNftToEvm({ + calls: mockCalls, + nftIdentifier: "nft123", + nftIds: ["1", "2"], + }) + 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 () => { + jest.mocked(useFlowChainId).mockReturnValue({ + data: null, + isLoading: false, + } as any) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmBridgeNftToEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeNftToEvm({calls: mockCalls}) + }) + + 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(() => useCrossVmBridgeNftToEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeNftToEvm(mockCalls) + }) + + 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(() => useCrossVmBridgeNftToEvm(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.crossVmBridgeNftToEvm({ + calls: mockCalls, + nftIdentifier: "nft123", + nftIds: ["1", "2"], + }) + }) + + await waitFor(() => expect(hookResult.current.isError).toBe(true)) + expect(hookResult.current.error?.message).toBe("Mutation failed") + }) + }) +}) diff --git a/packages/react-sdk/src/hooks/useCrossVmBridgeNftToEvm.ts b/packages/react-sdk/src/hooks/useCrossVmBridgeNftToEvm.ts new file mode 100644 index 000000000..439769dec --- /dev/null +++ b/packages/react-sdk/src/hooks/useCrossVmBridgeNftToEvm.ts @@ -0,0 +1,292 @@ +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 UseCrossVmBridgeNftToEvmTxArgs { + mutation?: Omit< + UseMutationOptions, + "mutationFn" + > + flowClient?: ReturnType +} + +export interface UseCrossVmBridgeNftToEvmTxMutateArgs { + nftIdentifier: string + nftIds: string[] + calls: EvmBatchCall[] +} + +export interface UseCrossVmBridgeNftToEvmTxResult + extends Omit, "mutate" | "mutateAsync"> { + crossVmBridgeNftToEvm: UseMutateFunction< + string, + Error, + UseCrossVmBridgeNftToEvmTxMutateArgs + > + crossVmBridgeNftToEvmAsync: UseMutateAsyncFunction< + string, + Error, + UseCrossVmBridgeNftToEvmTxMutateArgs + > +} + +// Takes a chain id and returns the cadence tx with addresses set +export const getCrossVmBridgeNftToEvmTransaction = (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 NonFungibleToken from ${contractAddresses.NonFungibleToken} +import ViewResolver from ${contractAddresses.ViewResolver} +import MetadataViews from ${contractAddresses.MetadataViews} +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 NFTs (from the same collection) from the signer's collection in Cadence to the signer's COA in FlowEVM +/// and then performs an arbitrary number of calls afterwards to potentially do things +/// with the bridged NFTs +/// +/// NOTE: This transaction also onboards the NFT to the bridge if necessary which may incur additional fees +/// than bridging an asset that has already been onboarded. +/// +/// @param nftIdentifier: The Cadence type identifier of the NFT to bridge - e.g. nft.getType().identifier +/// @param ids: The Cadence NFT.id of the NFTs to bridge to 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( + nftIdentifier: String, + ids: [UInt64], + evmContractAddressHexes: [String], + calldatas: [String], + gasLimits: [UInt64], + values: [UInt] +) { + let nftType: Type + let collection: auth(NonFungibleToken.Withdraw) &{NonFungibleToken.Collection} + 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 NFT type --- */ + // + // Construct the NFT type from the provided identifier + self.nftType = CompositeType(nftIdentifier) + ?? panic("Could not construct NFT type from identifier: ".concat(nftIdentifier)) + // Parse the NFT identifier into its components + let nftContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: self.nftType) + ?? panic("Could not get contract address from identifier: ".concat(nftIdentifier)) + let nftContractName = FlowEVMBridgeUtils.getContractName(fromType: self.nftType) + ?? panic("Could not get contract name from identifier: ".concat(nftIdentifier)) + + /* --- Retrieve the NFT --- */ + // + // Borrow a reference to the NFT collection, configuring if necessary + let viewResolver = getAccount(nftContractAddress).contracts.borrow<&{ViewResolver}>(name: nftContractName) + ?? panic("Could not borrow ViewResolver from NFT contract with name " + .concat(nftContractName).concat(" and address ") + .concat(nftContractAddress.toString())) + let collectionData = viewResolver.resolveContractView( + resourceType: self.nftType, + viewType: Type() + ) as! MetadataViews.NFTCollectionData? + ?? panic("Could not resolve NFTCollectionData view for NFT type ".concat(self.nftType.identifier)) + self.collection = signer.storage.borrow( + from: collectionData.storagePath + ) ?? panic("Could not borrow a NonFungibleToken Collection from the signer's storage path " + .concat(collectionData.storagePath.toString())) + + // Withdraw the requested NFT & 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 + ) + (FlowEVMBridgeConfig.baseFee * UFix64(ids.length)) + // Determine if the NFT requires onboarding - this impacts the fee required + self.requiresOnboarding = FlowEVMBridge.typeRequiresOnboarding(self.nftType) + ?? panic("Bridge does not support the requested asset type ".concat(nftIdentifier)) + // Add the onboarding fee if onboarding is necessary + 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 + ) + } + + execute { + if self.requiresOnboarding { + // Onboard the NFT to the bridge + FlowEVMBridge.onboardByType( + self.nftType, + feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider} + ) + } + + // Iterate over requested IDs and bridge each NFT to the signer's COA in EVM + for id in ids { + // Withdraw the NFT & ensure it's the correct type + let nft <-self.collection.withdraw(withdrawID: id) + assert( + nft.getType() == self.nftType, + message: "Bridged nft type mismatch - requested: ".concat(self.nftType.identifier) + .concat(", received: ").concat(nft.getType().identifier) + ) + // Execute the bridge to EVM for the current ID + self.coa.depositNFT( + nft: <-nft, + 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 NFT bridge transaction from Cadence to EVM. This function will + * bundle multiple EVM calls into one atomic Cadence transaction and return the transaction ID. + * + * Use `useCrossVmTransactionStatus` to watch the status of the transaction and get the transaction id + result of each EVM call. + * + * @returns The mutation object used to send the transaction. + */ +export function useCrossVmBridgeNftToEvm({ + mutation: mutationOptions = {}, + flowClient, +}: UseCrossVmBridgeNftToEvmTxArgs = {}): UseCrossVmBridgeNftToEvmTxResult { + const chainId = useFlowChainId() + const cadenceTx = chainId.data + ? getCrossVmBridgeNftToEvmTransaction(chainId.data) + : null + + const queryClient = useFlowQueryClient() + const fcl = useFlowClient({flowClient}) + const mutation = useMutation( + { + mutationFn: async ({ + nftIdentifier, + nftIds, + calls, + }: UseCrossVmBridgeNftToEvmTxMutateArgs) => { + if (!cadenceTx) { + throw new Error("No current chain found") + } + const encodedCalls = encodeCalls(calls) + + const txId = await fcl.mutate({ + cadence: cadenceTx, + args: (arg, t) => [ + arg(nftIdentifier, t.String), + arg(nftIds, t.Array(t.UInt64)), + 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: crossVmBridgeNftToEvm, + mutateAsync: crossVmBridgeNftToEvmAsync, + ...rest + } = mutation + + return { + crossVmBridgeNftToEvm, + crossVmBridgeNftToEvmAsync, + ...rest, + } +} diff --git a/packages/react-sdk/src/hooks/useCrossVmSpendNft.ts b/packages/react-sdk/src/hooks/useCrossVmSpendNft.ts index ce68d88e6..e4f37b096 100644 --- a/packages/react-sdk/src/hooks/useCrossVmSpendNft.ts +++ b/packages/react-sdk/src/hooks/useCrossVmSpendNft.ts @@ -216,6 +216,9 @@ transaction( * * Use `useCrossVmSpendNftStatus` to watch the status of the transaction and get the transaction id + result of each EVM call. * + * @deprecated This hook has been renamed to `useCrossVmBridgeNftToEvm` for better clarity. + * Please use `useCrossVmBridgeNftToEvm` instead. This hook will be removed in a future version. + * * @returns The mutation object used to send the transaction. */ export function useCrossVmSpendNft({