Skip to main content

Cross-VM Bridge

Flow provides the Cross-VM Bridge which enables the movement of fungible and non-fungible tokens between Flow-Cadence & Flow-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.

By default, when a user onboards a new token to the bridge, the bridge will deploy a standard token contract in the other VM that the owner of the original token contract has no direct control over. This bridge-deployed contract handles basic minting and metadata operations that are required for usage in the needed environment. If a developer wants to manage and connect the token contracts on both sides of the bridge, they can include pointers in each contract to indicate that they are associated and should be bridged for each other.

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 Flow EVM. 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-233 outlines the architecture and implementation of the VM bridge. An additional FLIP-318 describes how developers can create custom associations between tokens they control in each VM. 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 Flow EVM 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.

You can find full guides for creating these projects here.

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 Your token to the Bridge

For the purpose of this guide, we are assuming that the developer has already deployed a token smart contract to their preferred VM (Flow-Cadence or Flow-EVM) and wants to bridge it to the other (target) VM.

In order for the developer's token to be usable in the target VM, there must be a contract that defines the asset and how it behaves in the target VM. This contract is separate from the contract in the native VM, but they are "associated" with each other by the mechanisms of the Flow VM bridge.

To create this association, the asset must be "onboarded" to the bridge before bridging operations can be fulfilled. This can happen in two ways:

Option 1: Automatic Onboarding

Any user registers the native token contract with the bridge and the bridge deploys a basic templated version of the contract in the target VM. This basic contract is automatically associated with the native contract and is used for bridging. The developer has no direct control over this bridge-deployed contract because it is controlled by the bridge.

This method is covered in the Automatic Onboarding Section

Option 2: Custom Association Onboarding

With this option, developers can deploy their own contract to the target VM and declare a custom association between it and the native contract. This allows them to have more control over both contracts, enabling them to include more sophisticated features and mechanisms in their bridged token contracts such as ERC-721C, unique metadata views, and more that aren't included in the default bridged template versions.

This method is covered in the Custom Association Section

info

Before continuing with onboarding your token, you should review the Prep Your Assets for Bridging section of this document. This describes some steps you should follow to make sure that your native asset and/or bridged asset are properly set up for you to register them with the bridge.

Automatic Onboarding

Moving from a Cadence-native asset to EVM, automatic 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 automatically onboarding assets native to either VM:

Automatically Onboard a Cadence-native asset:

onboard_by_type.cdc
onboard_by_type.cdc

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

Automatically Onboard an EVM-native asset:

onboard_by_evm_address.cdc
onboard_by_evm_address.cdc

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

Custom Association Onboarding

With Custom Associations, developers can deploy NFT contracts in both VMs and associate them with each other, allowing them to retain control of the contracts in both VMs.

In order to do this, each contract must implement a special interface that tells the bridge what the associated contract is in the other VM. If the contracts do not point to each other this way, they will not be able be be registered as a custom association.

Review the Preparing Custom Associations section to learn how to set up each of your contracts for a custom association.

Below is the transaction for onboarding NFTs for a custom association. Remember that both the Cadence and the Solidity contract need to already be deployed and include the special interface conformances to point to each other!

Onboard an NFT Custom Association:

register_cross_vm_nft.cdc
onboard_by_type.cdc

