Skip to main content

Cross-VM Bridge

Flow provides the Cross-VM Bridge which enables the movement of fungible and non-fungible tokens between Cadence & EVM. The Cross-VM Bridge is a contract-based protocol enabling the automated and atomic bridging of tokens from Cadence into EVM with their corresponding ERC-20 and ERC-721 token types. In the opposite direction, it supports bridging of arbitrary ERC-20 and ERC-721 tokens from EVM to Cadence as their corresponding FT or NFT token types.

The Cross-VM Bridge internalizes the capabilities to deploy new token contracts in either VM state as needed, resolving access to, and maintaining links between associated contracts. It additionally automates account and contract calls to enforce source VM asset burn or lock, and target VM token mint or unlock.

Developers wishing to use the Cross-VM Bridge will be required to use a Cadence transaction. Cross-VM bridging functionality is not currently available natively in EVM on Flow. By extension, this means that the EVM account bridging from EVM to Cadence must be a CadenceOwnedAccount (COA) as this is the only EVM account type that can be controlled from the Cadence runtime.

This FLIP outlines the architecture and implementation of the VM bridge. This document will focus on how to use the Cross-VM Bridge and considerations for fungible and non-fungible token projects deploying to either Cadence or EVM.

Deployments

The core bridge contracts can be found at the following addresses:

ContractsTestnetMainnet
All Cadence Bridge contracts0xdfc20aee650fcbdf0x1e4aa0b87d10b141
FlowEVMBridgeFactory.sol0xf8146b4aef631853f0eb98dbe28706d029e52c520x1c6dea788ee774cf15bcd3d7a07ede892ef0be40
FlowEVMBridgeDeploymentRegistry.sol0x8781d15904d7e161f421400571dea24cc0db69380x8fdec2058535a2cb25c2f8cec65e8e0d0691f7b0
FlowEVMBridgedERC20Deployer.sol0x4d45CaD104A71D19991DE3489ddC5C7B284cf2630x49631Eac7e67c417D036a4d114AD9359c93491e7
FlowEVMBridgedERC721Deployer.sol0x1B852d242F9c4C4E9Bb91115276f659D1D1f7c560xe7c2B80a9de81340AE375B3a53940E9aeEAd79Df

And below are the bridge escrow's EVM addresses. These addresses are COAs and are stored stored in the same Flow account as you'll find the Cadence contracts (see above).

NetworkAddress
Testnet0x0000000000000000000000023f946ffbc8829bfd
Mainnet0x00000000000000000000000249250a5c27ecab3b

Interacting With the Bridge

info

All bridging activity in either direction is orchestrated via Cadence on COA EVM accounts. This means that all bridging activity must be initiated via a Cadence transaction, not an EVM transaction regardless of the directionality of the bridge request. For more information on the interplay between Cadence and EVM, see How EVM on Flow Works.

Overview

The Flow EVM bridge allows both fungible and non-fungible tokens to move atomically between Cadence and EVM. In the context of EVM, fungible tokens are defined as ERC20 tokens, and non-fungible tokens as ERC721 tokens. In Cadence, fungible tokens are defined by contracts implementing the FungibleToken interface and non-fungible tokens implement the NonFungibleToken interface.

Like all operations on Flow, there are native fees associated with both computation and storage. To prevent spam and sustain the bridge account's storage consumption, fees are charged for both onboarding assets and bridging assets. In the case where storage consumption is expected, fees are charged based on the storage consumed at the current network storage rate.

Onboarding

Since a contract must define the asset in the target VM, an asset must be "onboarded" to the bridge before requests can be fulfilled.

Moving from Cadence to EVM, onboarding can occur on the fly, deploying a template contract in the same transaction as the asset is bridged to EVM if the transaction so specifies.

Moving from EVM to Cadence, however, requires that onboarding occur in a separate transaction due to the fact that a Cadence contract is initialized at the end of a transaction and isn't available in the runtime until after the transaction has executed.

Below are transactions relevant to onboarding assets:

onboard_by_type.cdc

