diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 1)/README.md b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 1)/README.md index 69fb64f..964f0e7 100644 --- a/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 1)/README.md +++ b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 1)/README.md @@ -1,5 +1,6 @@ --- -published: true + +## published: true title: "How to Migrate an Ethereum Protocol to Solana — Contracts (Part 1)" author: ["Jimmy Zhao / Fullstack Engineer", "Bin Li / Tech Lead"] createTime: 2026-01-26 @@ -10,7 +11,6 @@ landingPages: ["Blockchain-Onchain infra"] thumb: "./thumb.png" thumb_h: "./thumb_h.png" intro: "A deep dive into the core mindset shift and best practices when moving contracts from Ethereum to Solana." ---- ## Article Overview @@ -24,6 +24,9 @@ In this article, we focus specifically on the smart contract layer. Rather than - [How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering): A systematic introduction to the fundamental differences between Ethereum and Solana in account models, execution mechanisms, and fee systems. - [How to Migrate an Ethereum Protocol to Solana — Contracts (Part 1)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-1?tab=engineering): A focus on the core mindset shift and best practices for contract development from Ethereum to Solana. +- [How to Migrate an Ethereum Protocol to Solana — Contracts (Part 2)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-2): focuses on limitations and shortcomings in Solana contract development, and demonstrates how to migrate an Ethereum contract to Solana through a concrete staking example. +- [How to Migrate an Ethereum Protocol to Solana — Frontend(Part 1)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-frontend-part-1): A focus on the frontend mindset shift and best practices for building Solana-native DApp infrastructure from Ethereum. +- [How to Migrate an Ethereum Protocol to Solana — Frontend(Part 2)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-frontend-part-2): A practical demonstration of the fundamental differences between Ethereum and Solana frontend development in wallet integration, state reading, transaction workflows, and event handling. --- @@ -77,11 +80,11 @@ pub mod staking { pub fn stake(ctx: Context, amount: u64) -> Result<()> { // Business logic operates on accounts passed via the context. // The context `ctx` contains all necessary accounts, - // such as `GlobalState` and `UserStakeInfo`, defined in the `Stake` struct below. - let state = &mut ctx.accounts.state; - let user_info = &mut ctx.accounts.user_stake_info; - state.total_staked += amount; - user_info.amount += amount; + // such as `PoolConfig`, `PoolState`, and `UserStakeInfo`. + let pool_state = &mut ctx.accounts.pool_state; + let user_stake_info = &mut ctx.accounts.user_stake_info; + pool_state.total_staked += amount; + user_stake_info.amount += amount; // ... Ok(()) } @@ -92,27 +95,63 @@ pub mod staking { #[derive(Accounts)] pub struct Stake<'info> { #[account(mut)] - pub state: Account<'info, GlobalState>, - #[account(mut)] - pub user_stake_info: Account<'info, UserStakeInfo>, - // ... other necessary accounts + pub user: Signer<'info>, + #[account( + seeds = [POOL_CONFIG_SEED, pool_config.pool_id.as_ref()], + bump = pool_config.bump + )] + pub pool_config: Box>, + #[account( + mut, + seeds = [POOL_STATE_SEED, pool_config.key().as_ref()], + bump = pool_state.bump, + has_one = pool_config + )] + pub pool_state: Box>, + #[account( + init_if_needed, + payer = user, + space = 8 + UserStakeInfo::INIT_SPACE, + seeds = [STAKE_SEED, pool_config.key().as_ref(), user.key().as_ref()], + bump + )] + pub user_stake_info: Box>, + pub system_program: Program<'info, System>, } // State is defined in separate account structs. #[account] -pub struct GlobalState { +#[derive(InitSpace)] +pub struct PoolConfig { + pub admin: Pubkey, + pub pool_id: Pubkey, + pub staking_mint: Pubkey, + pub reward_mint: Pubkey, + pub reward_per_second: u64, + pub bump: u8, +} + +#[account] +#[derive(InitSpace)] +pub struct PoolState { + pub pool_config: Pubkey, + pub acc_reward_per_share: u128, + pub last_reward_time: i64, pub total_staked: u64, - // ... other global state + pub total_reward_debt: i128, + pub bump: u8, } #[account] +#[derive(InitSpace)] pub struct UserStakeInfo { pub amount: u64, - // ... other user state + pub reward_debt: i128, + pub bump: u8, } ``` -Here, the `staking` program is stateless and holds no data. All data—both global `GlobalState` and per-user `UserStakeInfo`—are defined in separate `#[account]` structs. The program receives these accounts through the `Context` object (typed by the `Stake` struct), and then operates on them. +Here, the `staking` program is stateless and holds no data. All data—both pool-level `PoolConfig` / `PoolState` and per-user `UserStakeInfo`—are defined in separate `#[account]` structs. The program receives these accounts through the `Context` object (typed by the `Stake` struct), and then operates on them. This design's fundamental purpose is to enable large-scale [parallel processing](https://medium.com/solana-labs/sealevel-parallel-processing-thousands-of-smart-contracts-d814b378192). Because code and data are separated, Solana transactions will declare all accounts they will access ahead of execution and specify whether each account is read-only or writable. This allows the runtime to build a dependency graph and schedule transactions efficiently. If two transactions touch completely unrelated accounts—or both only read the same account—they can safely run in parallel. Only when one transaction needs to write to an account, other transactions that access that account (read or write) will be temporarily blocked and executed sequentially. With this fine-grained scheduling, Solana maximizes multi-core utilization to process many non-interfering transactions concurrently. This is a key element to its high throughput and low latency. @@ -147,7 +186,7 @@ pub struct Stake<'info> { #[account(mut)] pub user_token_account: Account<'info, TokenAccount>, #[account(mut)] - pub staking_vault: Account<'info, TokenAccount>, + pub staking_token: Account<'info, TokenAccount>, pub token_program: Program<'info, Token>, // ... } @@ -159,7 +198,7 @@ pub fn stake(ctx: Context, amount: u64) -> Result<()> { ctx.accounts.token_program.to_account_info(), Transfer { from: ctx.accounts.user_token_account.to_account_info(), - to: ctx.accounts.staking_vault.to_account_info(), + to: ctx.accounts.staking_token.to_account_info(), authority: ctx.accounts.user.to_account_info(), } ), @@ -216,13 +255,16 @@ async function stakeTokens( const userStakePda = getUserStakePda(statePda, user.publicKey); // All required accounts must be explicitly passed. + const userBlacklistPda = getBlacklistPda(statePda, user.publicKey); const stakeInstruction = programClient.getStakeInstruction({ user: userSigner, - state: address(statePda.toBase58()), + poolConfig: address(statePda.toBase58()), + poolState: address(poolStatePda.toBase58()), userStakeInfo: address(userStakePda.toBase58()), userTokenAccount: address(stakingToken.toBase58()), - stakingVault: address(stakingVaultPda.toBase58()), - // ... and other accounts + stakingToken: address(stakingTokenPda.toBase58()), + tokenProgram: address(TOKEN_PROGRAM_ID.toBase58()), + blacklistEntry: address(userBlacklistPda.toBase58()), amount: amount, }); @@ -230,7 +272,7 @@ async function stakeTokens( } ``` -In this TypeScript Test, calling the `stake` instruction requires a large account object: `user` (signer), `state` (global state account), `userStakeInfo` (user staking data account), `userTokenAccount` (the user's token account), `stakingVault` (the program's vault), etc. While this makes the client call more verbose, it brings transparency and safety. Before the transaction is sent, the client code explicitly defines all accounts included in the transaction. There are no hidden contextual dependencies in a Solana transaction. +In this TypeScript Test, calling the `stake` instruction requires a large account object: `user` (signer), `poolConfig` (pool config account), `poolState` (pool runtime state account), `userStakeInfo` (user staking data account), `userTokenAccount` (the user's token account), `stakingToken` (the program's staking token account), `blacklistEntry` (the user's blacklist PDA), etc. While this makes the client call more verbose, it brings transparency and safety. Before the transaction is sent, the client code explicitly defines all accounts included in the transaction. There are no hidden contextual dependencies in a Solana transaction. Additionally, on Ethereum, upgrading a contract often requires changing client code to point to a new contract address. On Solana, you simply deploy new program code to the same program ID, achieving seamless upgrades. All business data remains untouched in their accounts because data and logic are decoupled. Since the program address doesn’t change, client code remains compatible. @@ -240,8 +282,9 @@ If you want deeper architectural context for the code patterns in this article, To put these ideas into practice, you may want to get comfortable with a different, ecosystem-specific toolchain. From language to standard libraries, Solana's ecosystem differs significantly from Ethereum's ecosystem. The table below summarizes key differences to help you build a new understanding of the differences quickly. + | **Domain** | **Ethereum Ecosystem** | **Solana Ecosystem** | **Key Notes** | -| :------------------------ | :------------------------------------ | :---------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ------------------------- | ------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Frameworks** | Hardhat / Foundry (Solidity) | Anchor (Rust) | In the Ethereum ecosystem, Hardhat and Foundry are widely used smart contract development tools. Anchor is the de facto standard for Solana development; it uses powerful macros to greatly simplify the complexity of Solana program development. | | **Interface Standard** | ABI (Application Binary Interface) | IDL (Interface Definition Language) | Anchor automatically generates an IDL from your program code, similar to the ABI concept on Ethereum—ABI is Ethereum’s contract interaction standard, and the Solidity compiler automatically generates ABI files describing function/parameter/return binary encodings. Clients can use these IDL or ABI files to interact with your program without needing to understand the underlying implementation. | | **Standard Library** | OpenZeppelin | SPL (Solana Program Library) | OpenZeppelin is an import-and-inherit code library, whereas SPL is a set of reusable standard programs already deployed on-chain. You interact with them via Cross-Program Invocation (CPI) instead of copying code into your project. | @@ -249,6 +292,7 @@ To put these ideas into practice, you may want to get comfortable with a differe | **Network RPC** | Infura, Alchemy, QuickNode | Helius, Alchemy, QuickNode | Both ecosystems have top-tier RPC providers; only a few (like QuickNode) are multi-chain. Solana's high throughput has also led to specialized providers like Helius to offer enhanced Solana-first APIs. | | **Explorers** | Etherscan, Blockscout | Solscan, Solana Explorer, X-Ray | The Ethereum ecosystem has powerful tools like Tenderly for deep transaction simulation and debugging. In the Solana ecosystem, tools like Helius (product X-Ray) provide similar functionality. Due to Solana’s parallel transaction model, these tools focus more on visualizing value flows between accounts and CPI call chains to help developers understand complex instruction interactions. | + From this comparison, a clear pattern emerges: Ethereum development supports ideas like inheritance and extension (e.g., inheriting OpenZeppelin contracts), while Solana development supports composition and interaction (via CPI with on-chain SPL programs). We recommend that newcomers to Solana use the Anchor framework whenever possible. Unlike Ethereum's Hardhat/Foundry, which focuses on the external development flow (tests, deployment, scripting), Anchor affects how program code is written and runs. Its Macros and constraints dramatically simplify the process of writing Solana programs by handling a lot of tedious and error-prone low-level safety checks and data serialization. If you master Anchor, you'll master efficient, safe business logic on Solana. @@ -265,38 +309,102 @@ Native development requires direct interaction with Solana's low-level libraries Solana's official recommendation, meant specifically for developers migrating from Ethereum, is to choose Anchor. Anchor leverages Rust macros to simplify development, enhance safety, and ultimately automate the complex parts of native development. -Here's a simple `initialize` instruction for creating a new global state account using Anchor. Once you declare accounts and constraints, the framework handles validation and initialization for you. +Here's a simple `create_pool` instruction for creating pool config, pool state, staking token, and reward vault accounts using Anchor. Once you declare accounts and constraints, the framework handles validation and initialization for you. ```rust -// solana-staking/programs/solana-staking/src/instructions/initialize.rs -#[program] -pub mod staking { - pub fn initialize_handler(ctx: Context, reward_per_second: u64) -> Result<()> { - // Business logic is clean and focused. - let state = &mut ctx.accounts.state; - state.reward_per_second = reward_per_second; - state.admin = ctx.accounts.admin.key(); - // ... - Ok(()) - } +// solana-staking/programs/solana-staking/src/instructions/create_pool.rs +pub fn create_pool_handler( + ctx: Context, + pool_id: Pubkey, + reward_per_second: u64, +) -> Result<()> { + require!(pool_id != Pubkey::default(), StakingError::InvalidPoolId); + require!(reward_per_second > 0, StakingError::InvalidRewardPerSecond); + + let pool_config = &mut ctx.accounts.pool_config; + let pool_state = &mut ctx.accounts.pool_state; + let clock = Clock::get()?; + + pool_config.admin = ctx.accounts.admin.key(); + pool_config.pool_id = pool_id; + pool_config.staking_mint = ctx.accounts.staking_mint.key(); + pool_config.reward_mint = ctx.accounts.reward_mint.key(); + pool_config.reward_per_second = reward_per_second; + pool_config.bump = ctx.bumps.pool_config; + + pool_state.pool_config = pool_config.key(); + pool_state.acc_reward_per_share = 0; + pool_state.last_reward_time = clock.unix_timestamp; + pool_state.total_staked = 0; + pool_state.total_reward_debt = 0; + pool_state.bump = ctx.bumps.pool_state; + + Ok(()) } -// Define accounts and constraints declaratively. #[derive(Accounts)] -pub struct Initialize<'info> { +#[instruction(pool_id: Pubkey)] +pub struct CreatePool<'info> { #[account(mut)] pub admin: Signer<'info>, - // Anchor handles the creation and rent payment for this account. - #[account(init, payer = admin, space = 8 + GlobalState::INIT_SPACE)] - pub state: Account<'info, GlobalState>, + #[account( + init, + payer = admin, + space = 8 + PoolConfig::INIT_SPACE, + seeds = [POOL_CONFIG_SEED, pool_id.as_ref()], + bump + )] + pub pool_config: Box>, + #[account( + init, + payer = admin, + space = 8 + PoolState::INIT_SPACE, + seeds = [POOL_STATE_SEED, pool_config.key().as_ref()], + bump + )] + pub pool_state: Box>, + pub staking_mint: Account<'info, Mint>, + pub reward_mint: Account<'info, Mint>, + #[account( + init, + payer = admin, + token::mint = staking_mint, + token::authority = pool_config, + seeds = [STAKING_TOKEN_SEED, pool_config.key().as_ref()], + bump + )] + pub staking_token: Account<'info, TokenAccount>, + #[account( + init, + payer = admin, + token::mint = reward_mint, + token::authority = pool_config, + seeds = [REWARD_VAULT_SEED, pool_config.key().as_ref()], + bump + )] + pub reward_vault: Account<'info, TokenAccount>, pub system_program: Program<'info, System>, + pub token_program: Program<'info, Token>, } #[account] -pub struct GlobalState { +pub struct PoolConfig { pub admin: Pubkey, + pub pool_id: Pubkey, + pub staking_mint: Pubkey, + pub reward_mint: Pubkey, pub reward_per_second: u64, - // ... + pub bump: u8, +} + +#[account] +pub struct PoolState { + pub pool_config: Pubkey, + pub acc_reward_per_share: u128, + pub last_reward_time: i64, + pub total_staked: u64, + pub total_reward_debt: i128, + pub bump: u8, } ``` @@ -415,7 +523,7 @@ pub struct AccountClose<'info> { For more details, see [Mango Markets v4 source](https://github.com/blockworks-foundation/mango-v4/blob/dev/programs/mango-v4/src/accounts_ix/account_close.rs). -Our `solana-staking` example also follows this lifecycle model. The `initialize` instruction creates global state and vault accounts; the `stake` instruction uses `init` to create a user info account on first stake; and in `unstake`, if the user’s balance returns to zero, the program uses `close` to destroy their user info account and refund rent. See the repository here: [solana-staking](https://github.com/57blocks/evm-to-solana/tree/main/contract/solana-staking). +Our `solana-staking` example also follows this lifecycle model. The `create_pool` instruction creates pool config, pool state, staking token, and reward vault accounts; the `stake` instruction uses `init_if_needed` to create a user stake info account on first stake; and when a user wants to close out, the separate `close_user_stake_account` instruction destroys their user stake info account and refunds rent. See the repository here: [solana-staking](https://github.com/57blocks/evm-to-solana/tree/main/contract/solana-staking). ### Program Derived Addresses (PDA) @@ -436,7 +544,7 @@ pub struct Stake<'info> { init_if_needed, payer = user, space = 8 + UserStakeInfo::INIT_SPACE, - seeds = [STAKE_SEED, state.key().as_ref(), user.key().as_ref()], + seeds = [STAKE_SEED, pool_config.key().as_ref(), user.key().as_ref()], bump )] pub user_stake_info: Box>, @@ -447,15 +555,13 @@ pub struct Stake<'info> { #[account] #[derive(InitSpace)] pub struct UserStakeInfo { - pub owner: Pubkey, pub amount: u64, pub reward_debt: i128, - pub claimed: u64, pub bump: u8, } ``` -- `seeds = [STAKE_SEED, state.key().as_ref(), user.key().as_ref()]`: the core PDA definition. It derives `user_stake_info` from a constant `STAKE_SEED`, the global state account `state.key()`, and the user public key `user.key()`. This ensures a unique, predictable `UserStakeInfo` address per user per staking pool. +- `seeds = [STAKE_SEED, pool_config.key().as_ref(), user.key().as_ref()]`: the core PDA definition. It derives `user_stake_info` from a constant `STAKE_SEED`, the pool config account `pool_config.key()`, and the user public key `user.key()`. This ensures a unique, predictable `UserStakeInfo` address per user per staking pool. - `bump`: Anchor finds a `bump` and stores it in the PDA’s data. Future instructions use the stored `bump` to re-derive and verify the address, ensuring `user_stake_info` is legitimate, not forged. - `init_if_needed`: a convenience constraint that auto-creates this PDA on a user’s first stake. It’s feature-gated in Anchor because it can introduce reinitialization risks, so avoid it when possible. @@ -472,7 +578,7 @@ There are two reasons to do this. First, the complexity of CPI (Cross-Program In // Transfer staking tokens from user to vault let cpi_accounts = Transfer { from: ctx.accounts.user_token_account.to_account_info(), - to: ctx.accounts.staking_vault.to_account_info(), + to: ctx.accounts.staking_token.to_account_info(), authority: ctx.accounts.user.to_account_info(), }; let cpi_program = ctx.accounts.token_program.to_account_info(); @@ -523,20 +629,22 @@ Upgrades are crucial to a project’s evolution, and Ethereum and Solana offer v In early Ethereum, upgrading smart contracts was complex and risky. Because code and data are tightly coupled at one address, upgrading often meant deploying a new contract and migrating data, which can be complex and error-prone. The community developed mature Proxy patterns where data resides in a stable proxy contract and upgradeable logic contracts are referenced via pointers. Upgrades switch the logic implementation without changing the proxy address—now the de facto standard. -Solana's design is simpler and more elegant: program code and state storage are naturally separated. You can redeploy new BPF bytecode to the same program ID to upgrade the program, while state accounts (outside the program) remain intact. There is no data migration needed, significantly reducing complexity and risk. However, there's a new challenge–once an account's structure and size are set, you can’t expand it in-place. If you later add new fields to a state account that was allocated with a smaller size, you’ll get data misalignment or read errors. The recommended approach is to pre-allocate unused space (`padding`) in v1 so you can safely add fields later without changing account size: +Solana's design is simpler and more elegant: program code and state storage are naturally separated. You can redeploy new BPF bytecode to the same program ID to upgrade the program, while state accounts (outside the program) remain intact. There is no data migration needed, significantly reducing complexity and risk. However, there's a new challenge–once an account's structure and size are set, you can’t expand it in-place. If you later add new fields to a state account that was allocated with a smaller size, you’ll get data misalignment or read errors. The example below shows the current `PoolState` layout; if you expect the account to grow later, reserve extra space up front when you define the account size: ```rust -#[account(zero_copy)] -#[repr(C)] -pub struct MyState { - pub data_field_a: u64, - pub data_field_b: bool, - // Reserve 128 bytes for future upgrade - pub _reserved: [u8; 128], +#[account] +#[derive(InitSpace)] +pub struct PoolState { + pub pool_config: Pubkey, + pub acc_reward_per_share: u128, + pub last_reward_time: i64, + pub total_staked: u64, + pub total_reward_debt: i128, + pub bump: u8, } ``` -This way, when you need new fields, you can repurpose part of `_reserved` without changing the account size, keeping old accounts compatible with the new program. +This way, when you need new fields, you can reuse that preallocated space without changing the account size, keeping old accounts compatible with the new program. Also, when deploying a Solana program, you must set an upgrade authority (`upgrade authority`), which is often the deployer wallet or a multisig. This authority is the only entity that can update program bytecode. If it's compromised or removed improperly, the program could be maliciously upgraded or become immutable, so handle it with care. @@ -544,7 +652,7 @@ Also, when deploying a Solana program, you must set an upgrade authority (`upgra In Ethereum's ERC20 standard, transferring on behalf of a user usually takes two steps: the user calls `approve` to grant an allowance, and the authorized party (often a contract) then calls `transferFrom`. This exists because the account model distinguishes between the token holder and the executor, and the executor must submit a transaction separately. -In Solana’s SPL Token model, this is greatly simplified. Each token account records its _authority_ explicitly. As long as the transaction includes that authority’s signature, the program can directly call `token::transfer` to move tokens—no separate `transferFrom` needed. In other words, Solana’s runtime natively supports a **who-signs-who-authorizes** model instead of relying on contracts to check a second-layer approval. +In Solana’s SPL Token model, this is greatly simplified. Each token account records its *authority* explicitly. As long as the transaction includes that authority’s signature, the program can directly call `token::transfer` to move tokens—no separate `transferFrom` needed. In other words, Solana’s runtime natively supports a **who-signs-who-authorizes** model instead of relying on contracts to check a second-layer approval. Furthermore, Solana’s execution environment supports signature propagation across CPI: @@ -559,7 +667,7 @@ Our staking flow uses direct user signatures without proxy or PDA authority. Whe pub fn stake_handler(ctx: Context, amount: u64) -> Result<()> { let cpi_accounts = Transfer { from: ctx.accounts.user_token_account.to_account_info(), - to: ctx.accounts.staking_vault.to_account_info(), + to: ctx.accounts.staking_token.to_account_info(), authority: ctx.accounts.user.to_account_info(), }; let cpi_program = ctx.accounts.token_program.to_account_info(); @@ -570,7 +678,7 @@ pub fn stake_handler(ctx: Context, amount: u64) -> Result<()> { } ``` -Solana doesn’t need `transferFrom` because its runtime fuses _authorization_ and _execution_: if a valid signature is present in the transaction, the user has authorized the transfer without extra steps. +Solana doesn’t need `transferFrom` because its runtime fuses *authorization* and *execution*: if a valid signature is present in the transaction, the user has authorized the transfer without extra steps. ### Numerical Computation @@ -578,7 +686,7 @@ Numeric handling on Solana also requires a shift of thinking. First, regarding p When mixing multiplication and division, beware of precision loss in intermediate results. In many languages, writing `r = a / b * c` as a single expression may benefit from extended precision registers; on x86, the FPU uses 80-bit extended precision internally, only truncating to 64-bit at the end. Note that compilers may also reorder or combine operations. But if you split this into steps like `t = a / b; r = t * c;`, the intermediate result is written to memory (64-bit), then read back, causing extra precision loss. -For integer token amounts, choose `u64/u128` to avoid floating-point issues. However, for ratios, rates, and prices, floats may be necessary, and if that is the case, be careful with intermediate precision. For example, on x86, a single expression like `r = a / b * c` might compute in 80-bit precision, only truncating at the end. Note that splitting the computation into steps as described earlier (first computing t = a / b, then computing r = t \* c) forces 64-bit truncation in between, introducing additional errors. +For integer token amounts, choose `u64/u128` to avoid floating-point issues. However, for ratios, rates, and prices, floats may be necessary, and if that is the case, be careful with intermediate precision. For example, on x86, a single expression like `r = a / b * c` might compute in 80-bit precision, only truncating at the end. Note that splitting the computation into steps as described earlier (first computing t = a / b, then computing r = t c) forces 64-bit truncation in between, introducing additional errors. ## Conclusion @@ -595,3 +703,4 @@ In the next article, “From Ethereum to Solana — Contracts (Part 2),” we’ - [A Complete Guide to Solana Development for Ethereum Developers](https://solana.com/developers/evm-to-svm/complete-guide) - [Solana Development for EVM Developers](https://www.quicknode.com/guides/solana-development/getting-started/solana-development-for-evm-developers#key-architectural-differences-between-ethereum-and-solana) - [Verifying Programs](https://solana.com/docs/programs/verified-builds) + diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/README.md b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/README.md new file mode 100644 index 0000000..edf0fd9 --- /dev/null +++ b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/README.md @@ -0,0 +1,589 @@ +--- +published: true +title: "How to Migrate an Ethereum Protocol to Solana — Contracts (Part 2)" +author: ["Jimmy Zhao / Fullstack Engineer", "Bin Li / Tech Lead"] +createTime: 2026-03-05 +categories: ["engineering"] +subCategories: ["Blockchain & Web3"] +tags: ["Solana", "Ethereum", "Smart Contract", "Solidity", "Anchor"] +landingPages: ["Blockchain-Onchain infra"] +thumb: "./thumb.png" +thumb_h: "./thumb_h.png" +intro: "This article zooms in on the constraints and trade-offs you’ll run into when moving contract development from Ethereum to Solana. Using a concrete staking contract as an example, we’ll also walk through how an Ethereum contract can be migrated to Solana." +--- + +## Article Overview + +If you’ve already read [Contracts (Part 1)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-1), you can split state across accounts and wire up Anchor instructions—but day-to-day migration work still runs into Solana-specific walls that Solidity habits don’t prepare you for. Mainnet-fork testing is manual and easy to get wrong; transactions hit a hard compute-unit cap; CPI is one-way (no re-entrancy, no synchronous callbacks); token hooks and off-chain events follow different rules than `ERC-20` plus `emit`. Those constraints shape architecture early; ignoring them usually means a late refactor. + +This article names those trade-offs and what to do about them. The second half walks through a full staking migration—same `stake` / `unstake` / `claimRewards` behavior on Ethereum and Solana—with open-source code in [evm-to-solana](https://github.com/57blocks/evm-to-solana) for implementation, tests, and deployment. + +It’s part of our series on migrating Ethereum protocols to Solana (contracts, backend, frontend). If you’re new to the topic, start with the [Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble) for account models, execution, and fees. + +#### Article Navigation + +- [How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble): a systematic overview of the fundamental differences between Ethereum and Solana in account models, execution, and fee systems. +- [How to Migrate an Ethereum Protocol to Solana — Contracts (Part 1)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-1): focuses on the core mindset shifts and best practices for contract development when moving from Ethereum to Solana. +- [How to Migrate an Ethereum Protocol to Solana — Contracts (Part 2)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-2): focuses on limitations and shortcomings in Solana contract development, and demonstrates how to migrate an Ethereum contract to Solana through a concrete staking example. +- [How to Migrate an Ethereum Protocol to Solana — Frontend(Part 1)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-frontend-part-1): A focus on the frontend mindset shift and best practices for building Solana-native DApp infrastructure from Ethereum. +- [How to Migrate an Ethereum Protocol to Solana — Frontend(Part 2)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-frontend-part-2): A practical demonstration of the fundamental differences between Ethereum and Solana frontend development in wallet integration, state reading, transaction workflows, and event handling. + +## Solana Limitations and Trade-offs You Should Know + +Part 1 covers how to structure programs and accounts. The sections below are the constraints we hit most often when porting real protocols—mainnet testing, compute budgets, CPI semantics, token hooks, and how off-chain services read state changes. + +### Testing: The Challenge of Mainnet Forking + +In Ethereum development, one powerful and widely used testing strategy is Mainnet Forking. With tools like Hardhat or Foundry, developers can easily spin up a near-complete, lazily loaded snapshot of mainnet state in a local environment. That makes it straightforward to test interactions between new contracts and existing protocols (like Uniswap), because real accounts and state can be accessed instantly. + +In the Solana ecosystem, this “seamless” testing workflow used to be more challenging. While Solana’s standard local testing tool `solana-test-validator` supports a `--clone` flag that lets you clone specific mainnet accounts at startup, it’s fundamentally different from Ethereum’s Full State Forking. On Solana, cloning is an explicit operation—you must specify addresses up front—instead of lazily loading state on demand. This directly affects development workflows. If you want to test interactions with a complex mainnet protocol (like Jupiter), you need to manually identify and list every relevant account to clone (liquidity pools, config accounts, authority accounts, and so on). That process can be tedious—and it’s easy to miss something. + +Tools like [surfpool](https://github.com/txtx/surfpool) narrow the gap: a local environment in the same spirit as Foundry’s [Anvil](https://www.alchemy.com/dapps/foundry-anvil), fetching mainnet account data on demand instead of requiring every address in a `--clone` list up front. That makes it easier to integration-test against live protocols (e.g. Jupiter) without maintaining a long clone manifest by hand. + +### Compute Unit (CU) Limits + +On Ethereum, transaction complexity is mainly controlled by the gas limit. As long as you’re willing to pay enough gas, you can (in theory) execute very complex operations. Solana is different: each transaction has a strict hard cap on **compute units (CUs)**. The per-transaction limit is **1.4 million** CUs. Every instruction consumes CUs—from simple arithmetic to complex cross-program invocations (CPI)—and once cumulative usage exceeds the budget, the entire transaction fails. You can’t bypass the limit by paying more fees. + +This hard ceiling forces developers to stay constantly aware of compute complexity. Operations that are common on Ethereum—like iterating over a large array or running heavy loops—can easily exceed CU limits on Solana. You’ll often need to redesign by splitting heavy logic into multiple transactions, or by using more efficient algorithms to reduce per-transaction workload. This “compute efficiency first” requirement is the developer-facing reflection of Solana’s high-performance architecture. + +For more details on CU constraints, see our related article: [Deep Dive into Resource Limitations in Solana Development — CU Edition](https://57blocks.io/blog/deep-dive-into-resource-limitations-in-solana-development-cu-edition) + +### No Callbacks / No Re-entrancy + +Ethereum developers are usually very familiar with re-entrancy attacks—one of the most notorious smart contract vulnerabilities in Solidity. The attack works because inter-contract calls on Ethereum are synchronous, and the callee (Contract B) can call back into the caller (Contract A) before the caller finishes updating state. Here’s a typical vulnerable pattern, where state updates happen after an external call: + +```solidity +// Vulnerable Solidity Code +function withdraw() public { + uint256 userBalance = balances[msg.sender]; + require(userBalance > 0, "No balance to withdraw"); + + // The vulnerability is here: state is updated AFTER the external call. + (bool success, ) = msg.sender.call{value: userBalance}(""); + require(success, "Transfer failed"); + + // If the recipient is a malicious contract, it can re-enter this function + // before the balance is set to 0, allowing multiple withdrawals. + balances[msg.sender] = 0; +} +``` + +Solana eliminates this class of issue at the architectural level because it enforces a strict one-way invocation model. On Solana, Program A can call Program B via CPI, but Program B cannot call back into Program A from within its execution context. The call graph must be one-directional and acyclic. Consider the following Solana example: even if we update state _after_ a CPI call, there’s still no re-entrancy risk: + +```rust +// Solana (Anchor) equivalent logic - still safe from re-entrancy +pub fn withdraw(ctx: Context, amount: u64) -> Result<()> { + // 1. Perform checks. + let user_balance = ctx.accounts.user_vault.amount; + require!(user_balance >= amount, "Insufficient balance"); + + // 2. Interaction is performed BEFORE state change (not a best practice, but still safe). + // The Token Program is a trusted, separate program. It cannot and will not + // call back into our `withdraw` function. The execution flow is one-way. + token::transfer(cpi_context, amount)?; + + // 3. State is updated last. + ctx.accounts.user_vault.amount -= amount; + + Ok(()) +} +``` + +When our program calls the `Token Program` via CPI, execution pauses until `token::transfer` completes. The `Token Program` is a separate program, and it contains no logic that could call back into our `withdraw` function. In addition, Solana’s program model doesn’t have implicitly triggered `fallback` or `receive` functions that external calls can invoke—so the re-entrancy path is cut off at the design level. + +One-way CPI also limits what you can port unchanged. Flash-loan flows and other designs that depend on synchronous callbacks cannot run as a single nested call chain on Solana—you typically split work across transactions or verify state on a later instruction. + +### Why Hooks Are Harder + +Adding pre- or post-transfer hooks around token transfers is a common requirement—but Ethereum and Solana take very different paths. + +On Ethereum, each `ERC-20` token is typically a customizable contract. Developers can insert logic directly into `transfer` or `transferFrom`, or override internal hook functions like `_beforeTokenTransfer` to implement allow/deny lists, transaction taxes, free lists, and so on. The result is cohesive, simple, and easy to adjust. + +On Solana, token logic is standardized and provided by the officially deployed on-chain `Token Program`, which you can’t modify. To enable hook-like behavior, `Token-2022` introduced the `Transfer Hook` extension. When you create a token, you can specify a separate Hook program. On every transfer, the `Token Program` will automatically CPI-call the Hook program, passing all relevant accounts (read-only), and the Hook program decides whether the transfer should proceed. If the Hook program returns an error, the entire transfer is rolled back. + +On Solana you deploy a separate Hook program and bind it when you create the mint—more setup than overriding `transfer` on an `ERC-20`. Typical flow: + +1. Deploy a `Transfer Hook` program (allow/deny lists, fees, KYC, etc.). +2. At mint initialization, enable the Token-2022 extension and set the mint’s hook program id. +3. On each transfer, Token-2022 CPI-calls that program with the accounts you declared—often via the `execute` instruction and an `extra-account-metas` PDA so the hook sees every account it needs. A failing hook rolls back the transfer. + +**Caveats:** callers must use `transferChecked`; plain `transfer` fails. Many wallets and DEXs still do not support the extension—check compatibility before you ship. + +In our staking example, blacklist checks live inside `stake`, `unstake`, and `claimRewards`. That blocks protocol paths only, not arbitrary wallet-to-wallet transfers. For a global blacklist, use Transfer Hook. + +### Logs and Events + +On Ethereum, events are a core mechanism for smart contracts to communicate with the outside world. With the `emit` keyword, a contract can produce structured, indexable logs. Off-chain services can efficiently subscribe to these events to update UIs, run analytics, or trigger downstream logic. + +```solidity +// evm-staking/src/Staking.sol +// Define a structured event +event Staked(address indexed user, uint256 amount); + +function stake(uint256 amount) external { + // ... + // Emit the event with structured data + emit Staked(msg.sender, amount); +} +``` + +Solana’s native runtime is more primitive here. It provides basic logging via `sol_log` (typically used through Anchor’s `msg!` macro), but that’s essentially just printing a string into transaction logs. It’s great for debugging, but without strong structure or indexing. You can’t efficiently filter by event types or parameters the way you can on Ethereum. Off-chain services often have to scan logs wholesale, which makes reliable parsing harder. + +To address this, Anchor provides a lightweight `#[event]` macro that adds basic event parsing on top of native logs. + +First, define an event struct and tag it with `#[event]`: + +```rust +// solana-staking/programs/solana-staking/src/events.rs +#[event] +pub struct Staked { + pub pool: Pubkey, + pub user: Pubkey, + pub amount: u64, + pub timestamp: i64, +} +``` + +Then, use the `emit!` macro to emit this event: + +```rust +// solana-staking/programs/solana-staking/src/instructions/stake.rs +pub fn stake_handler(ctx: Context, amount: u64) -> Result<()> { + // ... (staking logic) + let pool_config = &ctx.accounts.pool_config; + let clock = Clock::get()?; + + // Emit the structured event + emit!(Staked { + pool: pool_config.pool_id, + user: ctx.accounts.user.key(), + amount, + timestamp: clock.unix_timestamp, + }); + + Ok(()) +} +``` + +Anchor’s `#[event]` macro serializes the event data (commonly Base64-encoded) and writes it into the transaction logs via `emit!`. It’s not as strongly structured as EVM-style events, but it’s recognizable by off-chain systems. On the client side, you can parse events with Anchor’s `EventParser`, or use services like Helius Webhooks / enhanced transaction APIs to fetch logs and extract events using your own parsing logic. + +So for any application that needs reliable off-chain communication, use Anchor’s `#[event]` rather than relying on raw `msg!` logs—this ensures your program broadcasts state changes in a standardized, machine-parseable way. + +## Hands-on: From Coding to Deployment + +### Case Overview + +Next: port a staking contract from Ethereum to Solana using the [evm-to-solana](https://github.com/57blocks/evm-to-solana) repo—the same example referenced in Part 1, with Foundry and Anchor code plus tests and deployment. + +**Business logic** + +- Users stake project tokens (`MyToken`) and earn `RewardToken` proportional to stake size and duration. + +**Core features** + +- `stake` — deposit `MyToken` +- `unstake` — withdraw staked `MyToken` +- `claimRewards` — claim accrued `RewardToken` + +### Ethereum Implementation (Foundry) + +Let’s see how this staking logic is implemented on Ethereum. The code follows a common Solidity pattern: all logic and state are packaged into a single contract named `Staking.sol`. + +**Contract structure and state variables** + +At the heart of the contract are its state variables, which record all data. + +```solidity +// evm-staking/src/Staking.sol +contract Staking is ReentrancyGuard, Ownable { + // Token contracts to interact with + IERC20 public stakingToken; + IERC20 public rewardToken; + + // Global state + uint256 public rewardRate = 100; // 1% per day + uint256 public totalStaked; + + // Per-user state, mapping an address to their stake info + struct StakeInfo { + uint256 amount; + int256 rewardDebt; + uint256 claimed; + } + mapping(address => StakeInfo) public stakes; + + // ... events and constructor +} +``` + +All staking data—token refs, pool totals, and every user’s `StakeInfo`—lives in this one contract’s storage (the single `mapping` is the main contrast with Solana’s per-user PDAs). + +**Core function implementations** + +The three core functions `stake`, `unstake`, and `claimRewards` all revolve around directly modifying these state variables. + +```solidity +// evm-staking/src/Staking.sol +function stake(uint256 amount) external nonReentrant { + // ... (checks) + + // Pulls tokens from the user into this contract + stakingToken.transferFrom(msg.sender, address(this), amount); + + // Update user's stake info directly in the mapping + stakes[msg.sender].amount += amount; + // Update global state + totalStaked += amount; + + // ... (update timestamps and emit event) +} + +function unstake(uint256 amount) external nonReentrant { + // ... (checks and claims pending rewards) + + // Update user's stake info + stakes[msg.sender].amount -= amount; + // Update global state + totalStaked -= amount; + + // Push tokens from this contract back to the user + stakingToken.transfer(msg.sender, amount); + + // ... (emit event) +} + +function _claimRewards() private { + uint256 reward = calculateReward(msg.sender); + if (reward > 0) { + // ... (update reward debt) + // Transfer reward tokens to the user + rewardToken.transfer(msg.sender, reward); + // ... (emit event) + } +} +``` + +`stake` pulls tokens into the contract vault; `unstake` and `_claimRewards` update the mapping and push tokens out—all without CPI to an external token program. + +The full contract code is available [here](https://github.com/57blocks/evm-to-solana/tree/main/contract/evm-staking). + +### Solana Implementation (Anchor) + +Now let’s implement the same staking logic the Solana way. As we’ve emphasized, the core is separating code from data. + +**Program structure and account definitions** + +In Anchor, we define a stateless program containing instructions like `stake` and `unstake`, and then define all account structs used for state. + +```rust +// solana-staking/programs/solana-staking/src/lib.rs & state.rs +// The program itself is stateless. +#[program] +pub mod solana_staking { + pub fn create_pool( + ctx: Context, + pool_id: Pubkey, + reward_per_second: u64, + ) -> Result<()> { /* ... */ } + pub fn stake(ctx: Context, amount: u64) -> Result<()> { /* ... */ } + pub fn unstake(ctx: Context, amount: u64) -> Result<()> { /* ... */ } + pub fn claim_rewards(ctx: Context) -> Result<()> { /* ... */ } + // ... other instructions +} + +// Pool-level config is stored in a dedicated account (one per pool). +#[account] +pub struct PoolConfig { + pub admin: Pubkey, + pub pool_id: Pubkey, + pub staking_mint: Pubkey, + pub reward_mint: Pubkey, + pub reward_per_second: u64, + pub bump: u8, +} + +// Mutable pool runtime state is split into a separate account. +#[account] +pub struct PoolState { + pub pool_config: Pubkey, + pub acc_reward_per_share: u128, + pub last_reward_time: i64, + pub total_staked: u64, + pub total_reward_debt: i128, + pub bump: u8, +} + +// Per-user state is also in its own account, typically a PDA. +#[account] +pub struct UserStakeInfo { + pub amount: u64, + pub reward_debt: i128, + pub bump: u8, +} +``` + +`PoolConfig` and `PoolState` split fixed config from mutable pool totals; each staker gets a `UserStakeInfo` PDA (`init_if_needed` in `stake`) instead of one on-chain `mapping`. + +**Instructions and context** + +On Solana, each instruction must explicitly declare all accounts it will touch. Take `stake` as an example: its Context clearly lists all participants. + +```rust +// solana-staking/programs/solana-staking/src/instructions/stake.rs +#[derive(Accounts)] +pub struct Stake<'info> { + // The user performing the action (signer) + #[account(mut)] + pub user: Signer<'info>, + + #[account( + seeds = [POOL_CONFIG_SEED, pool_config.pool_id.as_ref()], + bump = pool_config.bump + )] + pub pool_config: Box>, + + #[account( + mut, + seeds = [POOL_STATE_SEED, pool_config.key().as_ref()], + bump = pool_state.bump, + has_one = pool_config + )] + pub pool_state: Box>, + + // The user's personal stake info PDA + #[account( + init_if_needed, + payer = user, + space = 8 + UserStakeInfo::INIT_SPACE, + seeds = [STAKE_SEED, pool_config.key().as_ref(), user.key().as_ref()], + bump + )] + pub user_stake_info: Box>, + + // The user's token account holding the staking tokens + #[account( + mut, + token::mint = pool_config.staking_mint, + token::authority = user + )] + pub user_token_account: Account<'info, TokenAccount>, + + // The program's vault to store the staked tokens + #[account( + mut, + seeds = [STAKING_TOKEN_SEED, pool_config.key().as_ref()], + bump + )] + pub staking_token: Account<'info, TokenAccount>, + + /// CHECK: This account may or may not exist - used for blacklist validation + #[account( + seeds = [BLACKLIST_SEED, pool_config.key().as_ref(), user.key().as_ref()], + bump, + )] + pub blacklist_entry: UncheckedAccount<'info>, + + // Required external programs + pub token_program: Program<'info, Token>, + pub system_program: Program<'info, System>, +} +``` + +`blacklist_entry` is optional: if that PDA exists with data, `stake` rejects the user. Anchor checks seeds and `has_one` links before your handler runs. + +**Core function implementation** + +The `stake` instruction no longer “modifies internal state.” Instead, it performs a CPI call to the `Token Program` to transfer tokens, then updates the data stored in the passed-in `pool_state` and `user_stake_info` accounts. + +```rust +// solana-staking/programs/solana-staking/src/instructions/stake.rs +pub fn stake_handler(ctx: Context, amount: u64) -> Result<()> { + require!(amount > 0, StakingError::InvalidStakeAmount); + + let blacklist_info = &ctx.accounts.blacklist_entry.to_account_info(); + require!( + blacklist_info.data_is_empty() || blacklist_info.lamports() == 0, + StakingError::AddressBlacklisted + ); + + let pool_config = &ctx.accounts.pool_config; + let pool_state = &mut ctx.accounts.pool_state; + let user_stake = &mut ctx.accounts.user_stake_info; + let clock = Clock::get()?; + + update_pool(pool_config, pool_state, &clock)?; + + // 1. Command the Token Program to transfer tokens via CPI + let cpi_accounts = Transfer { + from: ctx.accounts.user_token_account.to_account_info(), + to: ctx.accounts.staking_token.to_account_info(), + authority: ctx.accounts.user.to_account_info(), + }; + let cpi_program = ctx.accounts.token_program.to_account_info(); + let cpi_ctx = CpiContext::new(cpi_program, cpi_accounts); + token::transfer(cpi_ctx, amount)?; + + // 2. Update the data on the user_stake_info account + user_stake.amount += amount; + let debt_delta = calculate_share_value(amount, pool_state.acc_reward_per_share)?; + user_stake.reward_debt += debt_delta; + user_stake.bump = ctx.bumps.user_stake_info; + + // 3. Update the data on the pool state account + pool_state.total_staked += amount; + pool_state.total_reward_debt += debt_delta; + + emit!(Staked { + pool: pool_config.pool_id, + user: ctx.accounts.user.key(), + amount, + timestamp: clock.unix_timestamp, + }); + + Ok(()) +} +``` + +Custody is a CPI to the Token Program; balances and reward debt are written to the `pool_state` and `user_stake_info` accounts passed into the instruction. + +The full Solana implementation is available [here](https://github.com/57blocks/evm-to-solana/tree/main/contract/solana-staking). + +### Contract Testing + +Validating correctness is a critical part of the development workflow. Here, mainstream testing frameworks on Ethereum and Solana differ both in philosophy and implementation. + +**Framework comparison** + +- Ethereum / Foundry: tests are often written directly in Solidity. The upside is that tests and contracts share the same language, context, and types. Test contracts can call internal/external functions directly and use `forge-std` utilities (like `vm.prank`) to simulate different callers and environments. It’s powerful and intuitive. +- Solana / Anchor: tests are written in TypeScript or JavaScript. Test scripts interact with a local validator (`solana-test-validator`) via client libraries. In other words, tests simulate a real front-end or back-end calling your program deployed to a local test network. This is closer to real user behavior, but requires more client-side setup. + +**Foundry test example** + +Below is a typical test case from `evm-staking` that validates the `stake` function directly in Solidity. + +```solidity +// evm-staking/test/Staking.t.sol +function testStake() public { + uint256 stakeAmount = 1000 * 10 ** 18; + + // Simulate the call coming from user1 + vm.startPrank(user1); + // User must first approve the staking contract + myToken.approve(address(staking), stakeAmount); + // Call the stake function + staking.stake(stakeAmount); + vm.stopPrank(); + + // Assertions are made directly against the contract's state + (uint256 stakedAmount, ,) = staking.getStakeInfo(user1); + assertEq(stakedAmount, stakeAmount); + assertEq(staking.totalStaked(), stakeAmount); +} +``` + +**Anchor test example** + +On Solana, the test script does more setup work: creating mock users, token accounts, then building and sending a full transaction to call the `stake` instruction. + +```typescript +// solana-staking/tests/solana-staking.test.ts +describe("Stake", () => { + it("should allow user to stake tokens", async () => { + // 1. Setup: Create a test user and their token accounts + const { user, userSigner } = await createTestUser(svm); + const { stakingToken, rewardToken } = await setupUserWithTokens( + provider, + admin, + user, + stakingMint, + rewardMint + ); + const stakeAmount = toToken(100); + + // 2. Action: Build and send the transaction to call the 'stake' instruction + await stakeTokens(user, userSigner, stakingToken, rewardToken, stakeAmount); + + // 3. Assertion: verify staking token account balance and account state + const stakingTokenAccount = getAccount(provider, stakingTokenPda); + expect(Number(stakingTokenAccount.amount)).to.equal(Number(stakeAmount)); + + const userStakePda = getUserStakePda(statePda, user.publicKey); + const userStakeInfo = getUserStakeInfo(provider, userStakePda); + expect(userStakeInfo).to.not.be.null; + expect(userStakeInfo!.amount.toString()).to.equal(stakeAmount.toString()); + expect(userStakeInfo!.rewardDebt.toString()).to.equal("0"); + + const globalState = getGlobalState(provider, statePda); + expect(globalState!.totalStaked.toString()).to.equal( + stakeAmount.toString() + ); + }); +}); +``` + +Foundry tests usually exercise contract internals directly; Anchor tests call the program through the client, which is closer to end-to-end integration testing. + +### Contract Deployment + +Deploying code to a blockchain is the final step. Ethereum and Solana differ deeply here as well—both at the protocol level and in tooling. + +**Ethereum / Foundry deployment** + +On Ethereum, deploying a contract is essentially sending a special transaction: the `to` field is empty, and the `data` field contains the compiled bytecode. Once miners include it in a block, the EVM executes the constructor logic, creates a new contract account, and stores the code at that address. + +In our example project, we use Foundry scripts to handle deployment, which provides flexibility for more complex deployment logic. + +```bash +# Run the deployment script using forge +forge script script/Deploy.s.sol --rpc-url --broadcast --verify +``` + +This command runs `Deploy.s.sol`, deploys the `Staking` contract to the specified network, and uses `--verify` to automatically upload source code to Etherscan for verification. The full deployment script is available [here](https://github.com/57blocks/evm-to-solana/blob/main/contract/evm-staking/script/Deploy.s.sol). + +**Solana / Anchor deployment** + +Solana’s deployment mechanism is completely different. Since code and data are separated, deploying a program doesn’t create a single account containing both code and state. Instead, it uploads the compiled on-chain program binary (BPF bytecode, typically a `.so` under `target/deploy/`) to a dedicated program account. That program account is executable, but it does not store business state. With Anchor, the deployment flow is significantly simplified: + +```bash +# First, build the program to get the BPF bytecode +anchor build + +# Then, run the deploy command for the initial deployment +anchor deploy --provider.cluster +``` + +Pick the cluster with `--provider.cluster` (`localnet`, `devnet`, or `mainnet-beta`). `anchor upgrade` (below) swaps bytecode at the same program id—pool and user accounts are untouched. + +When you need to update logic, you just modify code and run `anchor upgrade` to upload new bytecode to the same program ID—keeping all associated state accounts unchanged. + +```bash +# After making changes, build the new version +anchor build + +# Then, use the upgrade command +anchor upgrade target/deploy/your_program_name.so --provider.cluster +``` + +For more detailed steps and caveats, see our project’s [deployment doc](https://github.com/57blocks/evm-to-solana/blob/main/contract/solana-staking/DEPLOYMENT.md). + +On Ethereum, each `forge script` deploy typically mints a new contract address (and new storage). On Solana, `anchor deploy` / `anchor upgrade` keeps the same program id—only the executable changes; pool and user accounts you created earlier stay as they are. + +## Summary + +[Part 1](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-1) covered the account model—stateless programs, explicit accounts, CPI, and PDAs. This article adds the constraints that show up once you start building: mainnet fork testing, CU limits, one-way CPI (no re-entrancy), Transfer Hook, and Anchor events for off-chain indexing. + +The staking walkthrough ties both together. Same `stake` / `unstake` / `claimRewards` behavior on both chains; different shape. On Ethereum, one contract holds tokens and user state. On Solana, pool config, pool state, and each user's stake live in separate accounts, with token moves done via CPI to the Token Program. + +Before porting an EVM contract, map out: which state belongs in a global account vs. a per-user PDA, which operations call standard programs, and whether any instruction risks hitting the CU cap or needs to be split across transactions. A successful migration is a redesign, not a line-by-line port. + +Next in the series: frontend and backend—what has to change once contracts move off the EVM. + +## References + +- [Moving from Ethereum Development to Solana](https://solana.com/news/evm-to-svm) +- [EVM vs. SVM: Smart Contracts](https://solana.com/developers/evm-to-svm/smart-contracts) +- [How to Migrate From Ethereum to Solana: A Guide for Devs](https://www.helius.dev/blog/how-to-migrate-from-ethereum-to-solana) +- [Basic Knowledge Needed for Migrating from EVM to Solana](https://medium.com/@easypass.inc/basic-knowledge-needed-for-migrating-from-evm-to-solana-7814b29c8bd5) +- [A Complete Guide to Solana Development for Ethereum Developers](https://solana.com/developers/evm-to-svm/complete-guide) +- [Solana Development for EVM Developers](https://www.quicknode.com/guides/solana-development/getting-started/solana-development-for-evm-developers#key-architectural-differences-between-ethereum-and-solana) +- [Verifying Programs](https://solana.com/docs/programs/verified-builds) diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/thumb.png b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/thumb.png new file mode 100644 index 0000000..cee4a45 Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/thumb.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/thumb_h.png b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/thumb_h.png new file mode 100644 index 0000000..99beff9 Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/thumb_h.png differ