_90
import "FungibleToken"
_90
import "NonFungibleToken"
_90
import "CrossVMMetadataViews"
_90
import "EVM"
_90
_90
import "ScopedFTProviders"
_90
import "FlowEVMBridgeCustomAssociationTypes"
_90
import "FlowEVMBridgeCustomAssociations"
_90
import "FlowEVMBridge"
_90
import "FlowEVMBridgeConfig"
_90
_90
/// This transaction will register an NFT type as a custom cross-VM NFT. The Cadence contract must implement the
_90
/// CrossVMMetadata.EVMPointer view and the corresponding ERC721 must implement ICrossVM interface such that the Type
_90
/// points to the EVM contract and vice versa. If the NFT is EVM-native, a
_90
/// FlowEVMBridgeCustomAssociations.NFTFulfillmentMinter Capability must be provided, allowing the bridge to fulfill
_90
/// requests moving the ERC721 from EVM into Cadence.
_90
///
_90
/// See FLIP-318 for more information on implementing custom cross-VM NFTs: https://github.com/onflow/flips/issues/318
_90
///
_90
/// @param nftTypeIdentifer: The type identifier of the NFT being registered as a custom cross-VM implementation
_90
/// @param fulfillmentMinterPath: The StoragePath where the NFTFulfillmentMinter is stored
_90
///
_90
transaction(nftTypeIdentifier: String, fulfillmentMinterPath: StoragePath?) {
_90
_90
let nftType: Type
_90
let fulfillmentMinterCap: Capability<auth(FlowEVMBridgeCustomAssociationTypes.FulfillFromEVM) &{FlowEVMBridgeCustomAssociationTypes.NFTFulfillmentMinter}>?
_90
let scopedProvider: @ScopedFTProviders.ScopedFTProvider
_90
let expectedAssociation: EVM.EVMAddress
_90
_90
prepare(signer: auth(CopyValue, BorrowValue, IssueStorageCapabilityController, PublishCapability, SaveValue) &Account) {
_90
/* --- Assign registration fields --- */
_90
//
_90
self.nftType = CompositeType(nftTypeIdentifier) ?? panic("Could not construct type from identifier ".concat(nftTypeIdentifier))
_90
if fulfillmentMinterPath != nil {
_90
assert(
_90
signer.storage.type(at: fulfillmentMinterPath!) != nil,
_90
message: "There was no resource found at provided path ".concat(fulfillmentMinterPath!.toString())
_90
)
_90
self.fulfillmentMinterCap = signer.capabilities.storage
_90
.issue<auth(FlowEVMBridgeCustomAssociationTypes.FulfillFromEVM) &{FlowEVMBridgeCustomAssociationTypes.NFTFulfillmentMinter}>(
_90
fulfillmentMinterPath!
_90
)
_90
} else {
_90
self.fulfillmentMinterCap = nil
_90
}
_90
_90
/* --- Configure a ScopedFTProvider --- */
_90
//
_90
// Issue and store bridge-dedicated Provider Capability in storage if necessary
_90
if signer.storage.type(at: FlowEVMBridgeConfig.providerCapabilityStoragePath) == nil {
_90
let providerCap = signer.capabilities.storage.issue<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>(
_90
/storage/flowTokenVault
_90
)
_90
signer.storage.save(providerCap, to: FlowEVMBridgeConfig.providerCapabilityStoragePath)
_90
}
_90
// Copy the stored Provider capability and create a ScopedFTProvider
_90
let providerCapCopy = signer.storage.copy<Capability<auth(FungibleToken.Withdraw) &{FungibleToken.Provider}>>(
_90
from: FlowEVMBridgeConfig.providerCapabilityStoragePath
_90
) ?? panic("Invalid Provider Capability found in storage.")
_90
let providerFilter = ScopedFTProviders.AllowanceFilter(FlowEVMBridgeConfig.onboardFee)
_90
self.scopedProvider <- ScopedFTProviders.createScopedFTProvider(
_90
provider: providerCapCopy,
_90
filters: [ providerFilter ],
_90
expiration: getCurrentBlock().timestamp + 1.0
_90
)
_90
_90
/* --- Assign the expected EVM address --- */
_90
//
_90
let resolver = getAccount(self.nftType.address!).contracts.borrow<&{NonFungibleToken}>(name: self.nftType.contractName!)
_90
?? panic("Could not borrow NFT contract for NFT type \(nftTypeIdentifier)")
_90
let evmPointer = resolver.resolveContractView(resourceType: self.nftType, viewType: Type<CrossVMMetadataViews.EVMPointer>()) as! CrossVMMetadataViews.EVMPointer?
_90
?? panic("Cross-VM NFTs must implement CrossVMMetadataViews.EVMPointer view but none was found for NFT \(nftTypeIdentifier)")
_90
self.expectedAssociation = evmPointer.evmContractAddress
_90
}
_90
_90
execute {
_90
FlowEVMBridge.registerCrossVMNFT(
_90
type: self.nftType,
_90
fulfillmentMinter: self.fulfillmentMinterCap,
_90
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_90
)
_90
destroy self.scopedProvider
_90
}
_90
_90
post {
_90
FlowEVMBridgeConfig.getEVMAddressAssociated(with: self.nftType)?.equals(self.expectedAssociation) ?? false:
_90
"Expected final association with \(nftTypeIdentifier) to be set to \(self.expectedAssociation.toString()) but found "
_90
.concat(FlowEVMBridgeConfig.getEVMAddressAssociated(with: self.nftType)?.toString() ?? "nil")
_90
}
_90
}

Bridging

Once an asset has been onboarded, either by automatic or custom association, 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
get_associated_type.cdc

_16
import "EVM"
_16
_16
import "FlowEVMBridgeConfig"
_16
_16
/// Returns the Cadence Type associated with the given EVM address (as its hex String)
_16
///
_16
/// @param evmAddressHex: The hex-encoded address of the EVM contract as a String
_16
///
_16
/// @return The Cadence Type associated with the EVM address or nil if the address is not onboarded. `nil` may also be
_16
/// returned if the address is not a valid EVM address.
_16
///
_16
access(all)
_16
fun main(addressHex: String): Type? {
_16
let address = EVM.addressFromString(addressHex)
_16
return FlowEVMBridgeConfig.getTypeAssociated(with: address)
_16
}

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