_58
// source: https://www.github.com/onflow/flow-evm-bridge/blob/main/cadence/transactions/bridge/onboarding/onboard_by_type.cdc
_58
_58
import "FungibleToken"
_58
import "FlowToken"
_58
_58
import "ScopedFTProviders"
_58
_58
import "EVM"
_58
_58
import "FlowEVMBridge"
_58
import "FlowEVMBridgeConfig"
_58
_58
/// This transaction onboards the asset type to the bridge, configuring the bridge to move assets between environments
_58
/// NOTE: This must be done before bridging a Cadence-native asset to EVM
_58
///
_58
/// @param type: The Cadence type of the bridgeable asset to onboard to the bridge
_58
///
_58
transaction(type: Type) {
_58
_58
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_58
_58
prepare(signer: auth(CopyValue, BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue) &Account) {
_58
_58
/* --- Configure a ScopedFTProvider --- */
_58
//
_58
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_58
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_58
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_58
/storage/flowTokenVault
_58
)
_58
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_58
}
_58
// Copy the stored Provider capability and create a ScopedFTProvider
_58
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_58
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_58
) ?? panic("Invalid Provider Capability found in storage.")
_58
let providerFilter = ScopedFTProviders.AllowanceFilter(FlowEVMBridgeConfig.onboardFee)
_58
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_58
provider: providerCapCopy,
_58
filters: [ providerFilter ],
_58
expiration: getCurrentBlock().timestamp + 1.0
_58
)
_58
}
_58
_58
execute {
_58
// Onboard the asset Type
_58
FlowEVMBridge.onboardByType(
_58
type,
_58
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_58
)
_58
destroy self.scopedProvider
_58
}
_58
_58
post {
_58
FlowEVMBridge.typeRequiresOnboarding(type) == false:
_58
"Asset ".concat(type.identifier).concat(" was not onboarded to the bridge.")
_58
}
_58
}

onboard_by_evm_address.cdc

_57
// source: https://www.github.com/onflow/flow-evm-bridge/blob/main/cadence/transactions/bridge/onboarding/onboard_by_evm_address.cdc
_57
_57
import "FungibleToken"
_57
import "FlowToken"
_57
_57
import "ScopedFTProviders"
_57
_57
import "EVM"
_57
_57
import "FlowEVMBridge"
_57
import "FlowEVMBridgeConfig"
_57
_57
/// This transaction onboards the NFT type to the bridge, configuring the bridge to move NFTs between environments
_57
/// NOTE: This must be done before bridging a Cadence-native NFT to EVM
_57
///
_57
/// @param contractAddressHex: The EVM address of the contract defining the bridgeable asset to be onboarded
_57
///
_57
transaction(contractAddressHex: String) {
_57
_57
let contractAddress: EVM.EVMAddress
_57
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_57
_57
prepare(signer: auth(CopyValue, BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue) &Account) {
_57
/* --- Construct EVMAddress from hex string (no leading `"0x"`) --- */
_57
//
_57
self.contractAddress = EVM.addressFromString(contractAddressHex)
_57
_57
/* --- Configure a ScopedFTProvider --- */
_57
//
_57
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_57
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_57
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_57
/storage/flowTokenVault
_57
)
_57
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_57
}
_57
// Copy the stored Provider capability and create a ScopedFTProvider
_57
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_57
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_57
) ?? panic("Invalid Provider Capability found in storage.")
_57
let providerFilter = ScopedFTProviders.AllowanceFilter(FlowEVMBridgeConfig.onboardFee)
_57
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_57
provider: providerCapCopy,
_57
filters: [ providerFilter ],
_57
expiration: getCurrentBlock().timestamp + 1.0
_57
)
_57
}
_57
_57
execute {
_57
// Onboard the EVM contract
_57
FlowEVMBridge.onboardByEVMAddress(
_57
self.contractAddress,
_57
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_57
)
_57
destroy self.scopedProvider
_57
}
_57
}

Bridging

Once an asset has been onboarded, either by its Cadence type or EVM contract address, it can be bridged in either direction, referred to by its Cadence type. For Cadence-native assets, this is simply its native type. For EVM-native assets, this is in most cases a templated Cadence contract deployed to the bridge account, the name of which is derived from the EVM contract address. For instance, an ERC721 contract at address 0x1234 would be onboarded to the bridge as EVMVMBridgedNFT_0x1234, making its type identifier A.<BRIDGE_ADDRESS>.EVMVMBridgedNFT_0x1234.NFT.

To get the type identifier for a given NFT, you can use the following code:


_10
// Where `nft` is either a @{NonFungibleToken.NFT} or &{NonFungibleToken.NFT}
_10
nft.getType().identifier

You may also retrieve the type associated with a given EVM contract address using the following script:

get_associated_type.cdc

_18
// source: https://github.com/onflow/flow-evm-bridge/blob/main/cadence/scripts/bridge/get_associated_type.cdc
_18
_18
import "EVM"
_18
_18
import "FlowEVMBridgeConfig"
_18
_18
/// Returns the Cadence Type associated with the given EVM address (as its hex String)
_18
///
_18
/// @param evmAddressHex: The hex-encoded address of the EVM contract as a String
_18
///
_18
/// @return The Cadence Type associated with the EVM address or nil if the address is not onboarded. `nil` may also be
_18
/// returned if the address is not a valid EVM address.
_18
///
_18
access(all)
_18
fun main(addressHex: String): Type? {
_18
let address = EVM.addressFromString(addressHex)
_18
return FlowEVMBridgeConfig.getTypeAssociated(with: address)
_18
}

