Skip to main content
This guide walks through deploying a sender contract on Sepolia (Ethereum testnet) and a receiver contract on Amoy (Polygon testnet), then sending and verifying arbitrary data across the bridge. For background on how state sync works, see the state sync architecture docs.
The contracts and scripts in this guide are simplified illustrations. Do not deploy them to production without a thorough security review and audit appropriate for your use case.
Looking to bridge a token to Polygon PoS using the official bridge? Check out the guide on how to submit a request to get your token mapped.
1
Deploy sender contract
2
The sender contract calls syncState on the StateSender contract on Ethereum. This emits a StateSynced event that Heimdall validators pick up and relay to Bor.
3
StateSender is deployed at:
4
  • 0x49E307Fa5a58ff1834E0F8a60eB2a9609E6A5F50 on Sepolia
  • 0x28e4F3a7f651294B9564800b2D01f35189A5bFbE on Ethereum mainnet
  • 5
    pragma solidity ^0.8.0;
    
    interface IStateSender {
        function syncState(address receiver, bytes calldata data) external;
    }
    
    contract Sender {
        address public stateSenderContract;
        address public receiver;
        uint public states;
    
        constructor(address _stateSender, address _receiver) {
            stateSenderContract = _stateSender;
            receiver = _receiver;
        }
    
        function sendState(bytes calldata data) external {
            states += 1;
            IStateSender(stateSenderContract).syncState(receiver, data);
        }
    }
    
    6
    Deploy Sender.sol to Sepolia, passing in the StateSender address and your receiver contract address. Note the deployed address and ABI.
    7
    Deploy receiver contract
    8
    The receiver contract is invoked by the StateReceiver system contract on Bor whenever a matching StateSynced event is processed.
    9

    Verify the caller

    Always check that msg.sender is the StateReceiver contract at 0x0000000000000000000000000000000000001001. Without this check, any address on Polygon can call onStateReceive on your contract directly.
    10
    pragma solidity ^0.8.0;
    
    interface IStateReceiver {
        function onStateReceive(uint256 stateId, bytes calldata data) external;
    }
    
    contract Receiver is IStateReceiver {
        address constant STATE_RECEIVER = 0x0000000000000000000000000000000000001001;
    
        uint public lastStateId;
        bytes public lastData;
    
        function onStateReceive(uint256 stateId, bytes calldata data) external {
            require(msg.sender == STATE_RECEIVER, "unauthorized");
            lastStateId = stateId;
            lastData = data;
        }
    }
    
    11
    stateId is a monotonically increasing counter that uniquely identifies each state sync event. Deploy Receiver.sol to Amoy and note the deployed address and ABI.
    12
    Register your sender and receiver contracts
    13
    Before state sync will trigger your receiver, your sender/receiver pair must be registered with the StateSender contract. This registration is what authorizes the pair: only events originating from a registered sender contract targeting a registered receiver will be relayed by Heimdall validators.
    14
    Registration for new custom sender/receiver pairs is managed by the Polygon team. Contact the team on Discord or submit a request via the mapping form to initiate the process.
    15
    If you want to test end-to-end before your custom contracts are registered, you can use the already-registered example contracts referenced in the form.
    16
    Send and verify data
    17
    With contracts deployed and registered, use the following script to send arbitrary bytes from Sepolia and verify receipt on Amoy. State sync takes approximately 7 to 8 minutes to complete.
    18
    const { Web3 } = require('web3');
    
    const main = new Web3('<SEPOLIA_RPC_URL>');
    const matic = new Web3('<AMOY_RPC_URL>');
    
    const privateKey = '0x...';
    main.eth.accounts.wallet.add(privateKey);
    
    const senderAddress = '<SENDER_CONTRACT_ADDRESS>';
    const senderABI = [/* paste ABI here */];
    const receiverAddress = '<RECEIVER_CONTRACT_ADDRESS>';
    const receiverABI = [/* paste ABI here */];
    
    const sender = new main.eth.Contract(senderABI, senderAddress);
    const receiver = new matic.eth.Contract(receiverABI, receiverAddress);
    
    async function sendData(message) {
        const data = matic.utils.asciiToHex(message);
        const tx = await sender.methods.sendState(data).send({
            from: main.eth.accounts.wallet[0].address,
            gas: 200000,
        });
        console.log('Sent from Ethereum:', tx.transactionHash);
    }
    
    async function checkReceiver() {
        const stateId = await receiver.methods.lastStateId().call();
        const rawData = await receiver.methods.lastData().call();
        const message = matic.utils.hexToAscii(rawData);
        console.log('Last state ID:', stateId);
        console.log('Received message:', message);
    }
    
    async function run() {
        await sendData('Hello from Ethereum!');
        console.log('Waiting ~8 minutes for state sync...');
        setTimeout(checkReceiver, 8 * 60 * 1000);
    }
    
    run();
    
    19
    Successful output looks like:
    20
    Sent from Ethereum: 0x4f64ae4ab4d2b2d2dc82cdd9ddae73af026e5a9c46c086b13bd75e38009e5204
    Waiting ~8 minutes for state sync...
    Last state ID: 453
    Received message: Hello from Ethereum!