get_associated_address.cdc
get_associated_address.cdc

_19
import "EVM"
_19
_19
import "FlowEVMBridgeConfig"
_19
_19
/// Returns the EVM address associated with the given Cadence type (as its identifier String)
_19
///
_19
/// @param typeIdentifier: The Cadence type identifier String
_19
///
_19
/// @return The EVM address as a hex string if the type has an associated EVMAddress, otherwise nil
_19
///
_19
access(all)
_19
fun main(identifier: String): String? {
_19
if let type = CompositeType(identifier) {
_19
if let address = FlowEVMBridgeConfig.getEVMAddressAssociated(with: type) {
_19
return address.toString()
_19
}
_19
}
_19
return nil
_19
}

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
bridge_nft_to_evm.cdc

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

bridge_nft_from_evm.cdc
bridge_nft_from_evm.cdc

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

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
bridge_tokens_to_evm.cdc

_120
import "FungibleToken"
_120
import "ViewResolver"
_120
import "FungibleTokenMetadataViews"
_120
import "FlowToken"
_120
_120
import "ScopedFTProviders"
_120
_120
import "EVM"
_120
_120
import "FlowEVMBridge"
_120
import "FlowEVMBridgeConfig"
_120
import "FlowEVMBridgeUtils"
_120
_120
/// Bridges a Vault from the signer's storage to the signer's COA in EVM.Account.
_120
///
_120
/// NOTE: This transaction also onboards the Vault to the bridge if necessary which may incur additional fees
_120
/// than bridging an asset that has already been onboarded.
_120
///
_120
/// @param vaultIdentifier: The Cadence type identifier of the FungibleToken Vault to bridge
_120
/// - e.g. vault.getType().identifier
_120
/// @param amount: The amount of tokens to bridge from EVM
_120
///
_120
transaction(vaultIdentifier: String, amount: UFix64) {
_120
_120
let sentVault: @{FungibleToken.Vault}
_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 signer's account at path /storage/evm")
_120
_120
/* --- Construct the Vault type --- */
_120
//
_120
// Construct the Vault type from the provided identifier
_120
let vaultType = CompositeType(vaultIdentifier)
_120
?? panic("Could not construct Vault type from identifier: ".concat(vaultIdentifier))
_120
// Parse the Vault identifier into its components
_120
let tokenContractAddress = FlowEVMBridgeUtils.getContractAddress(fromType: vaultType)
_120
?? panic("Could not get contract address from identifier: ".concat(vaultIdentifier))
_120
let tokenContractName = FlowEVMBridgeUtils.getContractName(fromType: vaultType)
_120
?? panic("Could not get contract name from identifier: ".concat(vaultIdentifier))
_120
_120
/* --- Retrieve the funds --- */
_120
//
_120
// Borrow a reference to the FungibleToken Vault
_120
let viewResolver = getAccount(tokenContractAddress).contracts.borrow<&{ViewResolver}>(name: tokenContractName)
_120
?? panic("Could not borrow ViewResolver from FungibleToken contract with name"
_120
.concat(tokenContractName).concat(" and address ")
_120
.concat(tokenContractAddress.toString()))
_120
let vaultData = viewResolver.resolveContractView(
_120
resourceType: vaultType,
_120
viewType: Type<FungibleTokenMetadataViews.FTVaultData>()
_120
) as! FungibleTokenMetadataViews.FTVaultData?
_120
?? panic("Could not resolve FTVaultData view for Vault type ".concat(vaultType.identifier))
_120
let vault = signer.storage.borrow<auth(FungibleToken.Withdraw) &{FungibleToken.Vault}>(
_120
from: vaultData.storagePath
_120
) ?? panic("Could not borrow FungibleToken Vault from storage path ".concat(vaultData.storagePath.toString()))
_120
_120
// Withdraw the requested balance & set a cap on the withdrawable bridge fee
_120
self.sentVault <- vault.withdraw(amount: amount)
_120
var approxFee = FlowEVMBridgeUtils.calculateBridgeFee(
_120
bytes: 400_000 // 400 kB as upper bound on movable storage used in a single transaction
_120
)
_120
// Determine if the Vault requires onboarding - this impacts the fee required
_120
self.requiresOnboarding = FlowEVMBridge.typeRequiresOnboarding(self.sentVault.getType())
_120
?? panic("Bridge does not support the requested asset type ".concat(vaultIdentifier))
_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 FungibleToken Provider Capability found in storage at path "
_120
.concat(FlowEVMBridgeConfig.providerCapabilityStoragePath.toString()))
_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.sentVault.getType().identifier == vaultIdentifier:
_120
"Attempting to send invalid vault type - requested: ".concat(vaultIdentifier)
_120
.concat(", sending: ").concat(self.sentVault.getType().identifier)
_120
}
_120
_120
execute {
_120
if self.requiresOnboarding {
_120
// Onboard the Vault to the bridge
_120
FlowEVMBridge.onboardByType(
_120
self.sentVault.getType(),
_120
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_120
)
_120
}
_120
// Execute the bridge
_120
self.coa.depositTokens(
_120
vault: <-self.sentVault,
_120
feeProvider: &self.scopedProvider as auth(FungibleToken.Withdraw) &{FungibleToken.Provider}
_120
)
_120
// Destroy the ScopedFTProvider
_120
destroy self.scopedProvider
_120
}
_120
}