Alternatively, given some onboarded Cadence type, you can retrieve the associated EVM address using the following script:

get_associated_address.cdc

_21
// source: https://github.com/onflow/flow-evm-bridge/blob/main/cadence/scripts/bridge/get_associated_evm_address.cdc
_21
_21
import "EVM"
_21
_21
import "FlowEVMBridgeConfig"
_21
_21
/// Returns the EVM address associated with the given Cadence type (as its identifier String)
_21
///
_21
/// @param typeIdentifier: The Cadence type identifier String
_21
///
_21
/// @return The EVM address as a hex string if the type has an associated EVMAddress, otherwise nil
_21
///
_21
access(all)
_21
fun main(identifier: String): String? {
_21
if let type = CompositeType(identifier) {
_21
if let address = FlowEVMBridgeConfig.getEVMAddressAssociated(with: type) {
_21
return address.toString()
_21
}
_21
}
_21
return nil
_21
}

NFTs

Any Cadence NFTs bridging to EVM are escrowed in the bridge account and either minted in a bridge-deployed ERC721 contract or transferred from escrow to the calling COA in EVM. On the return trip, NFTs are escrowed in EVM - owned by the bridge's COA - and either unlocked from escrow if locked or minted from a bridge-owned NFT contract.

Below are transactions relevant to bridging NFTs:

bridge_nft_to_evm.cdc

_120
// source: https://www.github.com/onflow/flow-evm-bridge/blob/main/cadence/transactions/bridge/nft/bridge_nft_to_evm.cdc
_120
_120
import "FungibleToken"
_120
import "NonFungibleToken"
_120
import "ViewResolver"
_120
import "MetadataViews"
_120
import "FlowToken"
_120
_120
import "ScopedFTProviders"
_120
_120
import "EVM"
_120
_120
import "FlowEVMBridge"
_120
import "FlowEVMBridgeConfig"
_120
import "FlowEVMBridgeUtils"
_120
_120
/// Bridges an NFT from the signer's collection in Cadence to the signer's COA in FlowEVM
_120
///
_120
/// NOTE: This transaction also onboards the NFT to the bridge if necessary which may incur additional fees
_120
/// than bridging an asset that has already been onboarded.
_120
///
_120
/// @param nftIdentifier: The Cadence type identifier of the NFT to bridge - e.g. nft.getType().identifier
_120
/// @param id: The Cadence NFT.id of the NFT to bridge to EVM
_120
///
_120
transaction(nftIdentifier: String, id: UInt64) {
_120
_120
let nft: @{NonFungibleToken.NFT}
_120
let coa: auth(EVM.Bridge) &EVM.CadenceOwnedAccount
_120
let requiresOnboarding: Bool
_120
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_120
_120
prepare(signer: auth(CopyValue, BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue) &Account) {
_120
/* --- Reference the signer's CadenceOwnedAccount --- */
_120
//
_120
// Borrow a reference to the signer's COA
_120
self.coa = signer.storage.borrow<auth(EVM.Bridge) &EVM.CadenceOwnedAccount>(from: /storage/evm)
_120
?? panic("Could not borrow COA from provided gateway address")
_120
_120
/* --- Construct the NFT type --- */
_120
//
_120
// Construct the NFT type from the provided identifier
_120
let nftType = CompositeType(nftIdentifier)
_120
?? panic("Could not construct NFT type from identifier: ".concat(nftIdentifier))
_120
// Parse the NFT identifier into its components
_120
let nftContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: nftType)
_120
?? panic("Could not get contract address from identifier: ".concat(nftIdentifier))
_120
let nftContractName = FlowEVMBridgeUtils.getContractName(fromType: nftType)
_120
?? panic("Could not get contract name from identifier: ".concat(nftIdentifier))
_120
_120
/* --- Retrieve the NFT --- */
_120
//
_120
// Borrow a reference to the NFT collection, configuring if necessary
_120
let viewResolver = getAccount(nftContractAddress).contracts.borrow<&{ViewResolver}>(name: nftContractName)
_120
?? panic("Could not borrow ViewResolver from NFT contract")
_120
let collectionData = viewResolver.resolveContractView(
_120
resourceType: nftType,
_120
viewType: Type<MetadataViews.NFTCollectionData>()
_120
) as! MetadataViews.NFTCollectionData? ?? panic("Could not resolve NFTCollectionData view")
_120
let collection = signer.storage.borrow<auth(NonFungibleToken.Withdraw) &{NonFungibleToken.Collection}>(
_120
from: collectionData.storagePath
_120
) ?? panic("Could not access signer's NFT Collection")
_120
_120
// Withdraw the requested NFT & calculate the approximate bridge fee based on NFT storage usage
_120
let currentStorageUsage = signer.storage.used
_120
self.nft <- collection.withdraw(withdrawID: id)
_120
let withdrawnStorageUsage = signer.storage.used
_120
var approxFee = FlowEVMBridgeUtils.calculateBridgeFee(
_120
bytes: currentStorageUsage - withdrawnStorageUsage
_120
) * 1.10
_120
// Determine if the NFT requires onboarding - this impacts the fee required
_120
self.requiresOnboarding = FlowEVMBridge.typeRequiresOnboarding(self.nft.getType())
_120
?? panic("Bridge does not support this asset type")
_120
if self.requiresOnboarding {
_120
approxFee = approxFee + FlowEVMBridgeConfig.onboardFee
_120
}
_120
_120
/* --- Configure a ScopedFTProvider --- */
_120
//
_120
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_120
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_120
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_120
/storage/flowTokenVault
_120
)
_120
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_120
}
_120
// Copy the stored Provider capability and create a ScopedFTProvider
_120
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_120
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_120
) ?? panic("Invalid Provider Capability found in storage.")
_120
let providerFilter = ScopedFTProviders.AllowanceFilter(approxFee)
_120
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_120
provider: providerCapCopy,
_120
filters: [ providerFilter ],
_120
expiration: getCurrentBlock().timestamp + 1.0
_120
)
_120
}
_120
_120
pre {
_120
self.nft.getType().identifier == nftIdentifier:
_120
"Attempting to send invalid nft type - requested: ".concat(nftIdentifier)
_120
.concat(", sending: ").concat(self.nft.getType().identifier)
_120
}
_120
_120
execute {
_120
if self.requiresOnboarding {
_120
// Onboard the NFT to the bridge
_120
FlowEVMBridge.onboardByType(
_120
self.nft.getType(),
_120
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_120
)
_120
}
_120
// Execute the bridge
_120
self.coa.depositNFT(
_120
nft: <-self.nft,
_120
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_120
)
_120
// Destroy the ScopedFTProvider
_120
destroy self.scopedProvider
_120
}
_120
}

bridge_nft_from_evm.cdc

_108
// source: https://www.github.com/onflow/flow-evm-bridge/blob/main/cadence/transactions/bridge/nft/bridge_nft_from_evm.cdc
_108
_108
import "FungibleToken"
_108
import "NonFungibleToken"
_108
import "ViewResolver"
_108
import "MetadataViews"
_108
import "FlowToken"
_108
_108
import "ScopedFTProviders"
_108
_108
import "EVM"
_108
_108
import "FlowEVMBridge"
_108
import "FlowEVMBridgeConfig"
_108
import "FlowEVMBridgeUtils"
_108
_108
/// This transaction bridges an NFT from EVM to Cadence assuming it has already been onboarded to the FlowEVMBridge
_108
/// NOTE: The ERC721 must have first been onboarded to the bridge. This can be checked via the method
_108
/// FlowEVMBridge.evmAddressRequiresOnboarding(address: self.evmContractAddress)
_108
///
_108
/// @param nftIdentifier: The Cadence type identifier of the NFT to bridge - e.g. nft.getType().identifier
_108
/// @param id: The ERC721 id of the NFT to bridge to Cadence from EVM
_108
///
_108
transaction(nftIdentifier: String, id: UInt256) {
_108
_108
let nftType: Type
_108
let collection: &{NonFungibleToken.Collection}
_108
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_108
let coa: auth(EVM.Bridge) &EVM.CadenceOwnedAccount
_108
_108
prepare(signer: auth(BorrowValue, CopyValue, IssueStorageCapabilityController, PublishCapability, SaveValue, UnpublishCapability) &Account) {
_108
/* --- Reference the signer's CadenceOwnedAccount --- */
_108
//
_108
// Borrow a reference to the signer's COA
_108
self.coa = signer.storage.borrow<auth(EVM.Bridge) &EVM.CadenceOwnedAccount>(from: /storage/evm)
_108
?? panic("Could not borrow COA from provided gateway address")
_108
_108
/* --- Construct the NFT type --- */
_108
//
_108
// Construct the NFT type from the provided identifier
_108
self.nftType = CompositeType(nftIdentifier)
_108
?? panic("Could not construct NFT type from identifier: ".concat(nftIdentifier))
_108
// Parse the NFT identifier into its components
_108
let nftContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: self.nftType)
_108
?? panic("Could not get contract address from identifier: ".concat(nftIdentifier))
_108
let nftContractName = FlowEVMBridgeUtils.getContractName(fromType: self.nftType)
_108
?? panic("Could not get contract name from identifier: ".concat(nftIdentifier))
_108
_108
/* --- Reference the signer's NFT Collection --- */
_108
//
_108
// Borrow a reference to the NFT collection, configuring if necessary
_108
let viewResolver = getAccount(nftContractAddress).contracts.borrow<&{ViewResolver}>(name: nftContractName)
_108
?? panic("Could not borrow ViewResolver from NFT contract")
_108
let collectionData = viewResolver.resolveContractView(
_108
resourceType: self.nftType,
_108
viewType: Type<MetadataViews.NFTCollectionData>()
_108
) as! MetadataViews.NFTCollectionData? ?? panic("Could not resolve NFTCollectionData view")
_108
if signer.storage.borrow<&{NonFungibleToken.Collection}>(from: collectionData.storagePath) == nil {
_108
signer.storage.save(<-collectionData.createEmptyCollection(), to: collectionData.storagePath)
_108
signer.capabilities.unpublish(collectionData.publicPath)
_108
let collectionCap = signer.capabilities.storage.issue<&{NonFungibleToken.Collection}>(collectionData.storagePath)
_108
signer.capabilities.publish(collectionCap, at: collectionData.publicPath)
_108
}
_108
self.collection = signer.storage.borrow<&{NonFungibleToken.Collection}>(from: collectionData.storagePath)
_108
?? panic("Could not borrow collection from storage path")
_108
_108
/* --- Configure a ScopedFTProvider --- */
_108
//
_108
// Calculate the bridge fee - bridging from EVM consumes no storage, so flat fee
_108
let approxFee = FlowEVMBridgeUtils.calculateBridgeFee(bytes: 0)
_108
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_108
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_108
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_108
/storage/flowTokenVault
_108
)
_108
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_108
}
_108
// Copy the stored Provider capability and create a ScopedFTProvider
_108
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_108
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_108
) ?? panic("Invalid Provider Capability found in storage.")
_108
let providerFilter = ScopedFTProviders.AllowanceFilter(approxFee)
_108
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_108
provider: providerCapCopy,
_108
filters: [ providerFilter ],
_108
expiration: getCurrentBlock().timestamp + 1.0
_108
)
_108
}
_108
_108
execute {
_108
// Execute the bridge
_108
let nft: @{NonFungibleToken.NFT} <- self.coa.withdrawNFT(
_108
type: self.nftType,
_108
id: id,
_108
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_108
)
_108
// Ensure the bridged nft is the correct type
_108
assert(
_108
nft.getType() == self.nftType,
_108
message: "Bridged nft type mismatch - requeswted: ".concat(self.nftType.identifier)
_108
.concat(", received: ").concat(nft.getType().identifier)
_108
)
_108
// Deposit the bridged NFT into the signer's collection
_108
self.collection.deposit(token: <-nft)
_108
// Destroy the ScopedFTProvider
_108
destroy self.scopedProvider
_108
}
_108
}

Fungible Tokens

Any Cadence fungible tokens bridging to EVM are escrowed in the bridge account only if they are Cadence-native. If the bridge defines the tokens, they are burned. On the return trip the pattern is similar, with the bridge burning bridge-defined tokens or escrowing them if they are EVM-native. In all cases, if the bridge has authority to mint on one side, it must escrow on the other as the native VM contract is owned by an external party.

With fungible tokens in particular, there may be some cases where the Cadence contract is not deployed to the bridge account, but the bridge still follows a mint/burn pattern in Cadence. These cases are handled via TokenHandler implementations. Also know that moving $FLOW to EVM is built into the EVMAddress object so any requests bridging $FLOW to EVM will simply leverage this interface; however, moving $FLOW from EVM to Cadence must be done through the COA resource.

Below are transactions relevant to bridging fungible tokens:

bridge_tokens_to_evm.cdc

_121
// source: https://www.github.com/onflow/flow-evm-bridge/blob/main/cadence/transactions/bridge/tokens/bridge_tokens_to_evm.cdc
_121
_121
import "FungibleToken"
_121
import "ViewResolver"
_121
import "FungibleTokenMetadataViews"
_121
import "FlowToken"
_121
_121
import "ScopedFTProviders"
_121
_121
import "EVM"
_121
_121
import "FlowEVMBridge"
_121
import "FlowEVMBridgeConfig"
_121
import "FlowEVMBridgeUtils"
_121
_121
/// Bridges a Vault from the signer's storage to the signer's COA in EVM.Account.
_121
///
_121
/// NOTE: This transaction also onboards the Vault to the bridge if necessary which may incur additional fees
_121
/// than bridging an asset that has already been onboarded.
_121
///
_121
/// @param vaultIdentifier: The Cadence type identifier of the FungibleToken Vault to bridge
_121
/// - e.g. vault.getType().identifier
_121
/// @param amount: The amount of tokens to bridge from EVM
_121
///
_121
transaction(vaultIdentifier: String, amount: UFix64) {
_121
_121
let sentVault: @{FungibleToken.Vault}
_121
let coa: auth(EVM.Bridge) &EVM.CadenceOwnedAccount
_121
let requiresOnboarding: Bool
_121
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_121
_121
prepare(signer: auth(CopyValue, BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue) &Account) {
_121
/* --- Reference the signer's CadenceOwnedAccount --- */
_121
//
_121
// Borrow a reference to the signer's COA
_121
self.coa = signer.storage.borrow<auth(EVM.Bridge) &EVM.CadenceOwnedAccount>(from: /storage/evm)
_121
?? panic("Could not borrow COA from provided gateway address")
_121
_121
/* --- Construct the Vault type --- */
_121
//
_121
// Construct the Vault type from the provided identifier
_121
let vaultType = CompositeType(vaultIdentifier)
_121
?? panic("Could not construct Vault type from identifier: ".concat(vaultIdentifier))
_121
// Parse the Vault identifier into its components
_121
let tokenContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: vaultType)
_121
?? panic("Could not get contract address from identifier: ".concat(vaultIdentifier))
_121
let tokenContractName = FlowEVMBridgeUtils.getContractName(fromType: vaultType)
_121
?? panic("Could not get contract name from identifier: ".concat(vaultIdentifier))
_121
_121
/* --- Retrieve the funds --- */
_121
//
_121
// Borrow a reference to the FungibleToken Vault
_121
let viewResolver = getAccount(tokenContractAddress).contracts.borrow<&{ViewResolver}>(name: tokenContractName)
_121
?? panic("Could not borrow ViewResolver from FungibleToken contract")
_121
let vaultData = viewResolver.resolveContractView(
_121
resourceType: vaultType,
_121
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
_121
) as! FungibleTokenMetadataViews.FTVaultData? ?? panic("Could not resolve FTVaultData view")
_121
let vault = signer.storage.borrow<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(
_121
from: vaultData.storagePath
_121
) ?? panic("Could not access signer's FungibleToken Vault")
_121
_121
// Withdraw the requested balance & calculate the approximate bridge fee based on storage usage
_121
let currentStorageUsage = signer.storage.used
_121
self.sentVault <- vault.withdraw(amount: amount)
_121
let withdrawnStorageUsage = signer.storage.used
_121
// Approximate the bridge fee based on the difference in storage usage with some buffer
_121
var approxFee = FlowEVMBridgeUtils.calculateBridgeFee(
_121
bytes: currentStorageUsage - withdrawnStorageUsage
_121
) * 1.10
_121
// Determine if the Vault requires onboarding - this impacts the fee required
_121
self.requiresOnboarding = FlowEVMBridge.typeRequiresOnboarding(self.sentVault.getType())
_121
?? panic("Bridge does not support this asset type")
_121
if self.requiresOnboarding {
_121
approxFee = approxFee + FlowEVMBridgeConfig.onboardFee
_121
}
_121
_121
/* --- Configure a ScopedFTProvider --- */
_121
//
_121
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_121
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_121
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_121
/storage/flowTokenVault
_121
)
_121
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_121
}
_121
// Copy the stored Provider capability and create a ScopedFTProvider
_121
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_121
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_121
) ?? panic("Invalid Provider Capability found in storage.")
_121
let providerFilter = ScopedFTProviders.AllowanceFilter(approxFee)
_121
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_121
provider: providerCapCopy,
_121
filters: [ providerFilter ],
_121
expiration: getCurrentBlock().timestamp + 1.0
_121
)
_121
}
_121
_121
pre {
_121
self.sentVault.getType().identifier == vaultIdentifier:
_121
"Attempting to send invalid vault type - requested: ".concat(vaultIdentifier)
_121
.concat(", sending: ").concat(self.sentVault.getType().identifier)
_121
}
_121
_121
execute {
_121
if self.requiresOnboarding {
_121
// Onboard the Vault to the bridge
_121
FlowEVMBridge.onboardByType(
_121
self.sentVault.getType(),
_121
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_121
)
_121
}
_121
// Execute the bridge
_121
self.coa.depositTokens(
_121
vault: <-self.sentVault,
_121
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_121
)
_121
// Destroy the ScopedFTProvider
_121
destroy self.scopedProvider
_121
}
_121
}

