How to transfer ERC-721 tokens to the Polygon (Matic) chain
In this tutorial, we will go through the process of transferring ERC-721 tokens to the Polygon (Matic) chain, and from Polygon Matic to the Ethereum chain using the Polygon PoS SDK.
We will use the Ethereum Goerli testnet and Polygon Mumbai testnet, and an ERC-721 that has been deployed and its source code has been verified on Etherscan. There is a step-by-step guide written by the author of this tutorial.
The Polygon Proof of Stake (PoS) Bridge is a mechanism and a set of contracts on both Ethereum and Polygon that will help us in moving assets between the root chain and child chain.
In contrast to the Plasma Bridge, the Polygon PoS bridge is much faster, making it a better choice for dApps that are looking for faster withdrawals.
Prerequisites
In this tutorial, we will use Metamask as our wallet. If you are not familiar with it or don't know how to use it, check this video on Youtube.
When you are creating a Metamask wallet, it gives you a Secret Recovery Phrase (Seed). With this seed, you can recover all of your accounts in Metamask.
We will use this seed later in this tutorial. Here is an article about How to reveal your Secret Recovery Phrase
Requirements
Getting started
- In order to transfer assets between root (Ethereum) and child (Polygon) contracts, they should be mapped first. This is a process by which an existing token contract is mirrored between the root and child chain.
If the token you intend to transfer already exists on Polygon, this means you don't need to perform the mapping.
Check the official docs to learn about the mapping process. - After we mapped the contract, it's time to transfer the assets. We can either use the Mintnft or the Polygon SDK. We will use both of them
Setting up Metamask
Before we get into the details of moving the tokens, let's set up Metamask so we can check our ETH, MATIC, and MLBn token balances.
Goerli
The Goerli testnet is pre-configured in Metamask's list of available networks. You can select it from the dropdown list at the top of the Metamask interface.
You can fund your account with testnet Ether from the Goerli Authenticated faucet as long as you are willing to make a post on a valid Twitter or Facebook account. If this is not something you wish to do, there is an alternative faucet at goerli-faucet.slock.it
Mumbai
You can either open mumbai.polygonscan.com and click on "Add Mumbai Network" in the footer at the bottom of the page or add it manually using the following information:
- Network Name: Polygon Mumbai testnet
- RPC URL: https://rpc-mumbai.maticvigil.com/
- Chain ID: 80001
- Currency Symbol: MATIC
- Block Explorer URL: https://mumbai.polygonscan.com/
You can fund your Mumbai account with MATIC here
MLBn ERC-721 Contract
ERC-721
is a standard for representing ownership of non-fungible tokens. A non-fungible token can represent a piece of data stored somewhere, in a blockchain, decentralized storage, or ...
MLBNft
is the contract we have already deployed on the Goerli testnet. It is a standard OpenZeppelin ERC-721.
This contract has a MLBn
NFT, which was only minted once with TokenID 1.
We'll map this contract and transfer the NFT.
You can find a step-by-step guide to creating an ERC-721 token here
Contract info:
Name: MLBNft
Token: MLBn
Balance: 1
TokenID: 1
Owner: 0xD8f24D419153E5D03d614C5155f900f4B5C8A65C
Contract Address: 0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b
goerli etherscan https://goerli.etherscan.io/address/0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b
Gather this information for the contract you intend to map.
Wallet Balance using Web3js
ERC-721 contracts have a balanceOf
function. It takes Wallet Address as an argument and returns the balance.
If you have already configured a provider and the truffle-hdwallet-provider, you can use this function to check your ERC-721 token info and balance.
async function getBalance(web3, contract, contractAddress)
{
let contractInstance = new web3.eth.Contract(ABI, contractAddress);
let result = await contractInstance.methods.balanceOf(address).call()
console.log(`Balance:` , result);
}
The balanceOf
output for our contract in Goerli would be like:
Balance: 1
And for Mumbai:
Balance: 0
The full source code is:
const Web3 = require("web3");
const HDWalletProvider = require("@truffle/hdwallet-provider")
const secrets = require('./secrets.json')
let address = "0xD8f24D419153E5D03d614C5155f900f4B5C8A65C"; // The address to check balance of
let contractAddressGoerli = "0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b"; // Goerli Contract Address
let contractAddressInMumbai = "0xf6320326327c07759602423f01D8fad4AF9E3f24"; // Mumbai Contract Address
let ABI = [{"inputs":[],"s..."}]
// const provider = new HDWalletProvider(secrets.privateKey, secrets.localGeth); // Goerli Provider
const provider = new HDWalletProvider(secrets.privateKey, secrets.mumbai); // mumbai Provider
const web3 = new Web3(provider);
(async () => {
try
{
await getBalance()
}
catch (e)
{
console.log(e)
}
})()
async function getBalance() {
let contractInstance = new web3.eth.Contract(ABI, contractAddressInMumbai);
let result = await contractInstance.methods.balanceOf(address).call()
console.log(`Balance` , result);
}
secrets.json
: contains Seed, privateKey of the address (0xd8f2). And Mumbai API URL. ex:
{
"privateKey": "This should be the private key of an account specifically made for use on the Goerli testnet",
"seed": "This should be a Secret Recovery Phrase from Metamask and ONLY used on Ethereum testnets",
"mumbai": "https://matic-mumbai--jsonrpc.datahub.figment.io/apikey/YOUR_API_KEY/",
"localGeth": "http://127.0.0.1:8545"
}
Mapping
Now that everything is ready. Let's map our MLBn
contract.
Go to mapper.matic.today and complete the form
Make sure the token you want to map has had its contract verified on Etherscan
Choose Gorli Testnet -> Mumbai testnet
At this time the mapping process is not immediate. It can take up to 3 days to be confirmed.
Then open mapper.matic.today, and enter the contract address to see if it has been added.
As you may notice, the contract address in Goerli and Mumbai is not the same. Let's remember to add it to Metamask so it shows up when we are connected to Mumbai as well.
- Open Metamask
- Select the Mumbai testnet from the list of available networks
- Add Token
- Paste the contract address there (
0xf6320326327c07759602423f01D8fad4AF9E3f24
)
We don't yet have any tokens in Mumbai. We can transfer some across the bridge and check our balance again afterward.
Transfer using SDK
Let's take a look at the workflow for transferring tokens with the SDK:
- Approve: The owner of the token has to approve the Ethereum Predicate Contract which will lock the amount of token they want to transfer to Polygon.
- Deposit: Then a function has to be called on the RootChainManger contract which will trigger the ChildChainManager contract on the Mumbai testnet. The ChildChainManager contract will then call the deposit function of the Child token contract.
The Child contract is the copy of the Goerli testnet token contract in Mumbai testnet.
Providers
To interact with Goerli and Mumbai we can either run a local node (which is slightly more difficult) or use the RPC endpoints of infrastructure providers like DataHub or Infura (which is much simpler).
For Goerli, we will run a local Geth node. You can also use infura.
For Mumbai, we will use DataHub
Goerli:
Install the Geth client, if you have not installed it already, then run:
geth --goerli --ws --http --syncmode=light --http.api="eth,net,web3,personal,txpool" --allow-insecure-unlock --http.corsdomain "*"
The default endpoint is 127.0.0.1:8545
.
You can get attached and see if everything is fine:
geth attach http://127.0.0.1:8545
eth.getBalance("0xD8f24D419153E5D03d614C5155f900f4B5C8A65C")
Mumbai:
- Sign up for a DataHub
- Choose the Polygon service from the DataHub Services Dashboard
- Scroll down to see the Polygon endpoint URLs
- Form the URL like so, replacing the text YOUR_API_KEY with the API key you got from DataHub:
https://matic-mumbai--jsonrpc.datahub.figment.io/apikey/YOUR_API_KEY/
Installing helpers
Now that we have the information we need and the other important pieces in place, we can write some useful code using the maticjs client library and the HDWalletProvider class from Truffle.
We will need to install both of these packages on the commandline with the Node package manager. Use these commands to install the packages and save them in the project manifest, package.json
:
npm install @maticnetwork/maticjs --save
npm install @truffle/hdwallet-provider --save
Approve
To approve the Ethereum Predicate Contract we just need to call the approveERC721ForDeposit
function. The code for this is straightforward:
await maticPOSClient.approveERC721ForDeposit(rootToken, tokenId, {
from: user
})
Deposit
Next, we would call the depositERC721ForUser
function of the Ethereum Predicate Contract:
await maticPOSClient.depositERC721ForUser(
rootToken,
user,
tokenId.toString(),
{ from: user }
)
To bring it all together in JavaScript that can be executed either in a web browser or on the commandline, we can add some constants and use an external file to hold the sensitive API keys and wallet seed phrases. This is a complete example of how to use maticjs and the HDWalletProvider class to communicate with a deployed smart contract on Polygon. Use the following code as a guide for building your own solution!
// main.js
const HDWalletProvider = require("@truffle/hdwallet-provider")
const { MaticPOSClient } = require("@maticnetwork/maticjs")
const secrets = require("./secrets.json")
let user = "0xD8f24D419153E5D03d614C5155f900f4B5C8A65C"
let rootToken = "0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b" // Goerli Contract Address
let childToken = "0xf6320326327c07759602423f01D8fad4AF9E3f24" // Goerli Contract Address
let tokenId = 3 // Token ID
const parentProvider = new HDWalletProvider(secrets.seed, "http://127.0.0.1:8545") // // Local Geth client address
const maticProvider = new HDWalletProvider(secrets.seed, secrets.mumbai) // DataHub Mumbai Testnet JSONRPC URL
const maticPOSClient = new MaticPOSClient({
network: "testnet",
version: "mumbai",
parentProvider,
maticProvider
});
(async () =>
{
try
{
let result = await maticPOSClient.approveERC721ForDeposit(rootToken, tokenId, {
from: user,
gasPrice: "10000000000"
})
console.log(result)
let result_2 = await maticPOSClient.depositERC721ForUser(
rootToken,
user,
tokenId.toString(),
{
from: user,
gasPrice: "10000000000"
}
)
console.log(result_2)
}
catch (error)
{
console.log(error)
}
})()
The expected output for approveERC721ForDeposit is something like this:
{
blockHash: '0xc20d22a31da707a97930a0277dfdc64c4d1fdabe619ee8d5c30e8ec4429de8a7',
blockNumber: 5611016,
contractAddress: null,
cumulativeGasUsed: 29345,
effectiveGasPrice: '0x87ffb64b',
from: '0xd8f24d419153e5d03d614c5155f900f4b5c8a65c',
gasUsed: 29345,
logsBloom: '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000240000000000000000000000000000000000000000000000040000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000800000000000000000000000000000000000004000000020000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000100000000000000000400040000010000000000000000800000000000000000000000000000000000000000000',
status: true,
to: '0xcce32d5a6b433972fa3ff21233470d60ab7afd6b',
transactionHash: '0xe397fe030e14402ecfd4920a2e4dc32f3adb7b2cd8baa6df2cb7fa22decd4b8c',
transactionIndex: 0,
type: '0x0',
events: {
Approval: {
address: '0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b',
blockNumber: 5611016,
transactionHash: '0xe397fe030e14402ecfd4920a2e4dc32f3adb7b2cd8baa6df2cb7fa22decd4b8c',
transactionIndex: 0,
blockHash: '0xc20d22a31da707a97930a0277dfdc64c4d1fdabe619ee8d5c30e8ec4429de8a7',
logIndex: 0,
removed: false,
id: 'log_3c37d4f8',
returnValues: [Result],
event: 'Approval',
signature: '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925',
raw: [Object]
}
}
}
And for depositERC721ForUser:
{
blockHash: '0xb353ae7b47953a6bec8c2a0d01852dbb917d4b018cf3d8243d1fd69cdde3ee56',
blockNumber: 5611018,
contractAddress: null,
cumulativeGasUsed: 268430,
effectiveGasPrice: '0x87ffb64b',
from: '0xd8f24d419153e5d03d614c5155f900f4b5c8a65c',
gasUsed: 111868,
logsBloom: '0x0000000000000000000000000002000000000008000840000000000000000000000400000000000000040108010000000000080000000000000000000024000000004000400000001000000a000000000000000000040000000000100000000000000000020000000000000400000810000000000000000000000010000000000000000800000000000000000000000000000000000004000000020000080000020000000200000000000000000000000400000000000800000000000000000000000002000000000000000000000102000000100000000000000000400060000010000000000000000800000000000000000000000000000000000000000000',
status: true,
to: '0xbbd7cbfa79faee899eaf900f13c9065bf03b1a74',
transactionHash: '0x5167f0d875aeeb0b9a695be6acbcfe568039f7e43084d58f6211dbf14ec8336f',
transactionIndex: 2,
type: '0x0',
events: {
'0': {
address: '0x74D83801586E9D3C4dc45FfCD30B54eA9C88cf9b',
blockNumber: 5611018,
transactionHash: '0x5167f0d875aeeb0b9a695be6acbcfe568039f7e43084d58f6211dbf14ec8336f',
transactionIndex: 2,
blockHash: '0xb353ae7b47953a6bec8c2a0d01852dbb917d4b018cf3d8243d1fd69cdde3ee56',
logIndex: 2,
removed: false,
id: 'log_bed4a03e',
returnValues: Result {},
event: undefined,
signature: null,
raw: [Object]
},
'1': {
address: '0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b',
blockNumber: 5611018,
transactionHash: '0x5167f0d875aeeb0b9a695be6acbcfe568039f7e43084d58f6211dbf14ec8336f',
transactionIndex: 2,
blockHash: '0xb353ae7b47953a6bec8c2a0d01852dbb917d4b018cf3d8243d1fd69cdde3ee56',
.
.
.
Just a few things to mention:
secrets.json
: contains Seed, privateKey of the address (0xd8f2). And Mumbai API URL. ex:
{
"privateKey": "This should be the private key of an account specifically made for use on the Goerli testnet",
"seed": "This should be a Secret Recovery Phrase from Metamask and ONLY used on Ethereum testnets",
"mumbai": "https://matic-mumbai--jsonrpc.datahub.figment.io/apikey/YOUR_API_KEY/"
}
@truffle/hdwallet-provider
: Handles signing transactions processfrom
: The Goerli address we created token and want to send transactions withrootToken
: The ERC-721 contract address on the Goerli testnetamount
: the amount of token we want to transfer.
Potential errors and solutions
Not able to run main.js
If you are facing an error message like
Error: execution reverted: RootChainManager: TOKEN_NOT_MAPPED
The contract has not been mapped yet.
Geth not running
If you have not run Geth, you will get an error like this
Error: PollingBlockTracker - encountered an error while attempting to update latest block:
Error: connect ECONNREFUSED 127.0.0.1:8545
No peers available
If you get an error like this
{
code: -32000,
message: 'getDeleteStateObject (0000000000000000000000000000000000000000) error: no suitable peers available'
}
Wait a bit and try again
Sync & Confirmation
It takes up to 5 minutes for Mumbai to read data from the Goerli chain and sync itself. Once it has synced, then we can check the token balance in Metamask or via web3js
.
The balanceOf output for our contract in Goerli would be:
Balance: 0
And for Mumbai:
Balance: 1
Withdraw using SDK
Now if we want to transfer our tokens from Mumbai to Goerli, similar before, we can call burnERC721
and exitERC721
functions in order.
Let's take a look at the workflow for withdrawing tokens with the SDK:
- We will call the burn function of the Child token contract, which will burn the tokens.
- We will call the exit function of the RootChainManager contract, that submit the proof of burn and transfer the tokens.
Burn
To burn the tokens we just need to call the burnERC721
function. The code for this is straightforward:
let result = await maticPOSClient.burnERC721(childToken, tokenId, {
from: user
})
The expected output is something like this:
{
blockHash: '0x5e259cb402d7537b3d030e511865ab5e0aeb13416c72a52846fcceb2785788d8',
blockNumber: 19748463,
contractAddress: null,
cumulativeGasUsed: 1088161,
effectiveGasPrice: '0xb2d05e00',
from: '0xd8f24d419153e5d03d614c5155f900f4b5c8a65c',
gasUsed: 35319,
logsBloom: '0x00000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000018020000000000000000000240000000000000000000000000008000000800001000000040000000100000000000000000000020000000000000400000800000000000000000080000010000000000000000000000000000000000000000000000000000000000000020000000000220000000000000000000000000000000000000000000000000000000000024800000002000000000001000000000000000000100000000000100000000060000010000000000000000000000000000000000000000000000000000000100000',
status: true,
to: '0xf6320326327c07759602423f01d8fad4af9e3f24',
transactionHash: '0x09400584a1eabdf85fc491bbbfbc9d5283905478e76fd6a5d7d22bb63e0510fa',
transactionIndex: 4,
type: '0x0',
events: {
'0': {
address: '0x0000000000000000000000000000000000001010',
blockNumber: 19748463,
transactionHash: '0x09400584a1eabdf85fc491bbbfbc9d5283905478e76fd6a5d7d22bb63e0510fa',
transactionIndex: 4,
blockHash: '0x5e259cb402d7537b3d030e511865ab5e0aeb13416c72a52846fcceb2785788d8',
logIndex: 9,
removed: false,
id: 'log_8aa0a436',
returnValues: Result {},
event: undefined,
signature: null,
raw: [Object]
},
Approval: {
address: '0xf6320326327c07759602423f01D8fad4AF9E3f24',
blockNumber: 19748463,
transactionHash: '0x09400584a1eabdf85fc491bbbfbc9d5283905478e76fd6a5d7d22bb63e0510fa',
transactionIndex: 4,
blockHash: '0x5e259cb402d7537b3d030e511865ab5e0aeb13416c72a52846fcceb2785788d8',
logIndex: 7,
removed: false,
id: 'log_a24d5618',
returnValues: [Result],
event: 'Approval',
signature: '0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925',
raw: [Object]
},
Transfer: {
address: '0xf6320326327c07759602423f01D8fad4AF9E3f24',
blockNumber: 19748463,
transactionHash: '0x09400584a1eabdf85fc491bbbfbc9d5283905478e76fd6a5d7d22bb63e0510fa',
transactionIndex: 4,
blockHash: '0x5e259cb402d7537b3d030e511865ab5e0aeb13416c72a52846fcceb2785788d8',
logIndex: 8,
removed: false,
id: 'log_eb4348bf',
returnValues: [Result],
event: 'Transfer',
signature: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef',
raw: [Object]
}
}
}
Keep the transactionHash
value. We should pass it to the exit
function.
Exit
Once the checkpoint has been submitted, we can call the exitERC721
function with the transaction hash we got by calling burnERC721
:
Note that it may take some time for the checkpoint to get submitted and included. You can check if it is included or not by sending the block number to this API: https://apis.matic.network/api/v1/mumbai/block-included/THE_BLOCK_NUMBER
await maticPOSClient.exitERC721(
burnTxHash, // transactionHash: 0x09400584a1eabdf85fc491bbbfbc9d5283905478e76fd6a5d7d22bb63e0510fa
{ from: user }
)
The expected output is something like this:
{
from: '0xD8f24D419153E5D03d614C5155f900f4B5C8A65C',
gas: 285406,
gasLimit: 285406,
gasPrice: '10000000000',
nonce: 74,
chainId: 5,
value: 0,
to: '0xBbD7cBFA79faee899Eaf900F13C9065bF03B1A74',
data: '0x3805550f00000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000a25f90a228421989dd0b901a037f4568e3b7554bb03b542d686e68448de982f4a254613497c08d7cb1f8e445d73e51ba1b6d9045
.
.
.
}
The full source code is:
// main.js
const HDWalletProvider = require("@truffle/hdwallet-provider")
const { MaticPOSClient } = require("@maticnetwork/maticjs")
const secrets = require("./secrets.json")
let user = "0xD8f24D419153E5D03d614C5155f900f4B5C8A65C"
let rootToken = "0xcCE32d5A6B433972fA3Ff21233470D60ab7AFD6b" // Goerli Contract Address
let childToken = "0xf6320326327c07759602423f01D8fad4AF9E3f24" // Mumbai Contract Address
let tokenId = 4 // Token ID
const parentProvider = new HDWalletProvider(secrets.seed, secrets.goerli) // Goerli provider address
const maticProvider = new HDWalletProvider(secrets.seed, secrets.mumbai) // DataHub Mumbai Testnet JSONRPC URL
const maticPOSClient = new MaticPOSClient({
network: "testnet",
version: "mumbai",
parentProvider,
maticProvider
});
(async () =>
{
try
{
// STEP 1:
// let result = await maticPOSClient.burnERC721(childToken, tokenId, {
// from: user
// })
// console.log(result)
// STEP 2:
let burnTxHash = "0x09400584a1eabdf85fc491bbbfbc9d5283905478e76fd6a5d7d22bb63e0510fa" // result.transactionHash
let result_2 = await maticPOSClient.exitERC721(
burnTxHash,
{ from: user,
encodeAbi: true,
gasPrice: "10000000000"
}
)
console.log(result_2)
}
catch (error)
{
console.log(error)
}
})()
Transfer using Web UI
Transferring assets through Web UI is pretty simple. Just like the SDK, there is the Approve and a Deposit steps.
Open MintNFT Bridge
Make sure Goerli Testnet is selected in Metamask
Enter the contract address in Goerli and Mumbai, and ID of the token you want to transfer. We put 1 (MLBn)
Click on Approve
Then review the transaction details, like gas fees and the smart contract you are sending tokens to - before clicking on Confirm
Now, wait until you receive confirmations. Metamask and the website both will send a notification.
Now Click on Deposit
Once the transaction is mined, the process is complete! It takes about 7 minutes to complete the transfer. As mentioned before Polygon needs about 5 minutes to sync.
Conclusion
Congratulations! By completing this tutorial you learned how to use the Polygon PoS Bridge. We have configured Metamask and Geth, to communicate with the Goerli testnet and the Mumbai testnet. We then mapped an ERC-721 contract between the networks so it can be transferred via the bridge. Finally, we called functions on the PoS Bridge contracts and moved our assets from Ethereum to Polygon.
About The Author
I'm mlibre, a random guy from the solar galaxy. I am interested in blockchain tech and find it very useful for lots of things.
Feel free to check my Github