$cat/blog/cross-chain-development.md1424 words | 8 min
//

Building Cross-Chain dApps: Solana + Ethereum Complete Guide

#cross-chain development#Solana Ethereum bridge#multi-chain dApp
article.md

We built a cross-chain platform for an influencer project that hit $3M market cap. Solana NFTs that burned for Ethereum membership. Here's the technical breakdown and lessons from shipping cross-chain in production.

The Project: Solana NFT to Ethereum Token

The concept was simple. Buy an NFT on Solana (cheap, fast). Burn it to claim an ERC-20 membership token on Ethereum (established ecosystem).

Why cross-chain:

  • Solana: Low mint costs ($0.01 vs $10+ on Ethereum)
  • Ethereum: Established DeFi ecosystem for the token
  • User choice: Collect NFTs or convert to liquid tokens

Results:

MetricValue
Solana NFTs minted8,500
NFTs burned for Ethereum tokens3,200
Peak token market cap$3M
Development time3 months

Solana vs Ethereum: Technical Differences

Before building cross-chain, understand how different these chains really are.

Programming model:

AspectSolanaEthereum
LanguageRustSolidity
Account modelAccount-based with programsContract-based
State storageSeparate from logicCombined in contract
Gas/feesCompute unitsGas
Finality~400ms~12 min (safe)

Solana's account model:

// Solana: State is in accounts, programs are stateless
#[account]
pub struct NftData {
    pub owner: Pubkey,
    pub burned: bool,
    pub burn_signature: [u8; 64],
}

// Program operates on accounts passed to it
pub fn burn_nft(ctx: Context<BurnNft>) -> Result<()> {
    let nft = &mut ctx.accounts.nft_data;
    nft.burned = true;
    nft.burn_signature = generate_signature();
    Ok(())
}

Ethereum's contract model:

// Ethereum: State and logic in same contract
contract MembershipToken is ERC20 {
    mapping(bytes32 => bool) public claimed;

    function claimFromSolana(
        bytes32 burnProof,
        bytes memory signature
    ) external {
        require(!claimed[burnProof], "Already claimed");
        require(verifySolanaSignature(burnProof, signature), "Invalid");

        claimed[burnProof] = true;
        _mint(msg.sender, MEMBERSHIP_AMOUNT);
    }
}

Architecture Options

Three main approaches to cross-chain communication.

Option 1: Centralized relay (what we used)

Solana Program → Off-chain Indexer → Ethereum Contract

Pros: Simple, fast, cheap Cons: Centralized, trust required

Option 2: LayerZero / Wormhole

Solana Program → Bridge Protocol → Ethereum Contract

Pros: Decentralized, trustless Cons: Complex, expensive, dependency risk

Option 3: Custom oracle network

Solana Program → Multiple Validators → Ethereum Contract

Pros: Semi-decentralized, customizable Cons: Complex to build, ongoing costs

Why we chose centralized relay:

For a $3M project, the complexity and cost of decentralized bridges wasn't justified. Users trusted the project team anyway. We could ship in weeks instead of months.

Solana Program Implementation

The Solana side handles NFT minting and burning.

Program structure:

use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Mint};

declare_id!("YOUR_PROGRAM_ID");

#[program]
pub mod cross_chain_nft {
    use super::*;

    pub fn initialize(ctx: Context<Initialize>) -> Result<()> {
        let state = &mut ctx.accounts.state;
        state.authority = ctx.accounts.authority.key();
        state.total_burned = 0;
        Ok(())
    }

    pub fn burn_for_ethereum(
        ctx: Context<BurnForEthereum>,
        ethereum_address: [u8; 20]
    ) -> Result<()> {
        // Burn the NFT
        token::burn(
            CpiContext::new(
                ctx.accounts.token_program.to_account_info(),
                token::Burn {
                    mint: ctx.accounts.nft_mint.to_account_info(),
                    from: ctx.accounts.nft_account.to_account_info(),
                    authority: ctx.accounts.owner.to_account_info(),
                },
            ),
            1,
        )?;

        // Record burn for indexer
        let burn_record = &mut ctx.accounts.burn_record;
        burn_record.nft_mint = ctx.accounts.nft_mint.key();
        burn_record.owner = ctx.accounts.owner.key();
        burn_record.ethereum_address = ethereum_address;
        burn_record.timestamp = Clock::get()?.unix_timestamp;
        burn_record.claimed = false;

        // Increment counter
        let state = &mut ctx.accounts.state;
        state.total_burned += 1;

        emit!(BurnEvent {
            nft_mint: ctx.accounts.nft_mint.key(),
            ethereum_address,
            burn_index: state.total_burned,
        });

        Ok(())
    }
}

#[derive(Accounts)]
pub struct BurnForEthereum<'info> {
    #[account(mut)]
    pub owner: Signer<'info>,

    #[account(mut)]
    pub nft_mint: Account<'info, Mint>,

    #[account(
        mut,
        constraint = nft_account.owner == owner.key(),
        constraint = nft_account.amount == 1
    )]
    pub nft_account: Account<'info, TokenAccount>,

    #[account(
        init,
        payer = owner,
        space = 8 + BurnRecord::SIZE,
        seeds = [b"burn", nft_mint.key().as_ref()],
        bump
    )]
    pub burn_record: Account<'info, BurnRecord>,

    #[account(mut)]
    pub state: Account<'info, ProgramState>,

    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
}

#[account]
pub struct BurnRecord {
    pub nft_mint: Pubkey,
    pub owner: Pubkey,
    pub ethereum_address: [u8; 20],
    pub timestamp: i64,
    pub claimed: bool,
}

impl BurnRecord {
    pub const SIZE: usize = 32 + 32 + 20 + 8 + 1;
}

#[event]
pub struct BurnEvent {
    pub nft_mint: Pubkey,
    pub ethereum_address: [u8; 20],
    pub burn_index: u64,
}

Ethereum Contract Implementation

The Ethereum side verifies burns and mints tokens.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

contract CrossChainMembership is ERC20, Ownable {
    using ECDSA for bytes32;

    address public relayer;
    uint256 public constant TOKENS_PER_NFT = 1000 * 10**18;

    mapping(bytes32 => bool) public claimed;
    mapping(bytes32 => ClaimData) public claims;

    struct ClaimData {
        address claimer;
        uint256 timestamp;
        bytes32 solanaTxHash;
    }

    event Claimed(
        address indexed claimer,
        bytes32 indexed burnProof,
        uint256 amount
    );

    constructor(address _relayer) ERC20("Membership", "MEMBER") Ownable(msg.sender) {
        relayer = _relayer;
    }

    function claimFromSolana(
        bytes32 burnProof,
        bytes32 solanaTxHash,
        bytes memory signature
    ) external {
        require(!claimed[burnProof], "Already claimed");

        // Verify relayer signature
        bytes32 messageHash = keccak256(
            abi.encodePacked(
                msg.sender,
                burnProof,
                solanaTxHash,
                block.chainid
            )
        );
        bytes32 ethSignedHash = messageHash.toEthSignedMessageHash();
        require(ethSignedHash.recover(signature) == relayer, "Invalid signature");

        // Mark as claimed
        claimed[burnProof] = true;
        claims[burnProof] = ClaimData({
            claimer: msg.sender,
            timestamp: block.timestamp,
            solanaTxHash: solanaTxHash
        });

        // Mint tokens
        _mint(msg.sender, TOKENS_PER_NFT);

        emit Claimed(msg.sender, burnProof, TOKENS_PER_NFT);
    }

    function setRelayer(address _relayer) external onlyOwner {
        relayer = _relayer;
    }

    // Emergency functions
    function pause() external onlyOwner {
        // Implement pause logic
    }
}

The Relay/Indexer

The bridge between chains. This is the centralized piece.

// Simplified indexer logic
import { Connection, PublicKey } from '@solana/web3.js';
import { ethers } from 'ethers';

class CrossChainRelay {
    private solanaConnection: Connection;
    private ethWallet: ethers.Wallet;
    private contract: ethers.Contract;

    async indexBurnEvents() {
        // Subscribe to Solana program logs
        this.solanaConnection.onLogs(
            PROGRAM_ID,
            async (logs) => {
                const burnEvent = this.parseBurnEvent(logs);
                if (burnEvent) {
                    await this.processBurn(burnEvent);
                }
            }
        );
    }

    async processBurn(event: BurnEvent) {
        // Generate proof
        const burnProof = ethers.keccak256(
            ethers.AbiCoder.defaultAbiCoder().encode(
                ['bytes32', 'bytes20', 'uint64'],
                [event.nftMint, event.ethereumAddress, event.burnIndex]
            )
        );

        // Sign for Ethereum claim
        const messageHash = ethers.keccak256(
            ethers.AbiCoder.defaultAbiCoder().encode(
                ['address', 'bytes32', 'bytes32', 'uint256'],
                [
                    ethers.getAddress('0x' + Buffer.from(event.ethereumAddress).toString('hex')),
                    burnProof,
                    event.txHash,
                    CHAIN_ID
                ]
            )
        );

        const signature = await this.ethWallet.signMessage(
            ethers.getBytes(messageHash)
        );

        // Store for user to claim
        await this.storePendingClaim({
            ethereumAddress: event.ethereumAddress,
            burnProof,
            solanaTxHash: event.txHash,
            signature
        });
    }
}

Security Considerations

Cross-chain adds attack surface. Here's what we worried about.

1. Replay attacks

Same burn proof used on multiple chains or multiple times.

// Include chain ID in signature
bytes32 messageHash = keccak256(
    abi.encodePacked(
        msg.sender,
        burnProof,
        solanaTxHash,
        block.chainid  // Prevents cross-chain replay
    )
);

2. Relayer compromise

If relayer key is stolen, attacker can mint unlimited tokens.

Mitigations:

  • Multi-sig relayer
  • Rate limiting on contract
  • Maximum supply cap

3. Solana reorgs

Solana transactions can be dropped. Wait for finality.

// Wait for confirmation
const confirmation = await connection.confirmTransaction(
    signature,
    'finalized'  // Not 'processed' or 'confirmed'
);

4. Front-running claims

Someone sees burn and claims before legitimate owner.

// Ethereum address specified in Solana burn
// Only that address can claim
require(
    msg.sender == specifiedEthereumAddress,
    "Wrong claimer"
);

Development Timeline

Cross-chain takes longer than you think.

PhaseDurationWhat We Built
Design2 weeksArchitecture, security model
Solana program3 weeksMint, burn, events
Ethereum contract2 weeksClaim, verification
Indexer/relay3 weeksEvent processing, signatures
Frontend2 weeksWallet connection, UX
Testing2 weeksIntegration, edge cases
Audit prep1 weekDocumentation, cleanup
Total15 weeksFull cross-chain system

Compare to single-chain:

Single-chainDuration
Smart contract2 weeks
Frontend1 week
Testing1 week
Total4 weeks

Cross-chain added 11 weeks of development time.

When to Go Cross-Chain

Good reasons:

  • Leveraging specific chain strengths (Solana speed, Ethereum liquidity)
  • User base on multiple chains
  • Arbitrage opportunities between chains

Bad reasons:

  • "It sounds cool"
  • Following trends
  • Adding complexity for marketing

Our recommendation:

Start single-chain. Add cross-chain only when you have:

  • Proven product-market fit
  • Resources for 3x development time
  • Budget for additional security audits
  • Team with experience on both chains

Resources

Solana Development:

Ethereum Development:

Cross-Chain Infrastructure:

Testing:

Development Services:

continue_learning.sh

# Keep building your smart contract knowledge

$ More guides from 100+ contract deployments generating $250M+ volume