bridge_tokens_from_evm.cdc

_114
// source: https://www.github.com/onflow/flow-evm-bridge/blob/main/cadence/transactions/bridge/tokens/bridge_tokens_from_evm.cdc
_114
_114
import "FungibleToken"
_114
import "FungibleTokenMetadataViews"
_114
import "ViewResolver"
_114
import "MetadataViews"
_114
import "FlowToken"
_114
_114
import "ScopedFTProviders"
_114
_114
import "EVM"
_114
_114
import "FlowEVMBridge"
_114
import "FlowEVMBridgeConfig"
_114
import "FlowEVMBridgeUtils"
_114
_114
/// This transaction bridges fungible tokens from EVM to Cadence assuming it has already been onboarded to the
_114
/// FlowEVMBridge.
_114
///
_114
/// NOTE: The ERC20 must have first been onboarded to the bridge. This can be checked via the method
_114
/// FlowEVMBridge.evmAddressRequiresOnboarding(address: self.evmContractAddress)
_114
///
_114
/// @param vaultIdentifier: The Cadence type identifier of the FungibleToken Vault to bridge
_114
/// - e.g. vault.getType().identifier
_114
/// @param amount: The amount of tokens to bridge from EVM
_114
///
_114
transaction(vaultIdentifier: String, amount: UInt256) {
_114
_114
let vaultType: Type
_114
let receiver: &{FungibleToken.Vault}
_114
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_114
let coa: auth(EVM.Bridge) &EVM.CadenceOwnedAccount
_114
_114
prepare(signer: auth(BorrowValue, CopyValue, IssueStorageCapabilityController, PublishCapability, SaveValue, UnpublishCapability) &Account) {
_114
/* --- Reference the signer's CadenceOwnedAccount --- */
_114
//
_114
// Borrow a reference to the signer's COA
_114
self.coa = signer.storage.borrow<auth(EVM.Bridge) &EVM.CadenceOwnedAccount>(from: /storage/evm)
_114
?? panic("Could not borrow COA from provided gateway address")
_114
_114
/* --- Construct the Vault type --- */
_114
//
_114
// Construct the Vault type from the provided identifier
_114
self.vaultType = CompositeType(vaultIdentifier)
_114
?? panic("Could not construct Vault type from identifier: ".concat(vaultIdentifier))
_114
// Parse the Vault identifier into its components
_114
let tokenContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: self.vaultType)
_114
?? panic("Could not get contract address from identifier: ".concat(vaultIdentifier))
_114
let tokenContractName = FlowEVMBridgeUtils.getContractName(fromType: self.vaultType)
_114
?? panic("Could not get contract name from identifier: ".concat(vaultIdentifier))
_114
_114
/* --- Reference the signer's Vault --- */
_114
//
_114
// Borrow a reference to the FungibleToken Vault, configuring if necessary
_114
let viewResolver = getAccount(tokenContractAddress).contracts.borrow<&{ViewResolver}>(name: tokenContractName)
_114
?? panic("Could not borrow ViewResolver from FungibleToken contract")
_114
let vaultData = viewResolver.resolveContractView(
_114
resourceType: self.vaultType,
_114
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
_114
) as! FungibleTokenMetadataViews.FTVaultData? ?? panic("Could not resolve FTVaultData view")
_114
// If the vault does not exist, create it and publish according to the contract's defined configuration
_114
if signer.storage.borrow<&{FungibleToken.Vault}>(from: vaultData.storagePath) == nil {
_114
signer.storage.save(<-vaultData.createEmptyVault(), to: vaultData.storagePath)
_114
_114
signer.capabilities.unpublish(vaultData.receiverPath)
_114
signer.capabilities.unpublish(vaultData.metadataPath)
_114
_114
let receiverCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(vaultData.storagePath)
_114
let metadataCap = signer.capabilities.storage.issue<&{FungibleToken.Vault}>(vaultData.storagePath)
_114
_114
signer.capabilities.publish(receiverCap, at: vaultData.receiverPath)
_114
signer.capabilities.publish(metadataCap, at: vaultData.metadataPath)
_114
}
_114
self.receiver = signer.storage.borrow<&{FungibleToken.Vault}>(from: vaultData.storagePath)
_114
?? panic("Could not borrow Vault from storage path")
_114
_114
/* --- Configure a ScopedFTProvider --- */
_114
//
_114
// Calculate the bridge fee - bridging from EVM consumes no storage, so flat fee
_114
let approxFee = FlowEVMBridgeUtils.calculateBridgeFee(bytes: 0)
_114
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_114
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_114
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_114
/storage/flowTokenVault
_114
)
_114
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_114
}
_114
// Copy the stored Provider capability and create a ScopedFTProvider
_114
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_114
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_114
) ?? panic("Invalid Provider Capability found in storage.")
_114
let providerFilter = ScopedFTProviders.AllowanceFilter(approxFee)
_114
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_114
provider: providerCapCopy,
_114
filters: [ providerFilter ],
_114
expiration: getCurrentBlock().timestamp + 1.0
_114
)
_114
}
_114
_114
execute {
_114
// Execute the bridge request
_114
let vault: @{FungibleToken.Vault} <- self.coa.withdrawTokens(
_114
type: self.vaultType,
_114
amount: amount,
_114
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_114
)
_114
// Ensure the bridged vault is the correct type
_114
assert(vault.getType() == self.vaultType, message: "Bridged vault type mismatch")
_114
// Deposit the bridged token into the signer's vault
_114
self.receiver.deposit(from: <-vault)
_114
// Destroy the ScopedFTProvider
_114
destroy self.scopedProvider
_114
}
_114
}

Prep Your Assets for Bridging

Context

To maximize utility to the ecosystem, this bridge is permissionless and open to any fungible or non-fungible token as defined by the respective Cadence standards and limited to ERC20 and ERC721 Solidity standards. Ultimately, a project does not have to do anything for users to be able to bridge their assets between VMs. However, there are some considerations developers may take to enhance the representation of their assets in non-native VMs. These largely relate to asset metadata and ensuring that bridging does not compromise critical user assumptions about asset ownership.

EVMBridgedMetadata

Proposed in @onflow/flow-nft/pull/203, the EVMBridgedMetadata view presents a mechanism to both represent metadata from bridged EVM assets as well as enable Cadence-native projects to specify the representation of their assets in EVM. Implementing this view is not required for assets to be bridged, but the bridge does default to it when available as a way to provide projects greater control over their EVM asset definitions within the scope of ERC20 and ERC721 standards.

The interface for this view is as follows:


_20
access(all) struct URI: MetadataViews.File {
_20
/// The base URI prefix, if any. Not needed for all URIs, but helpful
_20
/// for some use cases For example, updating a whole NFT collection's
_20
/// image host easily
_20
access(all) let baseURI: String?
_20
/// The URI string value
_20
/// NOTE: this is set on init as a concatenation of the baseURI and the
_20
/// value if baseURI != nil
_20
access(self) let value: String
_20
_20
access(all) view fun uri(): String
_20
_20
}
_20
_20
access(all) struct EVMBridgedMetadata {
_20
access(all) let name: String
_20
access(all) let symbol: String
_20
_20
access(all) let uri: {MetadataViews.File}
_20
}