bridge_tokens_from_evm.cdc
bridge_tokens_from_evm.cdc

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

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.

Preparing Custom Associations

If you are a developer who wants to deploy and manage NFT contracts in both VMs and have tokens from each be exchangable for each other, you'll have to add some code to your contracts so they point to each other, indicating that they each represent the same token in their respective VMs.

For the purposes of these instructions, an NFT is native to a VM if that VM is the main source of truth for the contracts and where they are originally minted.

This feature is not available for Fungible Tokens at the moment, but may be in the future.

Cadence

All Cadence NFT contracts implement Metadata Views that return metadata about their NFTs in standard ways via the {Contract}.resolveContractView() and {NFT}.resolveView() methods.

The following new view (CrossVMMetadataViews.EVMPointer) must be resolved at the contract level (ViewResolver.resolveContractView()) for a given Type and at the NFT level (ViewResolver.Resolver.resolveView())


_11
/// View resolved at contract & resource level pointing to the associated EVM implementation
_11
access(all) struct EVMPointer {
_11
/// The associated Cadence Type
_11
access(all) let cadenceType: Type
_11
/// The defining Cadence contract address
_11
access(all) let cadenceContractAddress: Address
_11
/// The associated EVM contract address
_11
access(all) let evmContractAddress: EVM.EVMAddress
_11
/// Whether the asset is Cadence- or EVM-native
_11
access(all) let isCadenceNative: Bool
_11
}

This view allows a Cadence contract to specify which Solidity contract it is associated with.

You can see an example of how this view is implemented in the ExampleNFT contract in the Flow Non-Fungible Token repo.

As you can see in the code linked above, it is also advised to implement the CrossVMMetadataViews.EVMBytesMetadata view to allow you to have more control over what metadata is passed to your Solidity contract every time an NFT is bridged.

Flow EVM-Native NFTs

If the NFT being onboarded to the bridge is native to Flow-EVM, then the associated contract's minter resource must implement the FlowEVMBridgeCustomAssociationTypes.NFTFulfillmentMinter interface:


_29
/// Resource interface used by EVM-native NFT collections allowing for the fulfillment of NFTs from EVM into Cadence
_29
///
_29
access(all) resource interface NFTFulfillmentMinter {
_29
/// Getter for the type of NFT that's fulfilled by this implementation
_29
///
_29
access(all) view fun getFulfilledType(): Type
_29
_29
/// Called by the VM bridge when moving NFTs from EVM into Cadence if the NFT is not in escrow. Since such NFTs
_29
/// are EVM-native, they are distributed in EVM. On the Cadence side, those NFTs are handled by a mint & escrow
_29
/// pattern. On moving to EVM, the NFTs are minted if not in escrow at the time of bridging.
_29
///
_29
/// @param id: The id of the token being fulfilled from EVM
_29
///
_29
/// @return The NFT fulfilled from EVM as its Cadence implementation
_29
///
_29
access(FulfillFromEVM)
_29
fun fulfillFromEVM(id: UInt256): @{NonFungibleToken.NFT} {
_29
pre {
_29
id <= UInt256(UInt64.max):
_29
"The requested ID \(id.toString()) exceeds the maximum assignable Cadence NFT ID \(UInt64.max.toString())"
_29
}
_29
post {
_29
UInt256(result.id) == id:
_29
"Resulting NFT ID \(result.id.toString()) does not match requested ID \(id.toString())"
_29
result.getType() == self.getFulfilledType():
_29
"Expected \(self.getFulfilledType().identifier) but fulfilled \(result.getType().identifier)"
_29
}
_29
}
_29
}

You can see an example of an implementation of this interface in the Flow EVM bridge repo ExampleNFT contract.

Solidity

For custom associations, the following interface must be implemented in the IERC721-conforming Solidity contract.

This provides functionality to point to the address and type of the associated Cadence NFT.


_10
interface ICrossVM {
_10
/**
_10
* Returns the Cadence address defining the associated type
_10
*/
_10
function getCadenceAddress() external view returns (string memory);
_10
/**
_10
* Returns the Cadence Type identifier associated with the EVM contract
_10
*/
_10
function getCadenceIdentifier() external view returns (string memory);
_10
}

As an example, ICrossVM is already implemented and in use in the bridged ERC721 and ERC20 templates.

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: