diff --git a/.changeset/tiny-rabbits-try.md b/.changeset/tiny-rabbits-try.md new file mode 100644 index 000000000..81e4e86d8 --- /dev/null +++ b/.changeset/tiny-rabbits-try.md @@ -0,0 +1,5 @@ +--- +"@onflow/kit": minor +--- + +Add `useCrossVmSpendNft` hook diff --git a/packages/kit/src/constants.ts b/packages/kit/src/constants.ts index 54243f06f..4dfcb4eee 100644 --- a/packages/kit/src/constants.ts +++ b/packages/kit/src/constants.ts @@ -2,7 +2,12 @@ export const CONTRACT_ADDRESSES = { testnet: { EVM: "0x8c5303eaa26202d6", FungibleToken: "0x9a0766d93b6608b7", + NonFungibleToken: "0x631e88ae7f1d7c20", + ViewResolver: "0x631e88ae7f1d7c20", + MetadataViews: "0x631e88ae7f1d7c20", FlowToken: "0x7e60df042a9c0868", + ScopedFTProviders: "0xdfc20aee650fcbdf", + FlowEVMBridge: "0xdfc20aee650fcbdf", FlowEVMBridgeUtils: "0xdfc20aee650fcbdf", FlowEVMBridgeConfig: "0xdfc20aee650fcbdf", FungibleTokenMetadataViews: "0x9a0766d93b6608b7", @@ -10,7 +15,12 @@ export const CONTRACT_ADDRESSES = { mainnet: { EVM: "0xe467b9dd11fa00df", FungibleToken: "0xf233dcee88fe0abe", + NonFungibleToken: "0x1d7e57aa55817448", + ViewResolver: "0x1d7e57aa55817448", + MetadataViews: "0x1d7e57aa55817448", FlowToken: "0x1654653399040a61", + ScopedFTProviders: "0x1e4aa0b87d10b141", + FlowEVMBridge: "0x1e4aa0b87d10b141", FlowEVMBridgeUtils: "0x1e4aa0b87d10b141", FlowEVMBridgeConfig: "0x1e4aa0b87d10b141", FungibleTokenMetadataViews: "0xf233dcee88fe0abe", @@ -18,3 +28,5 @@ export const CONTRACT_ADDRESSES = { } export const CADENCE_UFIX64_PRECISION = 8 + +export const DEFAULT_EVM_GAS_LIMIT = "15000000" diff --git a/packages/kit/src/hooks/index.ts b/packages/kit/src/hooks/index.ts index d7c52adf9..c01fd1401 100644 --- a/packages/kit/src/hooks/index.ts +++ b/packages/kit/src/hooks/index.ts @@ -9,3 +9,4 @@ export {useFlowRevertibleRandom} from "./useFlowRevertibleRandom" export {useCrossVmBatchTransaction} from "./useCrossVmBatchTransaction" export {useCrossVmTokenBalance} from "./useCrossVmTokenBalance" export {useFlowTransactionStatus} from "./useFlowTransactionStatus" +export {useCrossVmSpendNft} from "./useCrossVmSpendNft" diff --git a/packages/kit/src/hooks/useCrossVmBatchTransaction.test.ts b/packages/kit/src/hooks/useCrossVmBatchTransaction.test.ts index c9ec48eee..4b4e1fac3 100644 --- a/packages/kit/src/hooks/useCrossVmBatchTransaction.test.ts +++ b/packages/kit/src/hooks/useCrossVmBatchTransaction.test.ts @@ -56,12 +56,12 @@ describe("useBatchEvmTransaction", () => { const result = encodeCalls(mockCalls as any) expect(result).toEqual([ - [ - {key: "to", value: "0x123"}, - {key: "data", value: ""}, - {key: "gasLimit", value: "100000"}, - {key: "value", value: "0"}, - ], + { + to: "0x123", + data: "", + gasLimit: "100000", + value: "0", + }, ]) }) }) diff --git a/packages/kit/src/hooks/useCrossVmBatchTransaction.ts b/packages/kit/src/hooks/useCrossVmBatchTransaction.ts index 89219309d..6fdfed55e 100644 --- a/packages/kit/src/hooks/useCrossVmBatchTransaction.ts +++ b/packages/kit/src/hooks/useCrossVmBatchTransaction.ts @@ -8,8 +8,9 @@ import { } from "@tanstack/react-query" import {useFlowChainId} from "./useFlowChainId" import {useFlowQueryClient} from "../provider/FlowQueryClient" +import {DEFAULT_EVM_GAS_LIMIT} from "../constants" -interface useCrossVmBatchTransactionArgs { +export interface UseCrossVmBatchTransactionArgs { mutation?: Omit< UseMutationOptions< { @@ -26,7 +27,7 @@ interface useCrossVmBatchTransactionArgs { > } -interface useCrossVmBatchTransactionResult +export interface UseCrossVmBatchTransactionResult extends Omit< UseMutationResult< { @@ -57,7 +58,7 @@ interface useCrossVmBatchTransactionResult }> } -interface EvmBatchCall { +export interface EvmBatchCall { // The target EVM contract address (as a string) address: string // The contract ABI fragment @@ -71,13 +72,14 @@ interface EvmBatchCall { // The value to send with the call value?: bigint } -interface CallOutcome { + +export interface CallOutcome { status: "passed" | "failed" | "skipped" hash?: string errorMessage?: string } -type EvmTransactionExecutedData = { +export interface EvmTransactionExecutedData { hash: string[] index: string type: string @@ -93,11 +95,9 @@ type EvmTransactionExecutedData = { stateUpdateChecksum: string } -// Helper to encode our ca lls using viem. -// Returns an array of objects with keys "address" and "data" (hex-encoded string without the "0x" prefix). export function encodeCalls( calls: EvmBatchCall[] -): Array> { +): Array<{to: string; data: string; gasLimit: string; value: string}> { return calls.map(call => { const encodedData = encodeFunctionData({ abi: call.abi, @@ -105,13 +105,13 @@ export function encodeCalls( args: call.args, }) - return [ - {key: "to", value: call.address}, - {key: "data", value: fcl.sansPrefix(encodedData) ?? ""}, - {key: "gasLimit", value: call.gasLimit?.toString() ?? "15000000"}, - {key: "value", value: call.value?.toString() ?? "0"}, - ] - }) as any + return { + to: call.address, + data: fcl.sansPrefix(encodedData) ?? "", + gasLimit: call.gasLimit?.toString() ?? DEFAULT_EVM_GAS_LIMIT, + value: call.value?.toString() ?? "0", + } + }) } const EVM_CONTRACT_ADDRESSES = { @@ -178,7 +178,7 @@ transaction(calls: [{String: AnyStruct}], mustPass: Bool) { */ export function useCrossVmBatchTransaction({ mutation: mutationOptions = {}, -}: useCrossVmBatchTransactionArgs = {}): useCrossVmBatchTransactionResult { +}: UseCrossVmBatchTransactionArgs = {}): UseCrossVmBatchTransactionResult { const chainId = useFlowChainId() const cadenceTx = chainId.data ? getCadenceBatchTransaction(chainId.data) @@ -203,7 +203,15 @@ export function useCrossVmBatchTransaction({ cadence: cadenceTx, args: (arg, t) => [ arg( - encodedCalls, + encodedCalls.map(call => [ + {key: "to", value: call.to}, + {key: "data", value: call.data}, + { + key: "gasLimit", + value: call.gasLimit, + }, + {key: "value", value: call.value}, + ]), t.Array( t.Dictionary([ {key: t.String, value: t.String}, diff --git a/packages/kit/src/hooks/useCrossVmSpendNft.test.ts b/packages/kit/src/hooks/useCrossVmSpendNft.test.ts new file mode 100644 index 000000000..ea14a92b2 --- /dev/null +++ b/packages/kit/src/hooks/useCrossVmSpendNft.test.ts @@ -0,0 +1,171 @@ +import {renderHook, act, waitFor} from "@testing-library/react" +import * as fcl from "@onflow/fcl" +import {FlowProvider} from "../provider" +import { + getCrossVmSpendNftransaction, + useCrossVmSpendNft, +} from "./useCrossVmSpendNft" +import {useFlowChainId} from "./useFlowChainId" + +jest.mock("@onflow/fcl", () => require("../__mocks__/fcl").default) +jest.mock("viem", () => ({ + encodeFunctionData: jest.fn(), + bytesToHex: jest.fn(x => `0x${x}`), +})) +jest.mock("./useFlowChainId", () => ({ + useFlowChainId: jest.fn(), +})) + +describe("useBatchEvmTransaction", () => { + 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) + }) + + describe("getCrossVmSpendNftTransaction", () => { + it("should return correct cadence for mainnet", () => { + const result = getCrossVmSpendNftransaction("mainnet") + expect(result).toContain("import EVM from 0xe467b9dd11fa00df") + }) + + it("should return correct cadence for testnet", () => { + const result = getCrossVmSpendNftransaction("testnet") + expect(result).toContain("import EVM from 0x8c5303eaa26202d6") + }) + + it("should throw error for unsupported chain", () => { + expect(() => getCrossVmSpendNftransaction("unsupported")).toThrow( + "Unsupported chain: unsupported" + ) + }) + }) + + describe("useCrossVmBatchTransaction", () => { + test("should handle successful transaction", async () => { + jest.mocked(fcl.mutate).mockResolvedValue(mockTxId) + jest.mocked(fcl.tx).mockReturnValue({ + onceExecuted: jest.fn().mockResolvedValue(mockTxResult), + } as any) + + let result: any + let rerender: any + await act(async () => { + ;({result, rerender} = renderHook(useCrossVmSpendNft, { + wrapper: FlowProvider, + })) + }) + + await act(async () => { + await result.current.spendNft({ + 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 () => { + ;(useFlowChainId as jest.Mock).mockReturnValue({ + data: null, + isLoading: false, + }) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmSpendNft(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.spendNft({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(() => useCrossVmSpendNft(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.spendNft(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 () => { + ;(fcl.mutate as jest.Mock).mockRejectedValue(new Error("Mutation failed")) + + let hookResult: any + + await act(async () => { + const {result} = renderHook(() => useCrossVmSpendNft(), { + wrapper: FlowProvider, + }) + hookResult = result + }) + + await act(async () => { + await hookResult.current.spendNft({ + 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/kit/src/hooks/useCrossVmSpendNft.ts b/packages/kit/src/hooks/useCrossVmSpendNft.ts new file mode 100644 index 000000000..1e3b46f3f --- /dev/null +++ b/packages/kit/src/hooks/useCrossVmSpendNft.ts @@ -0,0 +1,281 @@ +import * as fcl from "@onflow/fcl" +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" + +export interface UseCrossVmSpendNftTxArgs { + mutation?: Omit< + UseMutationOptions, + "mutationFn" + > +} + +export interface UseCrossVmSpendNftTxMutateArgs { + nftIdentifier: string + nftIds: string[] + calls: EvmBatchCall[] +} + +export interface UseCrossVmSpendNftTxResult + extends Omit, "mutate" | "mutateAsync"> { + spendNft: UseMutateFunction + spendNftAsync: UseMutateAsyncFunction< + string, + Error, + UseCrossVmSpendNftTxMutateArgs + > +} + +// Takes a chain id and returns the cadence tx with addresses set +export const getCrossVmSpendNftransaction = (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 spend transaction. This function will + * bundle multiple EVM calls into one atomic Cadence transaction and return the transaction ID. + * + * Use `useCrossVmSpendNftStatus` 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 useCrossVmSpendNft({ + mutation: mutationOptions = {}, +}: UseCrossVmSpendNftTxArgs = {}): UseCrossVmSpendNftTxResult { + const chainId = useFlowChainId() + const cadenceTx = chainId.data + ? getCrossVmSpendNftransaction(chainId.data) + : null + + const queryClient = useFlowQueryClient() + const mutation = useMutation( + { + mutationFn: async ({ + nftIdentifier, + nftIds, + calls, + }: UseCrossVmSpendNftTxMutateArgs) => { + 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: spendNft, mutateAsync: spendNftAsync, ...rest} = mutation + + return { + spendNft, + spendNftAsync, + ...rest, + } +}