Skip to main content

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.

warning

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:

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:


_10
npm run dev

You'll see:

Hybrid App Demo

Connect with a Cadence-compatible wallet.

warning

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":""}]}

tip

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:


_38
import EVM from 0x8c5303eaa26202d6
_38
_38
transaction(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:


_38
const 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:


_10
const user = await fcl.currentUser().snapshot();
_10
return user.addr;

Will return your Cadence address. This snippet:


_10
const block = await fcl.block();
_10
return 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

info

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, andignition/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.


_10
import ClickToken from '../contracts/ClickTokenModule#ClickToken.json';
_10
import deployedAddresses from '../contracts/deployed_addresses.json';
_10
_10
export 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
_48
import { useAccount } from 'wagmi';
_48
import { clickToken } from '../constants/contracts';
_48
_48
interface theButtonProps {
_48
// eslint-disable-next-line
_48
writeContract: Function;
_48
awaitingResponse: boolean;
_48
setAwaitingResponse: (value: boolean) => void;
_48
}
_48
_48
export 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


_110
import { useAccount, useReadContract } from 'wagmi';
_110
import { clickToken } from '../constants/contracts';
_110
import { useEffect, useState } from 'react';
_110
import { useQueryClient } from '@tanstack/react-query';
_110
import { formatUnits } from 'viem';
_110
_110
type scoreBoardEntry = {
_110
user: string;
_110
value: bigint;
_110
};
_110
_110
interface TopTenDisplayProps {
_110
reloadScores: boolean;
_110
setReloadScores: (value: boolean) => void;
_110
}
_110
_110
export 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
_61
import { useEffect, useState } from 'react';
_61
import TopTenDisplay from './TopTenDisplay';
_61
import {
_61
useWaitForTransactionReceipt,
_61
useWriteContract,
_61
useAccount,
_61
} from 'wagmi';
_61
import TheButton from './TheButton';
_61
_61
export 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:


_15
return (
_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!

scores

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.


_10
const 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:


_10
const 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: