Introduction
Ever since the launch of Flow EVM, it's been possible to supercharge your EVM apps by using Flow Cadence features and contracts. Some benefits, such as native VRF and inexpensive gas without compromising security are built in and either easy or automatic to use. Others, such as the ability to use Cadence to structure and call EVM transactions, are powerful but complicated to configure and use. They also require developers to manage concurrent connections to both networks.
FLIP 316 improves the Flow Client Library (FCL) to support cross-VM functionality between Flow EVM and Flow Cadence.
For EVM developers, this means that you can use the familiar wagmi, viem, and rainbowkit stack you're used to, add FCL, and get features like multi-call write with one signature for users with a Cadence-compatible wallet.
In this tutorial, you'll learn how to create a hybrid application and use some basic cross-VM features.
The FCL functionality described in this tutorial is in alpha. Some steps may change. We'll keep the tutorial updated, but please create an issue or let us know on Discord if something isn't working for you.
Objectives
After completing this guide, you'll be able to:
- Build an app that seamlessly integrates Flow Cadence and Flow EVM connections
- Add Cadence features to your Rainbowkit/wagmi/viem app
- Utilize Flow Client Library (FCL) to enable multi-call contract writes to Flow EVM
Prerequisites
Next.js and Modern Frontend Development
This tutorial uses Next.js. You don't need to be an expert, but it's helpful to be comfortable with development using a current React framework. You'll be on your own to select and use a package manager, manage Node versions, and other frontend environment tasks. If you don't have your own preference, you can just follow along with us and use npm.
Solidity and Cadence Smart Contract Development
Apps using the hybrid approach can interact with both Cadence and Solidity smart contracts. You don't need to be an expert in either of these, but it's helpful to be familiar with how smart contracts work in at least one of these languages.
Onchain App Frontends
We're assuming you're familiar with wagmi, viem, and rainbowkit. If you're coming from the Cadence, you might want to take a quick look at the getting started guides for these platforms. They're all excellent and will rapidly get you up to speed on how the EVM world commonly connects their apps to their contracts.
Getting Started
For this tutorial, we'll be starting from a fork of the FCL + RainbowKit + Wagmi Integration Demo built by the team.
Fork the repo so you can push your work freely to your own copy, then follow the setup instructions.
Project Overview
Open the cross-vm app scaffold in your editor, run it, and view the site in your browser:
_10npm run dev
You'll see:
Connect with a Cadence-compatible wallet.
In a production app, you'll want to manage this process carefully. Non-Cadence EVM wallets may be able to connect, but they will not be able to use any Cadence features.
Send Batch Transactions
The first demo built into this scaffold is multi-call contract write.
On Flow, this isn't an unstable experimental feature - it's a demonstration of the power of EVM + Cadence.
Click Send Batch Transaction Example
and approve the transaction. You'll see three lines appear on the page, similar to:
_10{"isPending":false,"isError":false,"txId":"b3c2b8c86e68177af04324152d45d9de9c2a118ff8f090476b3a07e0c9554912","results":[{"hash":"0x46e923a08d9008632e3782ea512c4c590d4650ba58b3e8b49628f58e6adddaa9","status":"passed","errorMessage":""},{"hash":"0x52c82dc689cd5909519f8a90d0a1ec2e74192d7603fd3b5d33f7f4d54a618a84","status":"passed","errorMessage":""}]}
Currently, the Flow wallet sponsors all gas for all transactions signed with the wallet on both testnet and mainnet!
Cadence Parent Transaction
The first line is the transaction id of the Flow Cadence transaction that calls both of the EVM transactions. Search for it in Testnet Cadence Flowscan.
Cadence transactions are more complicated than those in Solidity contracts. Rather than being restricted to running functions present on the contract, they can run arbitrary code as long as the caller has access to all of the resources required by the transaction.
You can see the code of the transaction in the Script
tab, but we've included it here for convenience:
_38import EVM from 0x8c5303eaa26202d6_38_38transaction(calls: [{String: AnyStruct}], mustPass: Bool) {_38_38 let coa: auth(EVM.Call) &EVM.CadenceOwnedAccount_38_38 // Borrow a reference to the EVM account that has the ability to sign transactions_38 prepare(signer: auth(BorrowValue) & Account) {_38 let storagePath = /storage/evm_38 self.coa = signer.storage.borrow<auth(EVM.Call) &EVM.CadenceOwnedAccount>(from: storagePath)_38 ?? panic("No CadenceOwnedAccount (COA) found at ".concat(storagePath.toString()))_38 }_38_38 // Iterate through the list of provided EVM transactions_38 execute {_38 for i, call in calls {_38 let to = call["to"] as! String_38 let data = call["data"] as! String_38 let gasLimit = call["gasLimit"] as! UInt64_38 let value = call["value"] as! UInt_38_38 let result = self.coa.call(_38 to: EVM.addressFromString(to),_38 data: data.decodeHex(),_38 gasLimit: gasLimit,_38 value: EVM.Balance(attoflow: value)_38 )_38_38 if mustPass {_38 assert(_38 result.status == EVM.Status.successful,_38 message: "Call index ".concat(i.toString()).concat(" to ").concat(to)_38 .concat(" with calldata ").concat(data).concat(" failed: ")_38 .concat(result.errorMessage)_38 )_38 }_38 }_38 }
In this case, it's checking that the caller of the Cadence transaction has permission to control to the EVM account, which is built in for Cadence Owned Accounts. The execute
phase then iterates through the EVM transactions and uses the Cadence accounts own permissions to sign the EVM transactions.
The loop also handles a check for the optional flag to cancel all of the transactions if any one of them fails. In other words, you could set up a 20 transaction arbitrage attempt and unwind everything if it fails at any step!
EVM Child Transactions
The next two lines show the transaction hashes for the EVM transactions. You can view this in Testnet EVM Flowscan by searching for the transaction hashes, the same as any other.
Look up both transactions.
The first is calling the deposit()
function to wrap FLOW and move it to EVM.
The second is calling the ERC-20 approve()
function to give another address the authority to spend those tokens.
For the demo, the code for this is hard-coded into src/app/page.tsx
:
_38const calls: EVMBatchCall[] = [_38 {_38 // Call deposit() function (wrap FLOW) on the token contract._38 address: '0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e', // Replace with your actual token contract address._38 abi: [_38 {_38 inputs: [],_38 name: 'deposit',_38 outputs: [],_38 stateMutability: 'payable',_38 type: 'function',_38 },_38 ],_38 functionName: 'deposit',_38 args: [], // deposit takes no arguments; value is passed with the call._38 },_38 {_38 // Call approve() function (ERC20 style) on the same token contract._38 address: '0xd3bF53DAC106A0290B0483EcBC89d40FcC961f3e', // Replace with your actual token contract address if needed._38 abi: [_38 {_38 inputs: [_38 { name: 'spender', type: 'address' },_38 { name: 'value', type: 'uint256' },_38 ],_38 name: 'approve',_38 outputs: [{ name: '', type: 'bool' }],_38 stateMutability: 'nonpayable',_38 type: 'function',_38 },_38 ],_38 functionName: 'approve',_38 args: [_38 '0x2E2Ed0Cfd3AD2f1d34481277b3204d807Ca2F8c2', // Spender address._38 BigInt('1000000000000000000'), // Approve 1 token (assuming 18 decimals)._38 ],_38 },_38];
It's called with the useBatchTransaction
hook via the sendBatchTransaction(calls)
function.
Code Evaluator
The demo also has an embedded code evaluator that you can use to experiment with snippets of code from fcl
or wagmi
.
For example:
_10const user = await fcl.currentUser().snapshot();_10return user.addr;
Will return your Cadence address. This snippet:
_10const block = await fcl.block();_10return block.height;
Returns the current Cadence VM block number.
Calling Your Own Contract
Next, we'll update the starter to connect to and call functions in our own contract. For this, we'll use a simple Button Clicker Contract. You can deploy your own copy, or use the one deployed at 0xA7Cf2260e501952c71189D04FAd17c704DFB36e6
.
Set Up Contract Imports
The following steps assume deployment with Hardhat Ignition. If you are using a different deployment method, import the contract address and abi as appropriate.
In your fork of the app, add a folder called contracts
to the src
folder. In it, copy over ['deployed_addresses.json] from
ignition/deployments/chain-545in the Button Clicker repo, and
ignition/deployments/chain-545/ClickTokenModule#ClickToken.json`.
Next, create a folder called constants
and add a file called contracts.ts
to it.
In it, import the contract artifact and addresses file, and create export a constant with this information.
_10import ClickToken from '../contracts/ClickTokenModule#ClickToken.json';_10import deployedAddresses from '../contracts/deployed_addresses.json';_10_10export const clickToken = {_10 abi: ClickToken.abi,_10 address: deployedAddresses['ClickTokenModule#ClickToken'] as `0x${string}`_10};
Build Traditional Functionality
This isn't a wagmi tutorial, so we'll give you some components to speed up the process. Add a folder called components
inside src
and add the following files.
TheButton.tsx
_48'use client';_48_48import { useAccount } from 'wagmi';_48import { clickToken } from '../constants/contracts';_48_48interface theButtonProps {_48 // eslint-disable-next-line_48 writeContract: Function;_48 awaitingResponse: boolean;_48 setAwaitingResponse: (value: boolean) => void;_48}_48_48export default function TheButton({_48 writeContract,_48 awaitingResponse,_48 setAwaitingResponse,_48}: theButtonProps) {_48 const account = useAccount();_48_48 function handleClick() {_48 setAwaitingResponse(true);_48 writeContract({_48 abi: clickToken.abi,_48 address: clickToken.address,_48 functionName: 'mintTo',_48 args: [account.address],_48 gas: 45000,_48 });_48 }_48_48 return (_48 <>_48 {!awaitingResponse && (_48 <button_48 onClick={handleClick}_48 className="w-full py-4 px-8 text-2xl font-bold text-white bg-green-500 hover:bg-green-600 rounded-lg shadow-lg transition-transform transform active:scale-95"_48 >_48 Click Me!!!_48 </button>_48 )}_48 {awaitingResponse && (_48 <button className="disabled w-full py-4 px-8 text-2xl font-bold text-white bg-gray-500 rounded-lg shadow-lg">_48 Please Wait..._48 </button>_48 )}_48 </>_48 );_48}
TopTenDisplay.tsx
_110import { useAccount, useReadContract } from 'wagmi';_110import { clickToken } from '../constants/contracts';_110import { useEffect, useState } from 'react';_110import { useQueryClient } from '@tanstack/react-query';_110import { formatUnits } from 'viem';_110_110type scoreBoardEntry = {_110 user: string;_110 value: bigint;_110};_110_110interface TopTenDisplayProps {_110 reloadScores: boolean;_110 setReloadScores: (value: boolean) => void;_110}_110_110export default function TopTenDisplay({_110 reloadScores,_110 setReloadScores,_110}: TopTenDisplayProps) {_110 const [scores, setScores] = useState<scoreBoardEntry[]>([]);_110_110 const account = useAccount();_110 const queryClient = useQueryClient();_110_110 const { data: scoresData, queryKey: getAllScoresQueryKey } = useReadContract({_110 abi: clickToken.abi,_110 address: clickToken.address as `0x${string}`,_110 functionName: 'getAllScores',_110 });_110_110 useEffect(() => {_110 if (scoresData) {_110 const sortedScores = scoresData as scoreBoardEntry[];_110 // Sort scores in descending order_110 sortedScores.sort((a, b) => Number(b.value) - Number(a.value));_110_110 setScores(sortedScores);_110 }_110 }, [scoresData]);_110_110 useEffect(() => {_110 if (reloadScores) {_110 console.log('Reloading scores...');_110 queryClient.invalidateQueries({ queryKey: getAllScoresQueryKey });_110 setReloadScores(false);_110 }_110 }, [reloadScores]);_110_110 function renderAddress(address: string) {_110 return address?.slice(0, 5) + '...' + address?.slice(-3);_110 }_110_110 function renderTopTen() {_110 if (scores.length === 0 || !account) {_110 return (_110 <ol>_110 <li>Loading...</li>_110 </ol>_110 );_110 }_110 // Only display the top 10 scores. If the user is in the top 10, bold the item with their score. If not, show it at the bottom with their ranking number_110 const topTen = scores.length > 10 ? scores.slice(0, 10) : scores;_110 // myRank is my address's position in the array of scores, +1. If it's not present, my rank is the length of the array_110 const myRank =_110 scores.findIndex((entry) => entry.user === account?.address) + 1 ||_110 scores.length + 1;_110_110 const topTenList = topTen.map((entry, index) => {_110 return (_110 <li key={entry.user + index + 1}>_110 {entry.user === account.address ? (_110 <strong>_110 {index + 1} -- {renderAddress(entry.user)} --{' '}_110 {formatUnits(entry.value, 18)}_110 </strong>_110 ) : (_110 <>_110 {index + 1} -- {renderAddress(entry.user)} --{' '}_110 {formatUnits(entry.value, 18)}_110 </>_110 )}_110 </li>_110 );_110 });_110_110 // Append my score if myRank is > 10_110 if (account?.address && (myRank > 10 || myRank > scores.length)) {_110 topTenList.push(_110 <li key={myRank}>_110 <strong>_110 {myRank} -- {renderAddress(account.address.toString())} --{' '}_110 {myRank > scores.length_110 ? 0_110 : formatUnits(scores[myRank - 1].value, 18)}_110 </strong>_110 </li>,_110 );_110 }_110_110 return <ol>{topTenList}</ol>;_110 }_110_110 return (_110 <div>_110 <h3>Top 10 Scores</h3>_110 {renderTopTen()}_110 </div>_110 );_110}
Content.tsx
_61'use client';_61_61import { useEffect, useState } from 'react';_61import TopTenDisplay from './TopTenDisplay';_61import {_61 useWaitForTransactionReceipt,_61 useWriteContract,_61 useAccount,_61} from 'wagmi';_61import TheButton from './TheButton';_61_61export default function Content() {_61 const [reload, setReload] = useState(false);_61 const [awaitingResponse, setAwaitingResponse] = useState(false);_61_61 const account = useAccount();_61_61 const { data, writeContract, error: writeError } = useWriteContract();_61_61 const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({_61 hash: data,_61 });_61_61 useEffect(() => {_61 if (receipt) {_61 console.log('Transaction receipt:', receipt);_61 setReload(true);_61 setAwaitingResponse(false);_61 }_61 }, [receipt]);_61_61 useEffect(() => {_61 if (writeError) {_61 console.error(writeError);_61 setAwaitingResponse(false);_61 }_61 }, [writeError]);_61_61 useEffect(() => {_61 if (receiptError) {_61 console.error(receiptError);_61 setAwaitingResponse(false);_61 }_61 }, [receiptError]);_61_61 return (_61 <div className="card gap-1">_61 {account.address && (_61 <div className="mb-4">_61 <TheButton_61 writeContract={writeContract}_61 awaitingResponse={awaitingResponse}_61 setAwaitingResponse={setAwaitingResponse}_61 />_61 </div>_61 )}_61 <br />_61 {<TopTenDisplay reloadScores={reload} setReloadScores={setReload} />}_61 </div>_61 );_61}
Then, import and add <Content />
to page.tsx
:
_15return (_15 <>_15 <div style={{ display: 'flex', justifyContent: 'flex-end', padding: 12 }}>_15 <ConnectButton />_15 </div>_15 <h3>Flow Address: {flowAddress}</h3>_15 <h3>EVM Address: {coa?.address}</h3>_15 <br />_15 <button onClick={() => sendBatchTransaction(calls)}>_15 Send Batch Transaction Example_15 </button>_15 {<p>{JSON.stringify({ isPending, isError, txId, results })}</p>}_15 <Content />_15 </>_15);
You'll now see the button and scoreboard from the contract. Test it out and earn a few points!
Supercharge your EVM App With Cadence
Now let's supercharge it. With the power of Cadence, you can use multi-call write and give your users way more tokens with a single click and single signature!
For the first pass, we'll skip some organization best practices.
Import clickToken
into page.tsx
and update calls
to instead call the mint
function from the Button Clicker contract.
_10const calls: EVMBatchCall[] = [_10 {_10 address: clickToken.address,_10 abi: clickToken.abi as Abi,_10 functionName: 'mintTo',_10 args: [coa?.address],_10 },_10];
Try clicking the Send Batch Transaction Example
button again. You'll have to manually refresh the page when the EVM transaction hash appears to see the score update. We haven't wired in the query invalidation yet.
Next, use some JavaScript to put 10 copies of the transaction call into the array:
_10const calls: EVMBatchCall[] = Array.from({ length: 10 }, () => ({_10 address: clickToken.address,_10 abi: clickToken.abi as Abi,_10 functionName: 'mintTo',_10 args: [coa?.address],_10}));
Click the button again and manually refresh page once the transaction hashes appear.
You just minted 10 tokens from 10 transactions with one signature!
Conclusion
In this tutorial, you reviewed the demo starter for building hybrid applications that utilize a common EVM stack and integrate with Flow Cadence. You then added functionality to interface with another contract that mints ERC-20 tokens. Finally, you supercharged your app by using the power of Cadence for EVM multi-call contract writes.
Now that you have completed the tutorial, you should be able to:
- Build an app that seamlessly integrates Flow Cadence and Flow EVM connections
- Add Cadence features to your Rainbowkit/wagmi/viem app
- Utilize Flow Client Library (FCL) to enable multi-call contract writes to Flow EVM