Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/chilly-kangaroos-train.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@onflow/react-sdk": minor
"@onflow/demo": minor
---

Added crossvm hook to transfer tokens from evm to cadence
147 changes: 147 additions & 0 deletions packages/demo/src/components/cards/cross-vm-receive-token-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {useCrossVmReceiveToken, useFlowConfig} from "@onflow/react-sdk"
import {useState, useMemo} from "react"
import {getContractAddress} from "../../constants"

export function CrossVmReceiveTokenCard() {
const config = useFlowConfig()
const currentNetwork = config.flowNetwork || "emulator"
const [vaultIdentifier, setVaultIdentifier] = useState("")
const [amount, setAmount] = useState("1000000000000000000") // 1 token (18 decimals for EVM)

const {
receiveToken,
isPending,
data: transactionId,
error,
} = useCrossVmReceiveToken()

const isNetworkSupported = currentNetwork === "testnet"

const clickTokenData = useMemo(() => {
if (currentNetwork !== "testnet") return null

const clickTokenAddress = getContractAddress("ClickToken", currentNetwork)
return {
name: "ClickToken",
vaultIdentifier: `A.${clickTokenAddress.replace("0x", "")}.EVMVMBridgedToken_a7cf2260e501952c71189d04fad17c704dfb36e6.Vault`,
Copy link

Copilot AI Aug 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hard-coded token identifier EVMVMBridgedToken_a7cf2260e501952c71189d04fad17c704dfb36e6 contains what appears to be a magic value. Consider extracting this to a constant or making it configurable to improve maintainability.

Suggested change
vaultIdentifier: `A.${clickTokenAddress.replace("0x", "")}.EVMVMBridgedToken_a7cf2260e501952c71189d04fad17c704dfb36e6.Vault`,
vaultIdentifier: `A.${clickTokenAddress.replace("0x", "")}.${EVM_VM_BRIDGED_TOKEN_IDENTIFIER}.Vault`,

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, but hardcoded just for the sake of running the demo

amount: "1000000000000000000", // 1 ClickToken (18 decimals for EVM)
}
}, [currentNetwork])

// Set default vault identifier when network changes
useMemo(() => {
if (clickTokenData && !vaultIdentifier) {
setVaultIdentifier(clickTokenData.vaultIdentifier)
}
}, [clickTokenData, vaultIdentifier])

const handleReceiveToken = () => {
receiveToken({
vaultIdentifier,
amount,
})
}

if (!isNetworkSupported) {
return (
<div className="p-4 border border-gray-200 rounded-xl bg-white mb-8">
<h2 className="text-black mt-0 mb-6 text-xl font-bold">
useCrossVmReceiveToken
</h2>
<div className="p-4 bg-yellow-100 border border-yellow-200 rounded text-yellow-800">
<p className="m-0">
<strong>Network not supported:</strong> This feature is only
available on testnet.
</p>
</div>
</div>
)
}

return (
<div className="p-4 border border-gray-200 rounded-xl bg-white mb-8">
<h2 className="text-black mt-0 mb-6 text-xl font-bold">
useCrossVmReceiveToken
</h2>
<div className="mb-6">
<label className="block mb-2 text-black">
<strong>Note:</strong> Prefilled with ClickToken (ERC20) vault
identifier
</label>
<label className="block mb-2 text-black font-medium">
Vault Identifier:
</label>
<input
type="text"
value={vaultIdentifier}
onChange={e => setVaultIdentifier(e.target.value)}
placeholder={
clickTokenData
? clickTokenData.vaultIdentifier
: "e.g., A.dfc20aee650fcbdf.EVMVMBridgedToken_a7cf2260e501952c71189d04fad17c704dfb36e6.Vault"
}
className="p-3 border-2 border-[#00EF8B] rounded-md text-sm text-black bg-white
outline-none transition-colors duration-200 ease-in-out w-full mb-4 font-mono"
/>

<label className="block mb-2 text-black font-medium">
Amount (Wei/UInt256):
</label>
<input
type="text"
value={amount}
onChange={e => setAmount(e.target.value)}
placeholder="e.g., 100000000000000000 (0.1 token with 18 decimals)"
className="p-3 border-2 border-[#00EF8B] rounded-md text-sm text-black bg-white
outline-none transition-colors duration-200 ease-in-out w-full mb-4 font-mono"
/>

<button
onClick={handleReceiveToken}
className={`py-3 px-6 text-base font-semibold rounded-md transition-all duration-200
ease-in-out mr-4 ${
isPending || !vaultIdentifier || !amount
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-[#00EF8B] text-black cursor-pointer"
}`}
disabled={isPending || !vaultIdentifier || !amount}
>
{isPending ? "Receiving..." : "Receive Token"}
</button>
</div>

<div className="p-4 bg-[#f8f9fa] rounded-md border border-[#00EF8B]">
<h4 className="text-black m-0 mb-4">Transaction Status:</h4>

{isPending && (
<p className="text-gray-500 m-0">Receiving tokens from EVM...</p>
)}

{error && (
<div className="p-4 bg-red-100 border border-red-200 rounded text-red-800 m-0">
<strong>Error:</strong> {error.message}
</div>
)}

{transactionId && !isPending && !error && (
<div className="p-4 bg-green-100 border border-green-200 rounded m-0">
<p className="text-green-800 m-0 mb-2">
<strong>Tokens received successfully!</strong>
</p>
<p className="text-green-800 m-0 font-mono">
<strong>Transaction ID:</strong> {transactionId}
</p>
</div>
)}

{!transactionId && !isPending && !error && (
<div className="text-gray-500 m-0">
<p className="mb-2">
Click "Receive Token" to bridge tokens from EVM to Cadence
</p>
</div>
)}
</div>
</div>
)
}
4 changes: 4 additions & 0 deletions packages/demo/src/components/container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {FlowQueryCard} from "./cards/flow-query-card"
import {FlowQueryRawCard} from "./cards/flow-query-raw-card"
import {FlowRevertibleRandomCard} from "./cards/flow-revertible-random-card"
import {FlowTransactionStatusCard} from "./cards/flow-transaction-status-card"
import {CrossVmReceiveTokenCard} from "./cards/cross-vm-receive-token-card"
import {KitConnectCard} from "./kits/kit-connect-card"
import {KitTransactionButtonCard} from "./kits/kit-transaction-button-card"
import {KitTransactionDialogCard} from "./kits/kit-transaction-dialog-card"
Expand Down Expand Up @@ -57,6 +58,9 @@ export function Container() {
<div id="flow-transaction-status" className="scroll-mt-4">
<FlowTransactionStatusCard />
</div>
<div id="cross-vm-receive-token" className="scroll-mt-4">
<CrossVmReceiveTokenCard />
</div>
</div>
</section>

Expand Down
3 changes: 3 additions & 0 deletions packages/demo/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export const CONTRACT_ADDRESSES: Record<string, Record<any, string>> = {
testnet: "0x7e60df042a9c0868",
mainnet: "0x1654653399040a61",
},
ClickToken: {
testnet: "0xdfc20aee650fcbdf",
},
}

// Helper function to get contract address for current network
Expand Down
1 change: 1 addition & 0 deletions packages/react-sdk/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ export {useFlowTransaction} from "./useFlowTransaction"
export {useFlowTransactionStatus} from "./useFlowTransactionStatus"
export {useCrossVmSpendNft} from "./useCrossVmSpendNft"
export {useCrossVmSpendToken} from "./useCrossVmSpendToken"
export {useCrossVmReceiveToken} from "./useCrossVmReceiveToken"
export {useCrossVmTransactionStatus} from "./useCrossVmTransactionStatus"
169 changes: 169 additions & 0 deletions packages/react-sdk/src/hooks/useCrossVmReceiveToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import {renderHook, act, waitFor} from "@testing-library/react"
import * as fcl from "@onflow/fcl"
import {FlowProvider} from "../provider"
import {
getCrossVmReceiveTokenTransaction,
useCrossVmReceiveToken,
} from "./useCrossVmReceiveToken"
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(),
}))
jest.mock("./useFlowClient", () => ({
useFlowClient: jest.fn(),
}))

describe("useCrossVmReceiveToken", () => {
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("getCrossVmReceiveTokenTransaction", () => {
it("should return correct cadence for mainnet", () => {
const result = getCrossVmReceiveTokenTransaction("mainnet")
expect(result).toContain("import EVM from 0xe467b9dd11fa00df")
})

it("should return correct cadence for testnet", () => {
const result = getCrossVmReceiveTokenTransaction("testnet")
expect(result).toContain("import EVM from 0x8c5303eaa26202d6")
})

it("should throw error for unsupported chain", () => {
expect(() => getCrossVmReceiveTokenTransaction("unsupported")).toThrow(
"Unsupported chain: unsupported"
)
})
})

describe("useCrossVmReceiveToken", () => {
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(useCrossVmReceiveToken, {
wrapper: FlowProvider,
}))
})

await act(async () => {
await result.current.receiveToken({
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(() => useCrossVmReceiveToken(), {
wrapper: FlowProvider,
})
hookResult = result
})

await act(async () => {
await hookResult.current.receiveToken({
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(() => useCrossVmReceiveToken(), {
wrapper: FlowProvider,
})
hookResult = result
})

await act(async () => {
await hookResult.current.receiveToken({
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 () => {
;(fcl.mutate as jest.Mock).mockRejectedValue(new Error("Mutation failed"))

let hookResult: any

await act(async () => {
const {result} = renderHook(() => useCrossVmReceiveToken(), {
wrapper: FlowProvider,
})
hookResult = result
})

await act(async () => {
await hookResult.current.receiveToken({
vaultIdentifier: "A.dfc20aee650fcbdf.ClickToken.Vault",
amount: "1000000000000000000",
})
})

await waitFor(() => expect(hookResult.current.isError).toBe(true))
expect(hookResult.current.error?.message).toBe("Mutation failed")
})
})
})
Loading