This uri value could be a pointer to some offchain metadata if you expect your metadata to be static. Or you could couple the uri() method with the utility contract below to serialize the onchain metadata on the fly. Alternatively, you may choose to host a metadata proxy which serves the requested token URI content.

SerializeMetadata

The key consideration with respect to metadata is the distinct metadata storage patterns between ecosystem. It's critical for NFT utility that the metadata be bridged in addition to the representation of the NFTs ownership. However, it's commonplace for Cadence NFTs to store metadata onchain while EVM NFTs often store an onchain pointer to metadata stored offchain. In order for Cadence NFTs to be properly represented in EVM platforms, the metadata must be bridged in a format expected by those platforms and be done in a manner that also preserves the atomicity of bridge requests. The path forward on this was decided to be a commitment of serialized Cadence NFT metadata into formats popular in the EVM ecosystem.

For assets that do not implement EVMBridgedMetadata, the bridge will attempt to serialize the metadata of the asset as a JSON data URL string. This is done via the SerializeMetadata contract which serializes metadata values into a JSON blob compatible with the OpenSea metadata standard. The serialized metadata is then committed as the ERC721 tokenURI upon bridging Cadence-native NFTs to EVM. Since Cadence NFTs can easily update onchain metadata either by field or by the ownership of sub-NFTs, this serialization pattern enables token URI updates on subsequent bridge requests.

Opting Out

It's also recognized that the logic of some use cases may actually be compromised by the act of bridging, particularly in such a unique partitioned runtime environment. Such cases might include those that do not maintain ownership assumptions implicit to ecosystem standards.

For instance, an ERC721 implementation may reclaim a user's assets after a month of inactivity. In such a case, bridging that ERC721 to Cadence would decouple the representation of ownership of the bridged NFT from the actual ownership in the defining ERC721 contract after the token had been reclaimed - there would be no NFT in escrow for the bridge to transfer on fulfillment of the NFT back to EVM. In such cases, projects may choose to opt-out of bridging, but importantly must do so before the asset has been onboarded to the bridge.

For Solidity contracts, opting out is as simple as extending the BridgePermissions.sol abstract contract which defaults allowsBridging() to false. The bridge explicitly checks for the implementation of IBridgePermissions and the value of allowsBridging() to validate that the contract has not opted out of bridging.

Similarly, Cadence contracts can implement the IBridgePermissions.cdc contract interface. This contract has a single method allowsBridging() with a default implementation returning false. Again, the bridge explicitly checks for the implementation of IBridgePermissions and the value of allowsBridging() to validate that the contract has not opted out of bridging. Should you later choose to enable bridging, you can simply override the default implementation and return true.

In both cases, allowsBridging() gates onboarding to the bridge. Once onboarded - a permissionless operation anyone can execute - the value of allowsBridging() is irrelevant and assets can move between VMs permissionlessly.

Under the Hood

For an in-depth look at the high-level architecture of the bridge, see FLIP #237

Additional Resources

For the current state of Flow EVM across various task paths, see the following resources: