diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Backend/README.md b/articles/How to Migrate an Ethereum Protocol to Solana Backend/README.md new file mode 100644 index 0000000..d0751cb --- /dev/null +++ b/articles/How to Migrate an Ethereum Protocol to Solana Backend/README.md @@ -0,0 +1,587 @@ +--- +published: true +title: "How to Migrate an Ethereum Protocol to Solana — Backend" +author: ["Jimmy Zhao / Fullstack Engineer", "Bin Li / Tech Lead"] +createTime: 2026-06-06 +categories: ["engineering"] +subCategories: ["Blockchain & Web3"] +tags: ["Solana", "Ethereum", "Backend", "Indexer", "NestJS", "Anchor"] +landingPages: ["Blockchain-Onchain infra"] +thumb: "./thumb.png" +thumb_h: "./thumb_h.png" +intro: "How to index Solana transaction logs, run cron jobs against on-chain state, and ship a NestJS backend for an EVM-to-Solana migration." +--- + +## Article Overview + +Contracts encode the rules, but most Web3 products still need a backend: historical stats, reward math, user activity tracking, and ops alerts. On Ethereum that often means `eth_getLogs` against indexed events. On Solana you pull transactions yourself and parse `logMessages`. + +This article is the backend piece of our EVM-to-Solana series. Earlier posts covered contracts and the frontend; here we walk through event sync, log parsing, account selection for `getSignaturesForAddress`, cron automation, and deployment. The running example is the Anchor staking pool from the series (`PoolConfig`, `PoolState`, `UserStakeInfo`). Code lives in [solana-backend](https://github.com/57blocks/evm-to-solana/tree/main/backend/solana-backend). + +#### Article Navigation + +- [How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble): account models, execution, and fees on both chains. +- [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): account model, CPI, PDAs, and Anchor patterns for EVM developers. +- [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): Solana limits (CU, forks, hooks, events) and a staking migration walkthrough. +- [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): wallets, transaction building, errors, and Jito bundles. +- [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): transaction building, account fetching, and event/log handling on the client. +- [How to Migrate an Ethereum Protocol to Solana — Backend](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-backend): event sync, log parsing, and state management for a production backend. + +## What a Solana Backend is for + +Smart contracts set business rules, but chain execution and query costs push most products to off-chain services for history, scoring, analytics, and monitoring. + +On EVM chains, nodes index contract events. A backend calls `eth_getLogs`, filters by topic, and stores rows. Solana has no equivalent typed event index. Anchor `#[event]` writes bytes into transaction logs, but you cannot query "all `Staked` events" from RPC. Logs are strings; filtering by event type is your job. + +The shift is from waiting on push-style event subscriptions to pulling signatures for chosen accounts, fetching transactions, and parsing logs. That thread runs through the rest of this post. We use the series staking program as the example; full backend source is in [solana-backend](https://github.com/57blocks/evm-to-solana/tree/main/backend/solana-backend). + +## Core Mindset Shifts + +### Parsing On-chain Events + +EVM backends follow a comfortable loop: contract emits, node indexes, backend filters. Solana does not offer that loop. Anchor events land in logs only. There is no RPC to list events by type. You start from an account address, list signatures with `getSignaturesForAddress`, fetch each transaction, and parse `logMessages`. + +The gap is indexing. EVM nodes build topic and bloom filters so you can filter by event signature. Solana validators and archive nodes keep history and logs, but not a structured event index. Program output is unstructured text until your parser turns it into rows. Third-party indexers can help, but the default RPC path is: logs in, events out, all in your service. + +### Choosing Which Account to Watch + +`getSignaturesForAddress` returns transactions that touch an address. What you watch defines noise, cost, and completeness. + +- **Global protocol activity:** watch `PoolConfig` PDA for admin updates, reward rate changes, and pool-level TVL moves. User stakes that do not touch config never appear here. +- **One user:** watch the wallet or that user's `UserStakeInfo` PDA for a low-noise stream of their stakes and claims. +- **Token transfers everywhere:** do not assume the mint address shows up. [SPL Token](https://solana.com/docs/tokens/basics) `transfer` lists source and destination token accounts, not the mint. [Token-2022](https://www.solana-program.com/docs/token-2022) `transfer_checked` includes the mint, so `getSignaturesForAddress(mint)` can work for that path. For plain SPL transfers, use a token transfer API from Solscan, Helius, or similar instead of mint-based signature polling. + +## Solana Event Filtering Gotchas + +### Pagination cursors must be real signatures + +`getSignaturesForAddress` is the core interface for event capture. Its `before` and `until` parameters take transaction signatures, not slot numbers or timestamps. To sync a slot range, call `getBlock` over the range, pick a block that contains transactions, and use a signature from that block as the cursor. If the range is empty blocks only, walk forward or backward until you hit a non-empty block. + +### Failed transactions can still leave logs + +On EVM, a reverted transaction drops its events. On Solana, logs emitted before failure stay in `logMessages`. RPC returns them. If you parse without checking status, you can persist state changes that never committed. Check `tx.meta.err` at the pipeline entry; only proceed when it is `null`. + +### Account WebSocket subscriptions are not event feeds + +You can subscribe to account data changes over WebSocket, but that tells you an account mutated, not which instruction ran or what event fired. You still infer semantics from diffs and logs. Connections drop; you need reconnect and backfill logic. It's a bad fit for large-scale accounts or high-throughput use cases. + +### Production alternative: Geyser / Yellowstone + +This article focuses on RPC polling, which is fine for prototypes and moderate volume. For lower latency and higher throughput, [Geyser gRPC (Yellowstone)](https://github.com/rpcpool/yellowstone-grpc) streams validator data without polling RPC. Helius, Triton (Yellowstone maintainers), and QuickNode offer hosted feeds. + +Pick polling vs Geyser roughly like this: + +- **Volume:** hundreds to a few thousand relevant txs per day fits RPC polling with batching and sleep between calls. Tens of thousands per day usually pushes you toward Geyser to cut latency and RPC load. +- **Latency:** cron plus RPC round trips often means seconds to minutes. Geyser can reach sub-second delivery for liquidations, bots, or live dashboards. +- **Ops:** polling needs only RPC URLs. Geyser needs a gRPC endpoint or your own Yellowstone node; setup costs more upfront but avoids aggressive rate limits on heavy `getTransaction` loops. + +## The Guide to Solana Backend Best Practices + +Two jobs show up in almost every Web3 backend: **event indexing** (pull chain history, parse logs, write a database) and **automation** (cron that reads state and optionally sends transactions). While both are scheduled via cron, they serve different purposes and don't interfere with each other. Here is a breakdown of each. + +### Event Indexing + +There is no `get events by type` RPC. Your backend needs a complete event synchronization pipeline: contract event shape, signature discovery, transaction fetch, parse, cursor persistence. + +#### Contract Side: Put Enough in The Event + +When designing Anchor events, include fields the backend needs even if on-chain logic ignores them. + +Minimal `Staked` event: + +``` +#[event] +pub struct Staked { + pub user: Pubkey, + pub amount: u64, + pub timestamp: i64, +} +``` + +After parsing, the backend still needs total stake and reward debt, which means another RPC read of `UserStakeInfo`: + +``` +const [userStakePda] = PublicKey.findProgramAddressSync( + [Buffer.from("stake"), statePda.toBuffer(), user.toBuffer()], + programId +); +const accountInfo = await connection.getAccountInfo(userStakePda); +const totalStaked = coder.accounts.decode( + "UserStakeInfo", + accountInfo.data +).amount; +``` + +Extra fields on the event avoid that round trip: + +``` +#[event] +pub struct Staked { + pub user: Pubkey, + pub amount: u64, + pub timestamp: i64, + pub total_staked: u64, + pub reward_debt: i128, +} +``` + +Log space is cheap on Solana. Trading a few extra bytes per tx for one fewer RPC call per event is usually worth it. + +#### Server Side: The Sync Pipeline + +Four steps: resolve start signature, list signatures, fetch and parse txs, persist progress in batches. + +**Step 1: turn a slot into a signature cursor** + +As above, cursors must be signatures. Example helper: + +``` +private async getStartTransactionSignature( + connection: Connection, + lastSyncedSlot: number +) { + const blockHistories = await connection.getBlocks( + lastSyncedSlot, + lastSyncedSlot + this.config.slotToCheck + ); + + for (let i = 0; i < blockHistories.length; i++) { + // getBlock is a heavy operation, taking about 5 seconds + const startBlock = await connection.getBlock(blockHistories[i], { + maxSupportedTransactionVersion: 0, + rewards: false, + transactionDetails: "accounts", + }); + + if (!startBlock) { + throw new Error(`Failed to fetch block at slot ${blockHistories[i]}`); + } + + const transactions = startBlock.transactions; + if (transactions.length > 0) { + return { + startBlockSlot: blockHistories[i], + startSignature: transactions[0].transaction.signatures[0], + }; + } + } + + throw new Error( + `No valid transactions found between slot ${lastSyncedSlot} and ${ + lastSyncedSlot + this.config.slotToCheck + }` + ); +} +``` + +If the target slot is empty, scan adjacent slots until you find one with transactions. End-signature lookup is the same idea in reverse. + +**Step 2: list signatures for the monitored account** + +After you have start and end signatures, call `getSignaturesForAddress` to pull the transaction signatures for the account. Results arrive newest-first; reverse to chronological order before processing. + +``` +let beforeSignature = endBlockInfo.endSignature; +let sigsCount = this.config.signaturesPerBatch; + +while (sigsCount >= this.config.signaturesPerBatch) { + const sigs = await connection.getSignaturesForAddress( + new PublicKey(monitorAddress), + { + limit: this.config.signaturesPerBatch, + until: startBlock.startSignature, + before: beforeSignature, + } + ); + + sigsCount = sigs.length; + if (sigsCount > 0) { + beforeSignature = sigs[sigsCount - 1].signature; + sigList.unshift(...sigs.reverse().map((sig: any) => sig.signature)); + await sleep(this.config.sleepTime); + } +} +``` + +- `before` walks backward in time. +- `reverse` then `unshift` keeps the full list oldest-first. +- `sleep` between calls to stay inside RPC rate limits. + +**Step 3: fetch transactions, drop failures, parse events** + +Batch `getParsedTransaction` with `Promise.all` inside each batch; sleep between batches. + +``` +for (let i = 0; i < sigList.length; i += batchSize) { + const promises = []; + for (const sig of sigList.slice(i, i + batchSize)) { + promises.push( + this.solanaEventsService.parseTransactionEvents( + this.chainId, + sig, + eventsParser + ) + ); + } + const eventsList = await Promise.all(promises); + for (const events of eventsList) { + parsedEvents.push(...events); + } + await sleep(this.config.sleepTime); +} +``` + +Per-transaction parser: fetch, check `meta.err`, then run the event parser. + +``` +async parseTransactionEvents( + chainId: number, + sig: string, + eventsParser: TransactionEventsParser +): Promise { + const connection = this.solanaConnections.getConnection(chainId); + const ptx = await connection.getParsedTransaction(sig, { + maxSupportedTransactionVersion: 0, + }); + + if (!ptx) { + console.error(`Can not get the parsed transaction from rpc, txhash: ${sig}`); + return []; + } + + // Check tx.meta.err first. Only guard against writing dirty data from failed txs. + if (ptx.meta?.err) { + return []; + } + + return eventsParser.parseEvents({ tx: ptx, sig }); +} +``` + +**Step 4: persist progress per batch** + +Update the sync cursor after each batch, not after the full run finishes. + +``` +async onBatchFetched(batchResult) { + const userActivities = []; + for (const event of batchResult.events) { + try { + const activity = this.convertEventToUserActivity(event, poolConfig); + userActivities.push(activity); + } catch (error) { + console.warn(`Failed to convert event ${event.transactionHash}:`, error); + } + } + + for (const activity of userActivities) { + await this.userActivityRepository.save(activity); + } + + const updatedSyncStatus = syncStatus.updateLastSyncBlock( + batchResult.endBlockNumber + ); + await this.syncStatusRepository.save(updatedSyncStatus); +} +``` + +If you only save the cursor at the end, a crash mid-run loses all progress. Batch checkpoints let restarts resume from `lastSyncBlock`. + +### Automation Tasks + +Besides indexing history, backends often run cron jobs: read vault balances, alert ops, or send admin transactions. Indexing replays the past; automation reacts on a schedule. + +In [solana-backend](https://github.com/57blocks/evm-to-solana/tree/main/backend/solana-backend), `PoolBalanceMonitorService` checks each pool's reward vault on a cron. It opens an alert when balance drops below a threshold and clears it when balance recovers. + +#### Read Current On-chain State + +Derive the reward vault PDA and read SPL token balance: + +``` +async getRewardVaultBalance(poolConfig: string): Promise { + const programId = new PublicKey(StakingIDL.address); + const poolConfigPda = new PublicKey(poolConfig); + const [rewardVaultPda] = PublicKey.findProgramAddressSync( + [Buffer.from("reward_vault"), poolConfigPda.toBuffer()], + programId + ); + + const connection = this.solanaConnections.getConnection(this.chainId); + const balance = await connection.getTokenAccountBalance(rewardVaultPda); + + return { + rewardVault: rewardVaultPda.toBase58(), + uiAmount: balance.value.uiAmount, + rawAmount: balance.value.amount, + }; +} +``` + +This is a very common pattern: derive PDA, read account, return typed result. + +#### Threshold Alerts without Spam + +Compare balance to threshold; create or resolve alerts. + +``` +private async checkPoolBalance(poolConfig: string): Promise { + const threshold = this.config.getOrThrow("REWARD_BALANCE_THRESHOLD"); + const balance = await this.rewardVaultReader.getRewardVaultBalance(poolConfig); + + if (balance.uiAmount >= threshold) { + // Balance recovered, resolve any open alerts + const openAlert = await this.alertRepository.findOpenAlert( + poolConfig, + LOW_REWARD_BALANCE + ); + if (openAlert) { + await this.alertRepository.resolveOpenAlert(poolConfig, LOW_REWARD_BALANCE); + } + return; + } + + // Balance below threshold; check for existing open alerts to avoid duplicates + const openAlert = await this.alertRepository.findOpenAlert( + poolConfig, + LOW_REWARD_BALANCE + ); + if (openAlert) { + return; + } + + await this.alertRepository.save({ + poolConfig, + alertType: LOW_REWARD_BALANCE, + message: `Reward vault balance is below threshold`, + threshold: String(threshold), + actualValue: String(balance.uiAmount), + createdAt: Math.floor(Date.now() / 1000), + resolved: false, + }); +} +``` + +Two details matter: skip creating a duplicate open alert, and resolve when balance recovers so on-call is not stuck with stale pages. + +## Case Study: solana-backend + +In the previous chapters, we covered event synchronization, limitations and workarounds, and the implementation details of the two core tasks. Now, let's tie it all together and walk through building a complete backend system.The event pipeline was described earlier; we add off-chain reward math and sync cursor bootstrap. + +### Project layout + +[solana-backend](https://github.com/57blocks/evm-to-solana/tree/main/backend/solana-backend) is TypeScript on NestJS. + +``` +solana-backend/ +├── src/ +│ ├── autotask/ # cron: balance checks + alerts +│ ├── event-fetch/ # signature fetch + tx parse +│ ├── indexer/ # cron scheduler for sync +│ ├── repositories/ # Prisma + on-chain reads +│ └── domain-services/ # off-chain reward math +├── prisma/ # schema + SQLite +├── Dockerfile +└── docker-compose.yml +``` + +- `event-fetch` talks to Solana RPC. +- `indexer` registers cron jobs for sync runs. +- `autotask` runs separate cron pipelines from indexing. +- `domain-services` mirrors on-chain reward formulas with BigInt. +- `repositories` wrap Prisma and PDA reads. + +### Implementation Notes + +#### Off-chain Reward Calculation + +The staking program uses a [MasterChef](https://github.com/sushiswap/masterchef)-style accumulator: update `acc_reward_per_share` before each action, then derive pending rewards from user stake and `reward_debt`. The backend copies the same math in BigInt so API queries do not hit RPC every time. + +``` +// src/domain-services/RewardCalculationService.ts +projectedAccRewardPerShare( + state: PoolState, + config: PoolConfig, + now: bigint +): bigint { + if (now <= BigInt(state.lastRewardTime) || state.totalStaked === 0n) { + return state.accRewardPerShare; + } + const reward = (now - BigInt(state.lastRewardTime)) * config.rewardPerSecond; + const accIncrement = (reward * ACC_REWARD_PRECISION) / state.totalStaked; + return state.accRewardPerShare + accIncrement; +} + +pendingRewards( + user: { amount: bigint; rewardDebt: bigint }, + state: PoolState, + config: PoolConfig, + now: bigint +): bigint { + const acc = this.projectedAccRewardPerShare(state, config, now); + const accumulated = (user.amount * acc) / ACC_REWARD_PRECISION; + const pending = accumulated - user.rewardDebt; + return pending > 0n ? pending : 0n; +} +``` + +BigInt end to end avoids JavaScript float bugs. API handlers call `pendingRewards`; callers do not need the on-chain formula. + +#### Sync Cursor Bootstrap + +The backend uses the `SyncStatus` table to track each pool's sync progress. The `db:init` script reads the `POOL_CONFIGS` env variable and populates the database with each pool's starting slot. + +``` +// scripts/db/init-db.ts +const entries = process.env.POOL_CONFIGS.split(","); +for (const entry of entries) { + const [poolConfigPda, slot] = entry.split(":"); + await prisma.syncStatus.create({ + data: { + poolConfig: poolConfigPda, + lastSyncBlock: Number(slot), + }, + }); +} +``` + +Each batch updates `lastSyncBlock`. Restarts continue from the last checkpoint without double-inserting or skipping ranges. + +### Tests + +[Jest](https://jestjs.io/) unit tests mock RPC and the database via injected interfaces. + +`EventIndexingService` tests cover cron registration and overlap protection: + +``` +// src/indexer/event-indexing.service.spec.ts +it("registers the event-indexing cron job on module init", () => { + service.onModuleInit(); + expect(schedulerRegistry.addCronJob).toHaveBeenCalledWith( + "event-indexing", + expect.any(Object) + ); +}); + +it("blocks overlapping sync cycles", async () => { + service.onModuleInit(); + let resolveRun: () => void; + mockRunOnce.mockReturnValue( + new Promise((resolve) => { + resolveRun = resolve; + }) + ); + const firstRun = service.tick(); + const secondRun = service.tick(); + // First cycle still running, second is skipped + expect(mockRunOnce).toHaveBeenCalledTimes(1); + resolveRun!(); + await Promise.all([firstRun, secondRun]); +}); +``` + +A sync pass can outlast the cron interval. An `isRunning` flag drops overlapping ticks so only one sync runs at a time. + +`PoolBalanceMonitorService` tests the alert lifecycle: + +``` +// src/autotask/pool-balance-monitor.service.spec.ts +it("goes through the full alert lifecycle: create → resolve → create again", async () => { + // Balance below threshold → create alert + rewardVaultReader.getRewardVaultBalance.mockResolvedValue({ + rewardVault: "reward-vault", + uiAmount: 5, + rawAmount: "5", + }); + await service.tick(); + expect(alertRepository.save).toHaveBeenCalledTimes(1); + + // Balance recovered → resolve alert + rewardVaultReader.getRewardVaultBalance.mockResolvedValue({ + rewardVault: "reward-vault", + uiAmount: 15, + rawAmount: "15", + }); + await service.tick(); + expect(alertRepository.resolveOpenAlert).toHaveBeenCalledTimes(1); + + // Balance below threshold again → create new alert + rewardVaultReader.getRewardVaultBalance.mockResolvedValue({ + rewardVault: "reward-vault", + uiAmount: 3, + rawAmount: "3", + }); + await service.tick(); + expect(alertRepository.save).toHaveBeenCalledTimes(2); +}); +``` + +Mocked `rewardVaultReader` and `alertRepository` stand in for RPC and the DB. + +### Deployment + +Multi-stage Docker keeps the runtime image small: + +``` +FROM node:20-alpine AS builder +WORKDIR /app +RUN apk add --no-cache python3 make g++ && corepack enable +COPY package.json pnpm-lock.yaml ./ +COPY prisma ./prisma +COPY idl ./idl +RUN pnpm install --frozen-lockfile +COPY src ./src +RUN pnpm db:generate && pnpm build + +FROM node:20-alpine AS runtime +WORKDIR /app +ENV NODE_ENV=production +RUN corepack enable +COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/prisma ./prisma +COPY --from=builder /app/idl ./idl +CMD ["node", "dist/src/main.js"] +``` + +`docker-compose.yml` mounts `./prisma` so SQLite survives container restarts: + +``` +services: + solana-backend: + build: . + env_file: .env + ports: + - "3000:3000" + volumes: + - ./prisma:/app/prisma + restart: unless-stopped +``` + +Typical deploy: + +``` +docker compose build solana-backend +docker compose run --rm solana-backend ./node_modules/.bin/prisma db push +docker compose run --rm solana-backend node dist/scripts/db/init-db.js +docker compose up -d +``` + +`db push` creates tables, `db:init` writes sync cursors, `up -d` starts the app. Use `docker compose logs -f solana-backend` for logs. + +## Summary + +Solana backends cannot rely on typed event queries like `eth_getLogs`. You pick accounts to watch, page signatures, fetch transactions, parse logs, and checkpoint progress. Filter failed txs at `tx.meta.err`. For volume or latency, consider Geyser instead of RPC polling. + +[solana-backend](https://github.com/57blocks/evm-to-solana/tree/main/backend/solana-backend) shows the layout: event-fetch and indexer for history, autotask for vault monitoring, BigInt reward math off-chain, Jest with mocks, Docker deploy. + +On EVM, the node often prepares the data layer for you. On Solana, the backend is that layer. Slot-to-signature cursors, reversed signature lists, and batch checkpoints are tedious, but they also mean you control exactly what gets indexed. + +## References + +- [Solana RPC Overview](https://solana.com/docs/rpc) +- [SPL Token](https://solana.com/docs/tokens/basics) +- [Token-2022](https://www.solana-program.com/docs/token-2022) +- [Geyser gRPC (Yellowstone)](https://github.com/rpcpool/yellowstone-grpc) +- [MasterChef](https://github.com/sushiswap/masterchef) + diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Backend/thumb.png b/articles/How to Migrate an Ethereum Protocol to Solana Backend/thumb.png new file mode 100644 index 0000000..cee4a45 Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Backend/thumb.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Backend/thumb_h.png b/articles/How to Migrate an Ethereum Protocol to Solana Backend/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 Backend/thumb_h.png differ 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..67d21a6 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 @@ -16,15 +16,18 @@ intro: "A deep dive into the core mindset shift and best practices when moving c As the Solana ecosystem matures, more Ethereum (EVM) protocol teams are exploring migration to Solana to achieve higher throughput, lower transaction costs, and improved user experience. Through leading and executing multiple real-world Ethereum-to-Solana migrations, we've accumulated hands-on experience across smart contract architecture, data models, transaction design, and full-stack coordination. -This article is part of a broader series on migrating Ethereum protocols to Solana, where we break the process down into three core layers: smart contracts, backend services, and frontend interactions. If you're new to the series, we recommend starting with "[How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering)," which introduces the fundamental architectural differences between the two ecosystems. +This article is part of a broader series on migrating Ethereum protocols to Solana, where we break the process down into three core layers: smart contracts, backend services, and frontend interactions. If you're new to the series, we recommend starting with [How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering), which introduces the fundamental architectural differences between the two ecosystems. In this article, we focus specifically on the smart contract layer. Rather than treating migration as a simple language switch from Solidity to Rust, we examine the deeper mindset shifts required when moving from Ethereum's contract-centric model to Solana's account-centric design. Using concrete examples and real production patterns, we'll walk through the most important conceptual changes, common pitfalls, and best practices that Ethereum developers need to understand to build secure and efficient Solana programs. #### Article Navigation -- [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 — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble): account models, execution, and fees on both chains. +- [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): account model, CPI, PDAs, and Anchor patterns for EVM developers. +- [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): Solana limits (CU, forks, hooks, events) and a staking migration walkthrough. +- [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): wallets, RPC, and client patterns when moving a DApp frontend to Solana. +- [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): transaction building, account fetching, and event/log handling on the client. +- [How to Migrate an Ethereum Protocol to Solana — Backend](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-backend): event sync, log parsing, and state management for a production backend. --- Solana rose in popularity and matured quickly because its high performance and low costs attracted a surge of developer and user attention. Meanwhile, Ethereum (EVM) and EVM-compatible chains had massive ecosystems but faced challenges like limited scalability and high transaction fees. As a result, more Web3 developers have been turning to Solana for its improved developer and user experiences, making the migration from Ethereum to Solana a prominent trend. So, how do we effectively port the smart contracts we've mastered on Ethereum to the Solana platform? Many developers initially thought that they would only need to reprogram apps using Rust rather than Solidity, but they soon discovered that the real migration challenge rested in the fundamental differences of Solana's underlying architecture. This article aims to help experienced Ethereum developers complete a critical mental model shift so they can efficiently and securely reimplement existing contract logic the Solana way. @@ -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..ced047f --- /dev/null +++ b/articles/How to Migrate an Ethereum Protocol to Solana Contracts (Part 2)/README.md @@ -0,0 +1,586 @@ +--- +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: "Solana contract constraints that bite during an EVM migration, plus a full staking port from Solidity to Anchor." +--- + +## Article Overview + +Once 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. Day-to-day migrations still run into problems that standard Solidity practices can't fix: mainnet fork testing is manual and easy to get wrong; transactions have a hard compute unit cap; CPI is one-way (no re-entrancy, no synchronous callbacks); token hooks and off-chain events are not `ERC-20` plus `emit`. Plan for these early or you refactor late. + +The sections below walk through each constraint first, then port staking end to end (`stake`, `unstake`, `claimRewards`) with code in [evm-to-solana](https://github.com/57blocks/evm-to-solana): 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): account models, execution, and fees on both chains. +- [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): account model, CPI, PDAs, and Anchor patterns for EVM developers. +- [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): Solana limits (CU, forks, hooks, events) and a staking migration walkthrough. +- [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): wallets, RPC, and client patterns when moving a DApp frontend to Solana. +- [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): transaction building, account fetching, and event/log handling on the client. +- [How to Migrate an Ethereum Protocol to Solana — Backend](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-backend): event sync, log parsing, and state management for a production backend. + +## Solana Limitations and Trade-offs You Should Know + +Part 1 covers how to structure programs and accounts. 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 + +On Ethereum, mainnet forking (Hardhat, Foundry) provides a lazy-loaded snapshot of the mainnet. You can call Uniswap or other live protocols without listing every account ahead of time. + +On Solana, `solana-test-validator --clone` only copies addresses you name at startup. There is no lazy full-state fork like Ethereum. To test against Jupiter you track down pools, config accounts, authorities, and anything else the instruction touches. Miss one account and the test fails in a way that is hard to debug. + +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, gas limits scale with what you pay. Solana caps each transaction at 1.4 million compute units (CUs) no matter the fee. Every instruction spends CUs, including CPIs. Exceed the budget and the whole transaction fails. + +Large loops and big in-memory passes that are routine on Ethereum often blow the CU budget on Solana. Split work across transactions or tighten the algorithm. We go deeper on limits in [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 + +Re-entrancy is a familiar Solidity bug: contract B can call back into contract A before A finishes writing state. External calls are synchronous. A common mistake is updating balances after the 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 CPI is one-way: program A can invoke program B, but B cannot call back into A in the same transaction. The call graph is acyclic. Even updating state after a CPI (not recommended style) avoids classic re-entrancy: + +```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(()) +} +``` + +While `token::transfer` runs, your program waits. The Token Program will not re-enter `withdraw`, and Solana has no `fallback`/`receive` hooks for strangers to call mid-instruction. + +The trade-off: flash loans and other synchronous callback chains do not map cleanly. You usually split steps across transactions or check state in a follow-up instruction. + +### Why Hooks are Harder + +Transfer hooks show up on both chains; the wiring differs. + +On Ethereum you usually own the token contract: override `transfer` or `_beforeTokenTransfer` for allow lists, fees, and similar rules. + +On Solana the SPL Token Program is shared and fixed. Token-2022 adds a `Transfer Hook` extension: you deploy a hook program, bind it at mint creation, and every transfer CPIs into it (read-only accounts). The hook approves or aborts the transfer. + +Setup on Solana is heavier than editing 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 `execute` and an `extra-account-metas` PDA so the hook sees every account it needs. A failing hook rolls back the transfer. + +Callers must use `transferChecked`; plain `transfer` will fail. Many wallets and DEXs still lack hook support, so check compatibility before shipping. + +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, `emit` writes structured logs. Indexers subscribe by topic and indexed fields to drive UIs, analytics, and jobs. + +```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 built-in logging functions (`sol_log`, Anchor’s `msg!`) output printf-style text into transaction logs. While this works fine for debugging, it is terrible for indexing queries. Parsers typically have to scrape the entire log rather than filtering by type. + +Anchor’s `#[event]` wraps a struct and serializes it into logs (often Base64) so clients can parse something closer to an EVM event. + +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(()) +} +``` + +`emit!` writes those bytes into the transaction log. Indexing is still weaker than Ethereum topics, but workable. Use Anchor’s `EventParser` on the client, or Helius-style webhooks and enhanced transaction APIs if you do not want to own the parser. + +For anything production-facing off-chain, use `#[event]` instead of raw `msg!`. + +## 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 (same example as Part 1), including Foundry and Anchor code, 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) + +On Ethereum the staking logic lives in one `Staking.sol` contract: state and functions together. + +**Contract structure and state variables** + +```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, per-user `StakeInfo`) sits in this contract’s storage. + +**Core function implementations** + +`stake`, `unstake`, and `claimRewards` read and write those storage fields directly. + +```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) + } +} +``` + +As you can see, the process is quite straightforward: the contract acts like an all-in-one central processor. It holds the tokens (the vault), maintains the ledger for every user, and directly executes all computations and state updates—this is a typical Ethereum contract design pattern. + +The full contract code is available [here](https://github.com/57blocks/evm-to-solana/tree/main/contract/evm-staking). + +### Solana Implementation (Anchor) + +The Solana version keeps the same behavior but splits code and 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** + +Each instruction must explicitly declare all accounts it will touch. The `stake` Context: + +```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** + +`stake` CPIs the Token Program to move tokens, then writes `pool_state` and `user_stake_info` supplied in the context. + +```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(()) +} +``` + +This pattern clearly illustrates Solana's core philosophy: a stateless program (logic) operating on external, explicitly passed-in accounts (data). + +The full Solana implementation is available [here](https://github.com/57blocks/evm-to-solana/tree/main/contract/solana-staking). + +### Contract Testing + +Foundry and Anchor take different approaches to tests. + +**Framework comparison** + +- **Ethereum / Foundry:** tests in Solidity against the contract, with `vm.prank` and other cheatcodes. +- **Solana / Anchor:** tests in TypeScript against a local validator, closer to how a client calls your program, with more setup boilerplate. + +**Foundry test example** + +From `evm-staking`, a direct `stake` test: + +```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 + +Deployment differs at the protocol level and in the 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. + +We deploy with a Foundry script in this repo. + +```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** + +Deploying a Solana program uploads the compiled binary (BPF, usually a `.so` in `target/deploy/`) to a program account. Business state stays in separate accounts. Anchor wraps the CLI steps: + +```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 +``` + +Set the cluster with `--provider.cluster` (`localnet`, `devnet`, or `mainnet-beta`). + +To ship new logic without migrating state, rebuild and upgrade the same program id: + +```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). + +Each `forge script` deploy on Ethereum usually gets a new contract address and empty storage. `anchor deploy` / `anchor upgrade` keeps the program id; only the executable changes while pool and user accounts stay put. + +## Summary + +[Part 1](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-contracts-part-1) covered stateless programs, explicit accounts, CPI, and PDAs. Here we added practical limits: mainnet fork testing, CU caps, one-way CPI, transfer hook, and Anchor events for indexing. + +The staking example uses the same three instructions on both chains with different layout: one Solidity contract vs. split pool/user accounts and Token Program CPIs. + +Before you port, decide what is global state vs. per-user PDAs, which steps call SPL programs, and whether any instruction needs splitting for CU. Treat it as a redesign, not a line-by-line port. + +Up next in the series: frontend and backend changes after the contracts move. + +## 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 diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/README.md b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/README.md new file mode 100644 index 0000000..1517a4a --- /dev/null +++ b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/README.md @@ -0,0 +1,1331 @@ +--- +published: true +title: "How to Migrate an Ethereum Protocol to Solana — Frontend(Part 1)" +author: ["Bonnie Chen/ Full Stack Engineer", "Shan Yang/Tech Lead"] +createTime: 2026-05-26 +categories: ["engineering"] +subCategories: ["Blockchain & Web3"] +tags: ["Solana", "Ethereum", "Frontend", "Wallet", "Transaction"] +landingPages: ["Blockchain-Onchain infra"] +thumb: "./thumb.png" +thumb_h: "./thumb_h.png" +intro: "Frontend architecture design and practical implementation for high-performance data access and transaction optimisation when migrating from Ethereum to Solana." +--- + +## Article Overview + +Teams are moving EVM projects to Solana for higher throughput, lower fees, and better UX. We've done this in production — migrated multiple Ethereum protocols to Solana across different industries. The hard parts are contract architecture, data models, transaction logic, and frontend-backend coordination. We've learned what breaks and what works. + +This series covers the three layers you actually touch during a migration: smart contracts, backend services, and the frontend. Each article comes from real projects — the pitfalls, the workarounds, the patterns that held up. We include full code examples and end-to-end case studies. + +This article covers the frontend stuff you actually touch when moving a DApp from EVM to Solana: wallet connection (including custom mobile adapters for wallets without official support), hardware wallet login via signTransaction + Memo, Address Lookup Tables, priority fees, transaction retry, and Jito Bundles. Not a staking demo — that's Part 2. This is the infrastructure layer. Everything below comes with working code you can use. + +#### Article Navigation + +- [How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble): account models, execution, and fees on both chains. +- [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): account model, CPI, PDAs, and Anchor patterns for EVM developers. +- [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): Solana limits (CU, forks, hooks, events) and a staking migration walkthrough. +- [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): wallets, transaction building, errors, and Jito bundles. +- [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): transaction building, account fetching, and event/log handling on the client. +- [How to Migrate an Ethereum Protocol to Solana — Backend](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-backend): event sync, log parsing, and state management for a production backend. + +--- + +## 1. Connecting Wallets + +Connecting a wallet to a Solana frontend is easy. Keeping the experience consistent across platforms is not: + +- Desktop: the wallet injects itself as a browser extension. +- Mobile: you need Deeplink / Universal Link to wake up a separate wallet app. +- In-app browser (e.g., Phantom Browser): the wallet injects a global object and pre-connects. + +A wallet's capabilities and behavior vary across these environments. Adapting to each wallet and platform individually doesn't scale. `@solana/wallet-adapter` (from Anza) solves this with an Adapter pattern — you write against a single interface, and it handles the rest. + +Below is the standard setup from the official docs ([APP.md](https://github.com/anza-xyz/wallet-adapter/blob/master/APP.md)), then we'll get into something the docs don't cover well: what happens on mobile when a wallet doesn't have an adapter yet. + +When integrating a wallet with a Solana DApp, you'll face: + +- **Adapter Availability:** Not every wallet ships an official adapter. +- **Mobile Connectivity:** Desktop users can often connect through an injected object even without an adapter. On mobile, without an official adapter or deep link support, the wallet app simply can't be reached. +- **The fix:** Build a custom wallet adapter backed by a deep link as a fallback for mobile. + +### 1.1 Standard Connection Flow: `ConnectionProvider` + `WalletProvider` + +In a typical React application, the minimum recommended access method for `@solana/wallet-adapter` is: + +- Use `ConnectionProvider` to provide an RPC connection. +- Use `WalletProvider` to register a set of supported wallets (official adapters are already implemented for common wallets). +- Use UI components (e.g., `WalletModalProvider` + `WalletMultiButton`) for interaction. + +In a desktop environment, as long as the wallet injects an object conforming to expectations via `window.xxx`, the connection or signing can often be completed through "non-standard methods" even without explicit adapter registration. + +However, this method does not naturally migrate to mobile scenarios. + +### 1.2 The Reality: The Adapter Gap on Mobile + +In practice, you hit these problems: + +1. Some wallets have not yet provided an official wallet adapter. +2. Some multi-chain wallets support Solana, but their injected objects, connection flow, or signing interface are not fully consistent with the standard adapter's expectations. +3. In mobile browsers, wallets typically do not inject a global object, and the only viable entry point for the DApp is the deeplink. + +This leads to a crucial difference: + +- On desktop, "no adapter" is usually just an experience issue. +- On mobile, "no adapter" often means the user cannot connect the wallet at all. + +The official documentation mainly describes the problem from the perspective of "[how a wallet should implement an adapter](https://github.com/anza-xyz/wallet-standard/blob/master/WALLET.md)", but in reality, the progress of wallet-side adaptation is not entirely controllable. As a DApp developer who has adopted the `@solana/wallet-adapter` system, you naturally face this problem: if a certain wallet does not have an official adapter yet, but you still want to provide a clear, usable connection entry point for mobile users, what should you do? + +### 1.3 Solution: Custom Wallet Adapter as a Mobile Fallback + +`@solana/wallet-adapter`'s core abstraction is the `WalletAdapter` interface (defined in `@solana/wallet-adapter-base`). The application layer doesn't care whether an adapter is official or custom — it just needs something that implements the interface. + +So we build a custom adapter backed by a deep link, with: + +- Basic identification information: `name / url / icon / deepLink`. +- Connection status: `publicKey / connecting / readyState`. +- Core methods: + - `connect() / disconnect() / isConnected()` + - `sendTransaction(tx, connection, options)` and others. + +From the application layer perspective: + +- The custom adapter is completely equivalent to official adapters like Phantom and Solflare. +- They can be registered together in `WalletProvider`. +- The UI layer (e.g., `WalletMultiButton`) does not need to perform any special checks. + +This makes a custom adapter very suitable as a fallback solution for scenarios where "the official adapter has not yet covered this wallet, but mobile users must have an entry point." + +As long as the same interface is implemented, you can register your `CustomWalletAdapter` just like registering Phantom, and it will be included in the `WalletProvider`'s wallet list. + +```typescript +export class CustomWalletAdapter extends BaseWalletAdapter { + readonly name: WalletName; + readonly icon: string; + readonly url: string; + readonly deepLink: string; + readonly supportedTransactionVersions = new Set<"legacy" | 0>(["legacy", 0]); + + private _connecting: boolean = false; + private _publicKey: PublicKey | null = null; + private _readyState: WalletReadyState; + + constructor(config: CustomWalletConfig) { + super(); + this.name = config.name; + this.icon = config.icon; + this.url = config.url; + this.deepLink = config.deepLink; + // Loadable: The wallet can be loaded (via deep link) but isn't installed as browser extension + this._readyState = WalletReadyState.Loadable; + } + + get publicKey(): PublicKey | null { + return this._publicKey; + } + + get connecting(): boolean { + return this._connecting; + } + + get connected(): boolean { + return !!this._publicKey; + } + + get readyState(): WalletReadyState { + return this._readyState; + } + + async autoConnect(): Promise { + // Deep link adapters should never auto-connect + // Auto-redirect without user interaction is bad UX + // Do nothing - require explicit user action to connect + } + + async connect(): Promise { + try { + // For deep link adapters, we only redirect to the wallet app + // The actual connection happens in the wallet's in-app browser + // We don't emit "connect" here because the connection isn't established yet + window.location.href = this.deepLink; + } catch (error) { + this.emit("error", new WalletConnectionError((error as Error).message)); + throw error; + } + } + + async disconnect(): Promise { + this._publicKey = null; + this.emit("disconnect"); + } + + async sendTransaction( + _transaction: T, + _connection: Connection, + _options?: SendOptions, + ): Promise { + // Deep link adapters handle transactions within the wallet app + // The actual signing happens after the deep link redirect + throw new Error( + "sendTransaction is handled by the wallet app after deep link redirect", + ); + } + + async signTransaction( + _transaction: T, + ): Promise { + throw new Error( + "signTransaction is handled by the wallet app after deep link redirect", + ); + } + + async signAllTransactions( + _transactions: T[], + ): Promise { + throw new Error( + "signAllTransactions is handled by the wallet app after deep link redirect", + ); + } + + async signMessage(_message: Uint8Array): Promise { + throw new Error( + "signMessage is handled by the wallet app after deep link redirect", + ); + } +} +``` + +Based on this basic adapter, we can further wrap a utility method to create different wallet adapters based on configuration, such as Backpack. + +```typescript +/** + * Build the Backpack deep link URL for mobile. + * Uses Universal Link format for iOS/Android. + */ +const buildBackpackDeepLink = (): string => { + const currentUrl = getCurrentUrl(); + const origin = getCurrentOrigin(); + + // Backpack Universal Link format + return `https://backpack.app/ul/v1/browse/${encodeURIComponent( + currentUrl, + )}?ref=${encodeURIComponent(origin)}`; +}; + +/** + * Create a Backpack wallet adapter. + * + * On mobile: Uses deep link to open Backpack app + * On desktop: Redirects to Chrome extension installation page + */ +export function createBackpackWalletAdapter(): CustomWalletAdapter { + return createCustomWalletAdapter({ + name: "Backpack" as WalletName<"Backpack">, + icon: BACKPACK_ICON, + url: BACKPACK_URL, + deepLinkBuilder: () => + isMobile() ? buildBackpackDeepLink() : BACKPACK_CHROME_EXTENSION_URL, + }); +} + +/** + * Create Backpack adapter only for mobile devices. + * Returns null on desktop (where the official adapter should be used). + */ +export function createBackpackMobileAdapter(): CustomWalletAdapter | null { + if (!isMobile()) { + return null; + } + return createBackpackWalletAdapter(); +} +``` + +In this way, we can register `BackpackWalletAdapter()` as a "custom wallet" in `WalletProvider` to provide a fallback for scenarios not yet covered by the official adapter. + +### 1.4 Deeplink: The Last Mile to Connect Mobile Wallets + +In a mobile environment, most Solana wallets exist as standalone Apps, and communication between the DApp and the wallet is typically through Deeplink / Universal Link. The typical flow is: + +1. The DApp constructs a URL defined by the wallet, for example: `mywallet://connect?dapp_url=...&session=...` +2. The browser redirects to this URL, and the system wakes up the wallet App. +3. After the wallet completes authorization or signing, it returns the result to the DApp via a callback URL. + +In the custom adapter design above: + +- The custom adapter encapsulates all the details of the deeplink protocol internally, while still exposing unified methods like `connect()`, `sendTransaction()`, etc. +- To the application, both browser extension wallets and mobile wallets launched via deeplink are, at the code level, merely different implementations of `WalletAdapter`. +- Therefore, when an official adapter temporarily does not cover a certain wallet, you can still use the "deeplink + custom adapter" route to provide a discoverable and usable connection entry point for the user, without breaking the existing `@solana/wallet-adapter` integration pattern. + +Thus, when an official adapter is temporarily unavailable for a wallet: + +You can still use the "Custom Adapter + Deeplink" approach to provide a clear, discoverable, and maintainable connection entry point for mobile users, without disrupting the existing `@solana/wallet-adapter` architecture. + +## 2. Wallet Login with Signature: Sign Message vs. Sign Transaction + +In a Solana DApp, simply getting the user's `publicKey` through "connecting the wallet" does not prove that the address is indeed controlled by the current user. The safer and more standard approach is to introduce a **Challenge-Response** protocol, requiring the user to sign a message generated by the backend to complete the login verification. + +### 2.1 Challenge-Response Basic Flow + +The typical wallet login flow is as follows: + +1. **User Connects Wallet** + + The user clicks "Connect Wallet" on the frontend, and the DApp establishes a connection with the wallet via the wallet adapter and obtains the user's `publicKey`. + This step only indicates that the user agrees to expose the address and does not constitute identity verification. + +2. **Frontend Requests Challenge from Backend** + + The frontend sends the obtained `publicKey` to the backend, requesting the generation of a login Challenge. + +3. **Backend Generates Challenge (Challenge Information)** + + The Challenge typically includes: + + - Wallet address (`publicKey`) + - Random number (`nonce`) + - Expiration time (`timestamp / TTL`) + - DApp identifier (e.g., domain name, application name, etc.) + +4. **Frontend Requests Wallet Signature** + + The frontend passes the Challenge to the wallet, requesting the user to sign it. + +5. **Frontend Submits Signature Result** + + The frontend sends the original Challenge and the signature generated by the user to the backend. + +6. **Backend Verifies Signature and State** + + The backend performs the following checks: + + - Verifies the signature's validity using `publicKey`. + - Checks if the wallet address in the Challenge matches the `publicKey` in the request. + - Checks if the `nonce` has not been used (to prevent replay attacks). + - Checks if the Challenge is within its validity period. + +7. **Login Success** + + After successful verification, the backend issues a session credential (e.g., JWT or Session) for that wallet address, used for subsequent identity identification. + +![](diagram.png) + +### 2.2 Signature Method Compatibility Issues + +- **`signMessage`**: Most software wallets support signing arbitrary messages, making it the simplest and most direct way to implement login. +- **`signTransaction`**: Some hardware wallets (e.g., Ledger, Trezor, etc.) typically do not support signing arbitrary messages; they only allow signing standard Solana Transactions. + +To accommodate these wallets, we construct a "special transaction" that contains only a Memo or other side-effect-free instruction, and ask the user to complete the login signature via `signTransaction`. Note: this transaction is only for local signing and backend verification. It is _not_ sent on-chain — it's an _off-chain_ signature that doesn't record anything on-chain or consume gas. + +Below we introduce both paths, aiming to reuse a single set of verification logic on the frontend and backend as much as possible. + +**Constructing and Signing a Message** + +A readable message format, similar to "Sign-In with Solana," can be used, for example: + +```typescript +const generateSignInMessage = useCallback((walletAddress: PublicKey) => { + + const timestamp = new Date().toISOString(); + const nonce = Math.random().toString(36).substring(7); + + return `${appName} wants you to sign in with your Solana account: + ${walletAddress.toBase58()} + + Please sign in to verify your ownership of this wallet + + URI: https://${domain} + Version: 1 + Network: Solana + Nonce: ${nonce} + Issued A + + // ... (rest of the message) ... +}, [appName, domain]); +``` + +Then, use the wallet's `signMessage` for signing. When using `@solana/wallet-adapter`, `signMessage` is an optional method, so its existence must be checked first: + +```typescript +const signMessageForAuth = useCallback( + async (message: string): Promise => { + if (!publicKey || !signMessage) { + throw new Error( + "Wallet not connected or does not support message signing", + ); + } + const encodedMessage = new TextEncoder().encode(message); + const signature = await signMessage(encodedMessage); + const signatureBase58 = bs58.encode(signature); + return { + signature: signatureBase58, + publicKey: publicKey.toBase58(), + success: true, + }; + }, + [publicKey, signMessage], +); +``` + +Here, we choose to base58 encode the `messageBytes` (`serializedMessage`). This allows the backend to uniformly handle the "signature + original signed bytes" (two pieces of data) to be compatible with both the `signMessage` and the `signTransaction` solution discussed later. + +### 2.3 Backend: Verifying `signMessage` Signature + +The backend logic is essentially standard Ed25519 signature verification. In Node.js, for example: + +```typescript +const publicKeyObj = new PublicKey(publicKeyStr); + +// Decode the base58 encoded inputs +const signatureBytes = bs58.decode(signature); +const messageBytes = bs58.decode(serializedMessageBase58); + +// Verify the signature using the ed25519 algorithm +const isValid = ed25519.verify( + signatureBytes, + messageBytes, + publicKeyObj.toBytes(), +); + +// Note: The commented-out code below shows an alternative +// verification method using nacl. +// const isValid = nacl.sign.detached.verify( +// messageBytes, +// signatureBytes, +// publicKeyObj.toBytes() +// ); + +return isValid; +``` + +Before calling the signature verification function, the backend must also: + +- Read the Challenge from storage. +- Verify: + - The `nonce` has not been used. + - The current time is within the validity period. + - The address declared in the Challenge matches `publicKeyStr`. +- After successful verification, immediately invalidate the Challenge and issue its own login session. + +That's the `signMessage`-based signature login flow, which works for most software wallets. + +### 2.4 Hardware Wallet: `signTransaction` + Memo Disguised as Sign Message + +In the security model of many hardware wallets, only signing a Transaction is allowed, but not signing an arbitrary sequence of bytes. Therefore, at the adaptation layer, you might encounter: + +- `wallet.signMessage` does not exist or throws a "not supported" error directly. +- Users with Ledger / Trezor cannot follow the `signMessage` login flow. + +To ensure compatibility with these wallets, the Challenge can be embedded into a transaction that is "only for signing, not necessarily for going on-chain," allowing the wallet to complete the signature with the same meaning via `signTransaction`. + +#### 2.4.1 Wallet Signs via `signTransaction` + Memo + +Example implementation (Frontend): + +```typescript +const signTransactionForAuth = useCallback( + async (message: string): Promise => { + if (!publicKey || !signTransaction) { + throw new Error( + "Wallet not connected or does not support transaction signing", + ); + } + const blockhashResponse = await connection.getLatestBlockhash(); + const lastValidBlockHeight = blockhashResponse.lastValidBlockHeight - 150; + + const memoInstruction = new TransactionInstruction({ + keys: [], + programId: MEMO_PROGRAM_ID, + data: Buffer.from(message, "utf-8"), + }); + + const tx = new Transaction({ + feePayer: publicKey, + blockhash: blockhashResponse.blockhash, + lastValidBlockHeight: lastValidBlockHeight, + }).add(memoInstruction); + + // Sign the transaction + const signedTx = await signTransaction(tx); + + // Get the signature from the signed transaction + const txSignature = signedTx.signatures[0]; + const signatureBase58 = bs58.encode(txSignature.signature!); + + // Get the serialized message that was actually signed + // This is what the wallet signed - the serialized transaction data + const serializedMessage = signedTx.serializeMessage(); + const serializedMessageBase58 = bs58.encode(serializedMessage); + + return { + signature: signatureBase58, + publicKey: publicKey.toBase58(), + success: true, + serializedMessage: serializedMessageBase58, + }; + }, + [publicKey, signTransaction, connection], +); +``` + +In the hardware wallet path, you also obtain two core pieces of data: + +- `signatureBase58`: The Ed25519 signature of the transaction message. +- `serializedMessageBase58`: The raw message bytes obtained by calling `tx.serializeMessage()`. + +The format is consistent with the `signMessage` path, allowing the backend to use the same set of verification logic. + +#### 2.4.2 Backend: How to Verify `signTransaction` Signature + +When the backend receives this type of request (which can be treated as `type: 'transaction'`), it first needs to verify that the signature was generated by the private key corresponding to the address: + +```typescript +const publicKeyObj = new PublicKey(publicKeyStr); +const signatureBytes = bs58.decode(signature); +const messageBytes = bs58.decode(serializedMessageBase58); + +// Verify the signature using ed25519 +const isValid = ed25519.verify( + signatureBytes, + messageBytes, + publicKeyObj.toBytes(), +); + +return isValid; + +// The commented-out nacl verification is also provided for reference: +/* +// Verify the signature using nacl +const isValidNacl = nacl.sign.detached.verify( + messageBytes, + signatureBytes, + publicKeyObj.toBytes() +); +*/ +``` + +On this basis, it can also cooperate with the Challenge for additional verification: + +- The Challenge (including its `nonce/expiration time/address`, etc.) is still generated and saved by the backend. +- The frontend writes the Challenge into the `data` field when constructing the Memo. +- For stronger security, the backend can further parse the transaction content corresponding to `messageBytes`, find the Memo instruction, and compare its `data` to ensure it matches the Challenge (this step is an enhanced verification, and the specific parsing code is not expanded here). + +Overall, the security semantics of this path are the same as the `signMessage` path: the user performed a non-repudiable signature on a Challenge initiated by the backend, and the backend establishes a login session for that address after verifying the signature and the Challenge's validity. + +## 3. Data and Indexing Layer + +Fetching and querying on-chain data efficiently matters for any DApp's frontend. The Solana ecosystem has its own indexing story — different from EVM's but covering the same needs. + +### 3.1 Data Indexing in EVM: Subgraphs + +In the EVM ecosystem, Subgraph is a tool used to build custom GraphQL APIs specifically for indexing blockchain data. Developers can use Subgraphs to: + +- Aggregate application-specific blockchain data for fast frontend access. +- Listen for smart contract events and store the data in a Graph Node. +- Provide real-time query interfaces to support complex frontend requirements. + +#### 3.1.1 How to use Subgraph + +The specific steps to build a Subgraph can be found in the official tutorial: [The Graph Quick Start](https://thegraph.com/docs/en/quick-start/). + +#### 3.1.2 Querying Data + +Once a Subgraph is created, the frontend can query on-chain data via GraphQL, eliminating the need to call RPC nodes directly, which greatly improves query efficiency. + +Querying the latest reward claim records: + +```typescript +import { gql, request } from "graphql-request"; +import { useQuery } from "@tanstack/react-query"; + +const REWARD_HISTORY_QUERY = gql` + { + rewardClaimeds(first: 10, orderBy: blockNumber, orderDirection: desc) { + id + user + reward + blockNumber + } + } +`; + +const useRewardHistory = () => { + const { data, refetch, isLoading, error, isRefetching } = useQuery<{ + rewardClaimeds: RewardRecord[]; + }>({ + queryKey: ["reward-history"], + queryFn: async () => { + const graphqlUrl = process.env.NEXT_PUBLIC_GRAPH_URL || ""; + return await request(graphqlUrl, REWARD_HISTORY_QUERY, {}, headers); + }, + refetchInterval: 30000, //Refetch every 30 seconds + refetchOnWindowFocus: true, // Refetch when window gains focus + staleTime: 10000, // Data is considered stale after 10 seconds + }); + + return { data, refetch, isLoading, error, isRefetching }; +}; +``` + +### 3.2 Solana Ecosystem: RPC and Third-Party Services + +Solana RPC nodes are fast at reading state — good for single-account queries and simple filtering. [@solana/rpc-graphql](https://github.com/anza-xyz/kit/tree/main/packages/rpc-graphql) gives you a GraphQL interface on top of RPC, which handles multi-account aggregation and complex data access without extra traversal code. + +However, native RPC and `rpc-graphql` still have limitations: + +- Complex cross-program queries or event backtracking still require additional handling. +- Queries for large volumes of historical data may be less efficient than specialized indexing services. + +Therefore, third-party indexing services remain valuable, such as **Helius** high-performance API and event subscription, which supports NFT, transaction events, and staking data queries. + +While Solana native RPC + `rpc-graphql` can address most query needs, third-party indexing services remain indispensable for complex queries, event subscriptions, and historical data access. + +#### 3.2.1 Self-Built Backend Indexer + REST API + +Event backtracking can also be implemented based on the Solana RPC without relying on third-party indexing services. The core idea is: + +1. Determine the Backtracking Range: Use the slot as the timeline to define the backtracking scope (usually avoiding the latest slot to ensure data stability). +2. Fetch Transaction Signatures: Call the RPC's getSignaturesForAddress method for the set of addresses to be monitored, fetching transaction signatures in pages within the specified range. +3. Get Transaction Details: After obtaining the signatures, concurrently call **getParsedTransaction** to fetch the details of each transaction. +4. Extract Events: Use a custom event parser to extract business events from transaction instructions, inner instructions, or logs. +5. Data Integration: Consolidate and sort the scattered events by slot/time, and output a new checkpoint (the synchronized slot) for the next round of incremental synchronization. + +This method can cover the needs of "historical playback + resuming from a breakpoint," but specialized indexing services are still more efficient in scenarios like complex cross-program analysis, real-time subscriptions, and retrieving massive amounts of historical data. + +We built a [working indexer](https://github.com/57blocks/evm-to-solana/tree/main/backend/solana-backend) with NestJS: it periodically pulls new transactions from the chain, uses Anchor's **EventParser** to extract events, stores them in a database, and exposes aggregated data to the frontend through a REST API. + +#### 3.2.2 Querying Data + +With the backend handling indexing and aggregation, the frontend's job is straightforward: call a REST API. + +```typescript +const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000"; + +async function fetchRewards(userAddress: string): Promise { + const res = await fetch(`${API_BASE}/api/rewards/${userAddress}`); + if (!res.ok) { + throw new Error(`Failed to fetch rewards: ${res.statusText}`); + } + return res.json(); +} + +const rewards = useQuery({ + queryKey: ["reward-history", address], + queryFn: () => fetchRewards(address), + enabled: !!address, + refetchInterval: 30000, + staleTime: 10000, +}); +``` + +## 4. Address Lookup Table (ALT) + +When developing DApps on Solana, a common limitation is that **a single transaction can reference a maximum of 32 accounts, and each transaction has a size limit of 1232 bytes**. The 1232-byte transaction size limit is not arbitrary but directly derived from the MTU constraints in real network environments, ensuring that transactions can be reliably broadcast without packet fragmentation (see Solana official [transaction documentation](https://solana.com/docs/core/transactions)). + +This can become a bottleneck in complex scenarios, such as: + +- DeFi protocol calls involving multiple Token accounts and auxiliary accounts. +- Batch NFT Mint or Transfer operations. + +To solve this problem, Solana provides the **Address Lookup Table (ALT)** — a specialized on-chain structure used to store account addresses. + +### 4.1 Core Mechanism + +- **Account Storage**: You can write frequently used account addresses into the ALT. +- **Index Reference**: The transaction no longer carries the full account address but resolves it from the ALT via an index. +- **Extension Capabilities**: + - Each ALT can store up to 256 account addresses. + - Each `extend` operation can write up to 20 addresses. +- **Transaction Compatibility**: ALT relies on the v0 version of the transaction format (`Versioned Transaction`), which can break the 32-account limit and reduce transaction size. + +### 4.2 Use Cases + +- **Breaking the Account Limit**: Complex cross-protocol interactions and batch NFT operations can be completed in one go. +- **Reducing Transaction Size**: Compressing account addresses through indexing reduces the number of bytes, leading to lower fees. + +### 4.3 Usage Flow and Example (Anchor Frontend Call Code Example) + +1. Creating and Extending the Address Lookup Table + +```typescript +/** + * Create an Address Lookup Table (ALT) for stake accounts + */ +export const createLookupTable = async ( + connection: Connection, + payer: PublicKey, + accounts: AltAccountInfo, + signTransaction: ( + tx: T, + ) => Promise, +): Promise => { + const recentSlot = await connection.getSlot(); + + // Create lookup table instruction + const [lookupTableInst, lookupTableAddress] = + AddressLookupTableProgram.createLookupTable({ + authority: payer, + payer, + recentSlot, + }); + + // Add accounts to lookup table instruction + const addAccountsInst = AddressLookupTableProgram.extendLookupTable({ + payer: payer, + authority: payer, + lookupTable: lookupTableAddress, + addresses: [ + accounts.state, + accounts.userStakeInfo, + accounts.userTokenAccount, + accounts.stakingVault, + accounts.rewardVault, + accounts.userRewardAccount, + accounts.tokenProgram, + accounts.blacklistEntry, + accounts.systemProgram, + accounts.clock, + ], + }); + + // Combine both instructions in a single transaction + const combinedTx = new Transaction() + .add(lookupTableInst) + .add(addAccountsInst); + + combinedTx.recentBlockhash = ( + await connection.getLatestBlockhash() + ).blockhash; + combinedTx.feePayer = payer; + + const signedTx = await signTransaction(combinedTx); + await sendAndConfirmTransaction(connection, signedTx.serialize()); + + // Fetch the created lookup table + const lookupTableResponse = + await connection.getAddressLookupTable(lookupTableAddress); + + return lookupTableResponse.value; +}; +``` + +2. Executing a Stake Transaction using ALT (v0 transaction) + +```typescript +try { + const accountInfo = await createStakeAccountInfo(publicKey!, program!); + + const accounts: AltAccountInfo = { + state: accountInfo.statePda, + userStakeInfo: accountInfo.userStakeInfoPda, + userTokenAccount: accountInfo.userTokenAccount, + stakingVault: accountInfo.stakingVault, + rewardVault: accountInfo.rewardVault, + userRewardAccount: accountInfo.userRewardAccount, + tokenProgram: TOKEN_PROGRAM_ID, + blacklistEntry: accountInfo.blacklistPda, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }; + + const lookupTable = await getOrCreateLookupTable(accounts); + if (!lookupTable) { + onError({ message: ERROR_MESSAGES.FAILED_TO_LOAD_LOOKUP_TABLE }); + return; + } + + const versionedTx = await createVersionedStakeTransaction( + connection, + publicKey!, + program!, + stakeAmount, + lookupTable, + ); + + const signedVersionedTx = await signTransaction!(versionedTx); + + const signature = await sendAndConfirmTransaction( + connection, + signedVersionedTx.serialize(), + ); + + setTransactionSignature(signature); + onSuccess(); + setTransactionSignature(undefined); +} catch (err) { + const errorInfo = formatErrorForDisplay(err); + onError(errorInfo); +} finally { + setIsStaking(false); +} +``` + +If your transaction only involves a few dozen accounts, a regular transaction is sufficient. However, for **batch operations** or **complex cross-protocol interactions**, ALT can significantly reduce development complexity, improve performance, and lower Gas costs. + +[This transaction](https://explorer.solana.com/tx/2pm9x29zuVerX8N9p2KKJBvKnHbDdNemjBrkiBeFXoaQAVT74c8KVzDWbXi7xbgNgzbZGnaSupDNbEEh1NryVjwX?cluster=devnet) is a transaction without lookup, and [this transaction](https://explorer.solana.com/tx/5uiw41GgowkdFHUXPVt7AEKPqkVgYy93mxrP5jTQNw9VEzbjWRJs5tHtGJWTtM793Sdn4SuS8oKHECheMfSA9bQU?cluster=devnet) is a transaction with lookup, showing a significant fee reduction from 0.000025 SOL to 0.000005 SOL, a saving of 80%. + +## 5. Solana Fee Optimisation: Compute Units and Priority Fee + +In Solana, the transaction fee consists of two parts: **base fee** and **priority fee**. The base fee is fixed and very low, while the priority fee allows users to pay an additional amount to boost the transaction's packing priority during periods of high **account contention**. + +### 5.1 Account Contention and Priority Fees + +- **The unit of transaction contention is the account**, not the transaction itself. +- **Account contention** occurs when multiple transactions simultaneously access the same account within the same block (e.g., writing to the state account of a liquidity pool). +- In this situation, validators prioritize transactions with a higher bid (more priority fee). + +If the account you are interacting with has no contention (e.g., personal wallet transfers, or a staking account only used by yourself), **no extra priority fee is needed**, and the transaction will still be landed normally. + +### 5.2 Typical Scenarios Requiring Priority Fees + +- Hot NFT mints, where thousands of users simultaneously write to the same global state account. +- AMM liquidity pools or DEX order placements, where everyone accesses the same market state account. +- High-frequency trading or arbitrage bots, competing concurrently on the same accounts. + +### 5.3 Compute Units + +The computing resources consumed by each transaction in the network are measured in **Compute Units (CU)**. By default, setting the CU too high leads to unnecessary waste, while setting it too low might cause transaction execution failure. Solana provides two mechanisms to help developers find the "appropriate CU request": + +Use `simulateTransaction` to estimate the CU required for transaction execution. You can first simulate the execution using `simulateTransaction` + the transaction containing all instructions, then read the consumed units. Add 10% on top of this value. + +```typescript +export const estimateComputeUnits = async ( + connection: Connection, + transaction: VersionedTransaction, +): Promise => { + try { + const simulation = await connection.simulateTransaction(transaction, { + sigVerify: false, + replaceRecentBlockhash: true, + }); + + if (simulation.value.err) { + console.warn("Simulation failed:", simulation.value.err); + return DEFAULT_COMPUTE_UNITS; + } + + const estimatedCU = simulation.value.unitsConsumed || 0; + + if (estimatedCU === 0) { + return DEFAULT_COMPUTE_UNITS; + } + + // Add 10% safety margin + return addSafetyMargin(estimatedCU, 0.1); + } catch (error) { + console.warn("Failed to estimate compute units:", error); + return DEFAULT_COMPUTE_UNITS; + } +}; +``` + +Note that the Compute Unit Limit is an upper bound, and the priority fee is calculated **based on this upper bound**, not the actual consumed CU. If you set a high CU Limit but use very little in practice, you might still pay an "overestimated" priority fee. + +### 5.4 Using Priority Fees to Increase Transaction Priority + +Solana provides the `getRecentPrioritizationFees` RPC method to query the priority fee situation for recently landed transactions on specified accounts. + +- You should pass the accounts involved in your transaction as parameters. +- The RPC returns the minimum priority fee for recently landed transactions. +- This value can be used as a baseline, and then appropriately increased based on the importance of your business operation. + +```typescript +/** + * Fetch recent priority fees for given accounts + */ +export const getRecentPriorityFees = async ( + connection: Connection, + publicKey: PublicKey, + accountInfo: StakeAccountInfo, +): Promise => { + try { + const response = await connection.getRecentPrioritizationFees({ + lockedWritableAccounts: [ + publicKey, + accountInfo.statePda, + accountInfo.userStakeInfoPda, + accountInfo.userTokenAccount, + accountInfo.stakingVault, + accountInfo.rewardVault, + accountInfo.userRewardAccount, + accountInfo.blacklistPda, + ], + }); + + if (!response || response.length === 0) { + return DEFAULT_PRIORITY_FEE; + } + + const allFees = response.map((item) => item.prioritizationFee); + const validFees = filterValidFees(allFees); + + if (areAllFeesZero(validFees)) { + return DEFAULT_PRIORITY_FEE; + } + + return calculateRecommendedFee(validFees); + } catch (error) { + console.warn("Failed to fetch priority fees:", error); + return DEFAULT_PRIORITY_FEE; + } +}; +``` + +### 5.5 [Optimal Compute Budget](https://solana.com/developers/guides/advanced/how-to-use-priority-fees) + +The priority fee is calculated using the formula: + +**Prioritization Fee = Compute Unit Limit x Compute Unit Price** + +When constructing a transaction, priority fee parameters can be explicitly specified via instructions: + +```typescript +const instructions = [ + createComputeUnitPriceInstruction(priorityFee), + createComputeUnitLimitInstruction(estimatedCU), + instruction, +]; + +const versionedTx = await createVersionedTransaction( + connection, + publicKey!, + instructions, +); + +const signedTx = await signTransaction!(versionedTx); + +const signature = await sendAndConfirmTransaction( + connection, + signedTx.serialize(), +); +``` + +## 6. Transaction Retry + +The mechanism for transaction broadcast and confirmation in Solana is significantly different from EVM. + +- **EVM Model**: After a transaction is submitted, as long as it enters the mempool, a miner will eventually package and execute it. Transaction nonces are strictly sequential, and there is no "packet loss" issue. +- **Solana Model**: Uses **high-concurrency + UDP broadcast**. There is no mempool, and a transaction must be packaged within a limited block height (the validity period of the blockhash), otherwise it will expire directly. + +This means that in high-load scenarios, transactions may fail to land due to packet loss, queue overflow, or fork rollback, thus requiring developers to implement **retry logic** on the client to ensure reliable transaction delivery. + +### 6.1 The Role of Blockhash + +Every Solana transaction must carry a `recentBlockhash`, which serves two purposes: + +1. **Anti-Replay**: Prevents the same transaction from being broadcast indefinitely. +2. **Setting Expiration Time**: A blockhash is valid for approximately 151 slots (about 60-75 seconds). After this time, the transaction will be rejected. + +Therefore, **the core of the retry logic** is: continuously re-broadcast the transaction before the blockhash expires. + +### 6.2 Retry Logic + +The common retry logic is as follows: + +1. Fetch the `recentBlockhash` and its corresponding `lastValidBlockHeight`. +2. Calculate a safety buffer, such as `lastValidBlockHeight - 150`, to avoid retrying too close to expiration. +3. Keep re-sending the transaction while `blockHeight < lastValidBlockHeight`, until the transaction is confirmed or expires. +4. If the transaction still fails to land, it needs to be re-signed (with a new blockhash) and broadcast again. + +```typescript +/** + * Executes the stake transaction with blockhash retry logic. + * Returns the transaction signature if successful, or undefined on error. + */ +const executeStakeWithRetry = async (): Promise => { + // Get latest blockhash and set lastValidBlockHeight + const blockhashResponse = await connection.getLatestBlockhash(); + + // Subtract a small safety buffer to ensure we stop retrying + // before the transaction actually expires, avoiding failures near expiration + const SAFETY_BUFFER = 100; // blocks + const lastValidBlockHeight = + blockhashResponse.lastValidBlockHeight - SAFETY_BUFFER; + + // Create stake instruction using the common utility + const { instruction } = await createStakeInstruction({ + publicKey: publicKey!, + program: program!, + stakeAmount, + }); + + // Create transaction with blockhash and lastValidBlockHeight + const transaction = new Transaction({ + feePayer: publicKey, + blockhash: blockhashResponse.blockhash, + lastValidBlockHeight: lastValidBlockHeight, + }).add(instruction); + + const signedTransaction = await signTransaction!(transaction); + const rawTransaction = signedTransaction.serialize(); + + // Get current block height + let blockHeight = await connection.getBlockHeight(); + let confirmedSignature: string | undefined; + + // Keep sending transaction until block height exceeds lastValidBlockHeight + while (blockHeight < lastValidBlockHeight) { + try { + const signature = await connection.sendRawTransaction(rawTransaction, { + // skipPreflight: true is REQUIRED for retry logic + // Reasons: + // 1. We intentionally send the same transaction multiple times + // 2. Preflight checks would fail on repeat sends with "Transaction already exists" + // 3. During network congestion, transactions may be dropped and need resending + // 4. We want to maximize chances of the transaction being accepted + // 5. Final confirmation is handled separately with proper validation + skipPreflight: true, + }); + const confirmation = await connection.confirmTransaction( + { + signature, + blockhash: blockhashResponse.blockhash, + lastValidBlockHeight: lastValidBlockHeight, + }, + "confirmed", + ); + if (!confirmation.value.err) { + confirmedSignature = signature; + onSuccess(); + break; + } + await sleep(500); + blockHeight = await connection.getBlockHeight(); + } catch (error) { + // Ignore send errors, continue retrying + await sleep(500); + blockHeight = await connection.getBlockHeight(); + } + } + + // Check if we had a successful confirmation + if (!confirmedSignature) { + onError({ + message: + "Transaction failed to send during the valid block height window. Please try again with a new transaction.", + title: "Transaction Failed", + }); + return; + } + + return confirmedSignature; +}; +``` + +Solana official documentation also provides relevant implementations for transaction retry; details can be found [here](https://solana.com/docs/advanced/retry). + +## 7. Error Handling + +Solana-related errors can generally be divided into three categories: + +1. **Wallet Adapter Errors**: Connection failure, user rejection of signature, unsupported capability (e.g., `signMessage`). +2. **RPC / Network Errors**: Node unreachable, blockhash expired, rate limit, etc. +3. **On-Chain Program Errors**: Including errors thrown by System Programs and custom business contract errors. + +A robust DApp needs to achieve "categorization, noise reduction, and user-friendly prompts" at all three levels. + +### 7.1 Unified Frontend Error Handling Entry + +All errors captured on the frontend or during the transaction process are handed over to a "classifier" for categorization, thereby simplifying the error handling logic in the UI layer. + +The UI layer only needs to be concerned with one of the following four error types: + +1. **Wallet Adapter Issues (`ErrorLevel.WalletAdapter`)** +2. **RPC/Network Issues (`ErrorLevel.RpcNetwork`)** +3. **On-Chain Program Business Logic Issues (`ErrorLevel.OnChainProgram`)** +4. **Unknown Error (`ErrorLevel.Unknown`)** + +The utility function `classifyError(error)` serves as this **unified entry point**, responsible for categorizing any error into one of the four `ErrorLevel` types. + +```typescript +/** + * Classifies any error into one of four error levels. + * This is the unified entry point for all error handling. + */ +export function classifyError(error: unknown): ClassifiedError { + if (!error) { + return { + level: ErrorLevel.Unknown, + error: null, + originalError: error, + }; + } + + const errorObj = error as { message?: string; msg?: string }; + const errorMessage = errorObj?.message || errorObj?.msg || String(error); + + // Level 1: Check for wallet adapter errors first + const walletError = matchWalletAdapterError(errorMessage); + if (walletError) { + return { + level: ErrorLevel.WalletAdapter, + error: walletError, + originalError: error, + }; + } + + // Level 2: Check for RPC/network errors + const rpcError = matchRpcNetworkError(errorMessage); + if (rpcError) { + return { + level: ErrorLevel.RpcNetwork, + error: rpcError, + originalError: error, + }; + } + + // Level 3: Check for on-chain program errors + const programError = parseProgramErrorInternal(error); + if (programError) { + return { + level: ErrorLevel.OnChainProgram, + error: programError, + originalError: error, + }; + } + + // Unknown error + return { + level: ErrorLevel.Unknown, + error: null, + originalError: error, + }; +} +``` + +`formatErrorForDisplay(error)`: Builds upon error classification to output a structure readily usable by the UI, containing the following fields: + +- **title** +- **message** +- **code?** (optional) +- **level?** (optional) + +```typescript +/** + * Formats an error for display in the UI. + * This function classifies the error and returns a structure + * suitable for ErrorModal. + */ +export function formatErrorForDisplay(error: unknown): FormattedError { + const classified = classifyError(error); + const userMessage = getUserFriendlyError(error); + const title = getTitleForLevel(classified.level); + + const result: FormattedError = { + title, + message: userMessage, + level: classified.level, + }; + + // Add error code if available + if (classified.error?.code) { + result.code = classified.error.code; + } + + return result; +} +``` + +## 8. Jito Bundle + +### 8.1 Limitations of Solana's Native Scheduling Model + +In Solana, most transactions are submitted via RPC nodes. The RPC forwards the transaction to the current and next Leader, where it enters the Leader's execution queue and is packaged into a block. Before the Leader actually executes it and the network votes, the transaction only exists in the memory queues of the client and the forwarding node. The core constraint: **the scheduling unit is a single transaction, and competing transactions are ordered in the public forwarding path.** + +While this model offers extremely high throughput under high parallel execution, it is not ideal for complex chained operations. If a business process involves multiple dependent transactions (e.g., borrow -> swap -> repay), these transactions are propagated, ordered, and executed independently. The system cannot guarantee their atomic submission as a whole or ensure strict execution in the intended order. Consequently, the native model lacks the ability for "multi-transaction atomic submission" and "deterministic sequential scheduling." + +### 8.2 Jito Bundle: Extended Scheduling + +The Jito Bundle wraps a group of transactions into an ordered sequence: `Bundle = [Tx1, Tx2, ..., TxN]`. The execution semantic: all transactions run in order, and state commits only if every transaction succeeds. If any fail, the whole bundle rolls back. By making the scheduling unit a "transaction sequence" instead of a single transaction, Bundles provide atomicity and sequential guarantees for complex multi-step operations — without changing the underlying execution engine. + +Under the hood, Bundles travel through Jito Labs' Block Engine — a private scheduling channel separate from public RPC. The Block Engine receives the transaction sequence and routes it directly to validators running the Jito-Solana client. In the standard path, transactions enter the candidate queue individually and compete for ordering. A Bundle skips this — it's treated as one indivisible unit with its internal order locked. + +A Jito Bundle isn't a new transaction type — it's a scheduling layer on top of the existing execution model. It upgrades the scheduling unit from "single transaction" to "transaction sequence," giving you atomic execution and deterministic ordering for multi-transaction workflows. + +### 8.3 Use Cases + +Bundles are useful in two ways: the atomicity semantics, and the engineering support for complex chained strategies. Typical cases: + +- **MEV Arbitrage**: Packaging a user's transaction with an arbitrage transaction into an atomic sequence for execution, ensuring that both the buy and sell operations are completed under the same price state, preventing the intermediate state from being interrupted or price slippage from causing the strategy to fail. +- **Liquidations**: Packaging an oracle price update transaction with a liquidation transaction for submission, ensuring the liquidation logic is executed atomically based on the latest price, avoiding execution failure or contention conflicts caused by separating price updates and liquidation. +- **Batching Complex Operations**: When a single transaction is limited by transaction size or Compute Budget, multiple steps can be split into multiple transactions and encapsulated within the same Bundle to complete the complex operation with sequential execution and atomic submission. + +The common feature of these scenarios is their dependence on "sequential determinism and atomic submission semantics among multiple transactions." Under the standard model of public propagation and independent ordering, these strategies are susceptible to scheduling competition and state changes. By encapsulating the transaction sequence into a Bundle and submitting it through Jito Labs' private scheduling channel, the execution order can be locked, and overall consistency can be guaranteed before entering the block construction phase. + +### 8.4 Practical Implementation + +Based on the above capabilities, the following section will focus on the typical scenario of **MEV Protection**, demonstrating how to submit a Staking transaction through the Jito Bundle's private channel to enhance result determinism and strategy stability in a price-sensitive execution environment. + +#### 8.4.1 Transaction Submission Methods + +The Jito Block Engine provides two transaction submission interfaces: + +- **`sendBundle`** is used to submit a Bundle containing multiple related transactions, suitable for complex scenarios requiring atomic execution of multiple operations (e.g., flash loan arbitrage, multi-step DeFi operations). +- **`sendTransaction`** is a simplified interface for single transactions, when paired with the `bundleOnly=true` parameter, also gains MEV protection and rollback protection capabilities. + +For the Stake operation in this project, since it only involves a single transaction, we choose `sendTransaction` over `sendBundle` to simplify the implementation while maintaining the same level of protection: + +```typescript +POST https://.block-engine.jito.wtf/api/v1/transactions?bundleOnly=true +``` + +The `bundleOnly=true` parameter treats the transaction as a single-transaction Bundle — if execution fails, all state changes roll back. No partial execution. + +```typescript +export const sendJitoTransaction = async ( + signedTransaction: Transaction, +): Promise => { + try { + // Serialize and encode the transaction for the API body + const serialized = signedTransaction.serialize(); + const base58Tx = bs58.encode(serialized); + + const endpointUrl = `${JITO_BLOCK_ENGINE_URL}/api/v1/transactions?bundleOnly=true`; + + const requestBody = { + jsonrpc: "2.0", + id: 1, + method: "sendTransaction", + params: [base58Tx], + }; + + // Send the transaction via a POST request to Jito's API + const response = await fetch(endpointUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(requestBody), + }); + + // Check for HTTP errors + if (!response.ok) { + throw new Error( + `Jito API request failed: ${response.status} ${response.statusText}`, + ); + } + + const result = await response.json(); + + // Check for JSON-RPC errors returned in the response body + if (result.error) { + throw new Error( + `Jito sendTransaction error: ${result.error.message || JSON.stringify(result.error)}`, + ); + } + + // Success: Return the transaction signature + const signature = result.result; + return signature; + } catch (error) { + // Log and re-throw a standardized error + console.error("Failed to send transaction via Jito:", error); + throw new Error( + `Jito transaction failed: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } +}; +``` + +#### 8.4.2 Fee Strategy + +`sendBundle` and `sendTransaction` **have fundamental differences in their fee strategies:** + +- **`sendBundle`**: Only requires a Jito Tip. Bundles are sent directly to the Jito Block Engine and prioritized by Jito validators. Priority Fee has no effect on Bundle processing priority — validators decide whether to accept and process a Bundle based on the Tip amount. +- **`sendTransaction`**: A **70/30 allocation principle** is recommended, allocating 70% of the total fee as the **Priority Fee** and 30% as the **Jito Tip**. The Priority Fee is set via `ComputeBudgetProgram.setComputeUnitPrice()`, increasing the transaction's priority in the validator's queue. The Jito Tip incentivizes Jito validators to prioritize the transaction. + +| **Submission Method** | **Priority Fee** | **Jito Tip** | **Core Explanation** | +|:--------------------- |:---------------- |:------------ |:----------------------------------------------- | +| `sendBundle` | Not Required | Required | Tip determines the Bundle's processing priority | +| `sendTransaction` | 70% | 30% | Both work together to increase success rate | + +**Important Notes on the Tip:** + +1. **Do not use Address Lookup Table (ALT) for the Tip account**. The Tip instruction must directly include the full account address; using ALT compression will prevent the Tip from being correctly recognized. +2. **The Tip is only effective for Jito-Solana Leaders**. If the current slot Leader is not running the Jito-Solana client, the Tip will have no preferential processing effect, essentially wasting funds. Fortunately, Jito validators control about 95% of the network stake, so your transaction will encounter a Jito Leader in most cases. + +The fee amount determination relies on the `tip_floor` API: + +```typescript +GET https://bundles.jito.wtf/api/v1/bundles/tip_floor +``` + +This interface returns the statistical distribution of Tips for recently successfully landed transactions. For `sendTransaction`, we use the `landed_tips_50th_percentile` (median) as a benchmark and calculate the 70/30 ratio: + +```typescript +export const calculateJitoFees = async () => { + const tipFloor = await fetchTipFloor(); + const baseFee = Math.ceil( + tipFloor.landed_tips_50th_percentile * LAMPORTS_PER_SOL, + ); + const totalFee = Math.max(JITO_MIN_TIP_LAMPORTS, baseFee); + return { + priorityFee: Math.ceil(totalFee * 0.7), + jitoTip: Math.max(JITO_MIN_TIP_LAMPORTS, Math.ceil(totalFee * 0.3)), + }; +}; +``` + +#### 8.4.3 Tip Accounts and Load Balancing Strategy + +The Jito Tip must be transferred to a specific Tip account. The API interface for fetching these accounts is: + +```typescript +POST https://.block-engine.jito.wtf/api/v1/getTipAccounts +``` + +Jito maintains 8 Tip accounts for receiving tips. To achieve load balancing, one account should be randomly selected when constructing the transaction: + +```typescript +export const getRandomTipAccount = async (): Promise => { + const tipAccounts = await fetchTipAccounts(); + const randomIndex = Math.floor(Math.random() * tipAccounts.length); + return new PublicKey(tipAccounts[randomIndex]); +}; +``` + +Alternative Solution: If the above API is unavailable, the static accounts listed [here](https://docs.jito.wtf/lowlatencytxnsend/#response-example-tips) can be used as a substitute. + +#### 8.4.4 Network Environment and CORS Handling + +The Jito Block Engine's test environment only supports Solana Testnet, not Devnet. Ensure that your contract is deployed to Testnet and that your wallet and RPC are connected to Testnet during development and debugging. + +Additionally, the `tip_floor` API has CORS restrictions, meaning browsers cannot access it directly. The development environment can use a Vite proxy to resolve this, while the production environment requires configuring an Nginx reverse proxy or forwarding the request through a backend service. + +```typescript +// vite.config.ts + +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + proxy: { + // Proxy requests starting with "/api/v1/bundles/tip_floor" + // to the Jito Bundles API + "/api/v1/bundles/tip_floor": { + target: "https://bundles.jito.wtf", + changeOrigin: true, // Needed for cross-origin requests + }, + }, + }, +}); +``` + +## 9. Conclusion + +If you're coming from EVM, Solana mechanisms like ALT, priority fees, blockhash expiration, and account contention will feel unfamiliar. ALT isn't a gas optimization — it's a constraint you work within when your transaction touches many accounts. Priority fees aren't validator tips; they're bids on contentious accounts. Blockhash expiration gives your transaction a shelf life of about 60 seconds — your retry logic needs to account for that. + +Once these patterns click, you can build interactions that play to Solana's strengths: complex operations in a single transaction, predictable costs, stability under load. + +Part 2 walks through a complete Staking demo, connecting all the frontend patterns covered here into a working end-to-end example. + +We recommend tackling one piece at a time — ALT, priority fee estimation, or the error handler — and getting it solid before adding the rest. Full sample code: [evm-to-solana](https://github.com/57blocks/evm-to-solana). + +## References + +- [EVM-To-Solana](https://github.com/57blocks/evm-to-solana) +- [Solana extensions to the Wallet Standard](https://github.com/anza-xyz/wallet-standard) +- [Subgraphs](https://thegraph.com/docs/en/subgraphs/quick-start/) +- [Retrying Transactions](https://solana.com/developers/guides/advanced/retry) +- [Jito](https://docs.jito.wtf/) +- [Solana JavaScript SDK](https://github.com/anza-xyz/kit) +- [Solana Document](https://solana.com/docs) diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/diagram.png b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/diagram.png new file mode 100644 index 0000000..d45a38b Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/diagram.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/thumb.png b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/thumb.png new file mode 100644 index 0000000..3cf3a8d Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/thumb.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/thumb_h.png b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/thumb_h.png new file mode 100644 index 0000000..3b7880b Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 1)/thumb_h.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/README.md b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/README.md new file mode 100644 index 0000000..1f946d2 --- /dev/null +++ b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/README.md @@ -0,0 +1,544 @@ +--- +published: true +title: "How to Migrate an Ethereum Protocol to Solana — Frontend (Part 2)" +author: ["Bonnie Chen/ Full Stack Engineer", "Shan Yang/Tech Lead"] +createTime: 2026-06-05 +categories: ["engineering"] +subCategories: ["Blockchain & Web3"] +tags: ["Solana", "Ethereum", "Frontend", "Staking", "Wallet", "Anchor"] +landingPages: ["Blockchain-Onchain infra"] +thumb: "./thumb.png" +thumb_h: "./thumb_h.png" +intro: "A complete staking demo implementation comparing EVM and Solana frontend patterns side by side — wallet connection, reading state, writing transactions, and event handling." +--- + +## Article Overview + +Solana is maturing, and more EVM teams are looking at migration. Higher throughput, cheaper transactions, better UX — the pitch is real. We've led a handful of these migrations end to end, and the patterns that matter most aren't obvious from reading the docs. This series distills what we learned across contracts, backends, and frontends. + +If you're new to the series, start with the [preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering) — it covers the architectural differences between Ethereum and Solana that everything else depends on. + +This article is about the frontend. Not the theory — the actual code. Using a complete staking demo, we'll compare EVM and Solana side by side across wallet connection, reading state, writing transactions, and handling events. Each section shows both approaches so you can see where the patterns split and why. + +## Article Navigation + +- [How to Migrate an Ethereum Protocol to Solana — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble): account models, execution, and fees on both chains. +- [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): account model, CPI, PDAs, and Anchor patterns for EVM developers. +- [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): Solana limits (CU, forks, hooks, events) and a staking migration walkthrough. +- [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): wallets, transaction building, errors, and Jito bundles. +- [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): transaction building, account fetching, and event/log handling on the client. +- [How to Migrate an Ethereum Protocol to Solana — Backend](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-backend): event sync, log parsing, and state management for a production backend. + +Using a complete staking demo, we'll connect the core frontend interaction scenarios side by side — EVM on one side, Solana on the other — so you can apply the concepts from earlier articles to real code. + +The demo is a simplified staking protocol: users stake tokens, earn rewards, unstake or claim anytime with no lockup. Through it, we'll work through: + +1. **Architectural division:** Where business logic stops and frontend code begins. +2. **Wallet connection:** MetaMask + Wagmi (EVM) vs Wallet Adapter (Solana). +3. **Reading state:** ABI + contract addresses vs PDA + Anchor. +4. **Writing data:** `approve` + `stake` (two transactions) vs direct `stake` (one). +5. **Event handling:** Persistent EVM logs vs ephemeral Solana program logs. +6. **Putting it together:** How these connect in a real demo. + +[View the code on GitHub](https://github.com/57blocks/evm-to-solana). + +## 1. Business Logic and Frontend Responsibilities + +What the protocol does: + +- **Stake:** Users stake `MyToken` tokens they hold. +- **Claim rewards:** The protocol issues `RewardToken` based on stake amount and duration. +- **State and security:** Track each user's staking position and enforce access controls. + +What the frontend handles: + +- `stake`: Initiate a staking transaction for `MyToken`. +- `unstake`: Initiate a withdrawal transaction. +- `getStakeInfo`: Query and display the user's current position. + +This demo is about *how* the frontend talks to EVM and Solana protocols. Reward formula design is out of scope — that's a backend concern. + +## 2. Connecting the Wallet + +### 2.1 EVM: MetaMask + Wagmi + RainbowKit + +In EVM land, MetaMask is effectively the standard. Frontends usually reach for RainbowKit + Wagmi + Viem. + +The steps: + +1. Configure the chain and RPC (we're on Sepolia testnet). +2. Create a wagmi config with SSR enabled. +3. Wrap the app with `RainbowKitProvider`. +4. Use `ConnectButton` to render the connection UI. + +```ts +export const config = getDefaultConfig({ + appName: "Stake App", + projectId: "EVM-DAPP", + chains: [sepolia], + transports: { + [sepolia.id]: http(process.env.NEXT_PUBLIC_RPC_URL || ""), + }, + ssr: true, +}); + +const client = new QueryClient(); + + + + + + + +``` + +On the page: + +```html + +``` + +The wallet ecosystem is centralized enough that if you integrate MetaMask, you've covered the vast majority of users. One main wallet, maybe a couple others. + +### 2.2 Solana: Multi-Wallet Ecosystem + Wallet Adapter + +Solana doesn't have a MetaMask. The wallet landscape is fragmented — Phantom, Solflare, Backpack, and others. Anza's [Wallet Adapter](https://github.com/anza-xyz/wallet-adapter) exists to paper over this. + +Step 1: Create the `WalletProvider`: + +```ts +interface WalletProviderProps { + children: ReactNode; +} + +export const WalletProvider: FC = ({ children }) => { + const { network, endpoint } = SOLANA_CONFIG; + const wallets = useMemo( + () => [new PhantomWalletAdapter(), new SolflareWalletAdapter()], + [network] + ); + + return ( + + + {children} + + + ); +}; +``` + +Step 2: [Use the official UI button](https://github.com/anza-xyz/wallet-adapter/blob/master/APP.md) + +```ts + + + Connect Wallet + + +``` + +On EVM, "wallet" basically means MetaMask. On Solana, you think about multiple wallets from day one. Wallet Adapter isn't a nice-to-have — it's the abstraction that keeps things from turning into a mess. + +## 3. Reading State Data (Read-Only Operations) + +Reading state is every DApp's bread and butter. No gas, no signatures, anyone can do it. + +### 3.1 EVM: useReadContract + ABI + +Wagmi's `useReadContract` makes this straightforward: + +```ts +const { + data: stakeInfoData, + isLoading: isReading, + error: readError, + refetch, +} = useReadContract({ + address: STAKING_CONTRACT_ADDRESS, + abi: stakingAbi, + functionName: "getStakeInfo", + args: [address as Address], + query: { + enabled: !!address && isConnected, + }, +}); +``` + +The three pieces you need: contract address, the ABI, and the function name + args. Under the hood it's wrapping `eth_call`. + +### 3.2 Solana: Account Model + PDA + Anchor + +On Solana, state lives in accounts. A user's staking info is stored in a [PDA (Program Derived Address)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering#pda-program-derived-address) — an account whose address is derived from the program ID and some seeds. + +Two approaches to reading Solana state: + +1. **Read raw accounts and parse manually:** Use [`@solana/web3.js`](https://github.com/solana-foundation/solana-web3.js) to fetch account data, then deserialize the buffer yourself. Works, but tedious. +2. **Use Anchor and IDL (recommended):** Combine [`@coral-xyz/anchor`](https://github.com/coral-xyz/anchor-book) with an [IDL](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering#idl-interface-description-language) and let Anchor handle the parsing. + +Creating the `useProgram` hook: + +```ts +const [program, setProgram] = useState>(); + +const { connection } = useConnection(); +const wallet = useAnchorWallet(); + +useEffect(() => { + if (wallet) { + const provider = new AnchorProvider(connection, wallet, {}); + setProvider(provider); + const program = new Program(idl, provider); + setProgram(program); + } +}, [connection, wallet]); + +return { program, setProgram }; +``` + +Reading user staking info: + +```ts +const userStakeInfo = await program.account.userStakeInfo.fetch(userStakeInfoPda); +``` + +EVM: ABI + contract address → call function, get return value. Solana: IDL + PDA → read account struct directly. Anchor wraps Solana's account model into something that feels closer to ABI-based development, which helps if you're coming from EVM. + +## 4. Writing Data (Transaction Operations) + +Writing data changes on-chain state. That means user signatures and transaction fees. We'll use staking as the running example. + +### 4.1 EVM: approve → stake (Two Steps) + +The typical flow: + +1. Check the allowance — how much the token contract lets the staking contract spend: + +```ts +const { + data: currentAllowance, + isLoading: isAllowanceLoading, + refetch: refetchAllowance, +} = useReadContract({ + address: STAKING_TOKEN_ADDRESS, + abi: stakingTokenAbi, + functionName: "allowance", + args: [address, STAKING_CONTRACT_ADDRESS], + query: { + enabled: !!address && isConnected, + }, +}); +``` + +2. If the allowance is too low, approve first: + +```ts +const { + writeContract, + data: writeData, + error: writeError, +} = useWriteContract(); + +writeContract({ + address: STAKING_TOKEN_ADDRESS, + abi: stakingTokenAbi, + functionName: "approve", + args: [STAKING_CONTRACT_ADDRESS, stakeAmountWei], +}); +``` + +3. Then stake: + +```ts +await writeContract({ + address: STAKING_CONTRACT_ADDRESS, + abi: stakingAbi, + functionName: "stake", + args: [stakeAmountWei], +}); +``` + +Two transactions, and the user has to sign both. It's the ERC20 design — the token contract needs explicit permission before another contract can move tokens from your wallet. + +### 4.2 Solana: Signature Is Authorization + +Solana doesn't do the approve dance. Here's why: + +1. Each SPL token balance lives in a Token Account. Every token account has an owner, and only the owner can transfer from it. +2. [ATA (Associated Token Account)](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble?tab=engineering#ata-associated-token-account) is unique and derivable — `getAssociatedTokenAddressSync(mint, owner)` gives you the same address every time. +3. Tokens in a token account come from a Mint (which defines total supply and decimals). When you stake, the staking program doesn't ask the Mint for permission. It transfers tokens from your ATA to the `stakingVault`, signed by you. + +The user's signature *is* the authorization. One transaction instead of two. + +#### 4.2.1 Staking Program `stake` IDL (Excerpt) + +```json +{ + "args": [ + { + "name": "amount", + "type": "u64" + } + ], + "accounts": [ + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "state", + "writable": true, + "pda": { + "seeds": ["state", "staking_mint"] + } + }, + { + "name": "user_stake_info", + "writable": true, + "pda": { + "seeds": ["stake", "state", "user"] + } + }, + { + "name": "user_token_account", + "writable": true + }, + { + "name": "staking_vault", + "writable": true, + "pda": { + "seeds": ["staking_vault", "state"] + } + }, + { + "name": "reward_vault", + "writable": true, + "pda": { + "seeds": ["reward_vault", "state"] + } + }, + { + "name": "user_reward_account", + "writable": true + }, + { + "name": "blacklist_entry", + "pda": { + "seeds": ["blacklist", "state", "user"] + } + }, + { + "name": "token_program", + "address": "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + }, + { + "name": "clock", + "address": "SysvarC1ock11111111111111111111111111111111" + } + ] +} +``` + +`amount: u64` — how many MyToken tokens the user wants to stake. + +The accounts: + +| Account | Type | What it is | PDA Seeds | +| -------------------------------------- | ------ | ---------------------------- | ------------------------------------------------------------ | +| user | Signer | User's signing account | — | +| state | PDA | Global staking state | `["state", staking_mint]` | +| user_stake_info | PDA | User's staking info | `["stake", statePda, user_publicKey]` | +| user_token_account | ATA | User's staking token account | `getAssociatedTokenAddressSync(stakingMint, user_publicKey)` | +| staking_vault | PDA | Escrow for staked tokens | `["staking_vault", statePda]` | +| reward_vault | PDA | Escrow for reward tokens | `["reward_vault", statePda]` | +| user_reward_account | ATA | User's reward token account | `getAssociatedTokenAddressSync(rewardMint, user_publicKey)` | +| blacklist_entry | PDA | Blacklist account | `["blacklist", statePda, user_publicKey]` | +| token_program / system_program / clock | System | Required by Anchor | — | + +PDAs that depend on the user's public key (`user_stake_info`, `blacklist_entry`) include `user_publicKey` in their seeds. This makes them per-user by construction. PDAs that aren't user-specific (`staking_vault`, `reward_vault`) use constant seeds + `statePda` — globally unique, one per deployment. ATA addresses come from `getAssociatedTokenAddressSync`, which is a different mechanism from PDA but also unique and derivable. + +#### 4.2.2 PDA Generation + +User-specific PDAs: + +```ts +import { PublicKey } from "@solana/web3.js"; + +// Global state PDA +const [statePda] = PublicKey.findProgramAddressSync( + [Buffer.from("state"), stakingMint.toBuffer()], + program.programId +); + +// User staking info PDA +const [userStakeInfoPda] = PublicKey.findProgramAddressSync( + [Buffer.from("stake"), statePda.toBuffer(), user.publicKey.toBuffer()], + program.programId +); + +// Blacklist PDA +const [blacklistEntryPda] = PublicKey.findProgramAddressSync( + [Buffer.from("blacklist"), statePda.toBuffer(), user.publicKey.toBuffer()], + program.programId +); +``` + +Non-user-specific PDAs (`staking_vault`, `reward_vault`) are tied to `statePda` and derived with fixed seeds: + +```ts +const [stakingVault] = PublicKey.findProgramAddressSync( + [Buffer.from("staking_vault"), statePda.toBuffer()], + program.programId +); + +const [rewardVault] = PublicKey.findProgramAddressSync( + [Buffer.from("reward_vault"), statePda.toBuffer()], + program.programId +); +``` + +#### 4.2.3 Token Account (ATA) + +Get the ATA with `getAssociatedTokenAddressSync`: + +```ts +import { getAssociatedTokenAddressSync } from "@solana/spl-token"; + +const userTokenAccount = getAssociatedTokenAddressSync(stakingMint, user.publicKey); +const userRewardAccount = getAssociatedTokenAddressSync(rewardMint, user.publicKey); +``` + +`stakingMint` is the mint address for `MyToken`; `rewardMint` is for `RewardToken`. Once the ATA exists, the user's tokens live there and Anchor can reference it directly in transactions. On Solana, each SPL token gets its own account for the balance — it's not bundled into the user's main wallet account the way ERC20 balances are stored in a mapping on the token contract. + +#### 4.2.4 Executing Stake / Unstake + +Stake: + +```ts +const transaction = await program.methods + .stake(new BN(convertToLamports(stakeAmount))) + .accounts({ + user: publicKey, + state: statePda, + userStakeInfo: userStakeInfoPda, + userTokenAccount: userTokenAccount, + stakingVault: state.stakingVault, + rewardVault: state.rewardVault, + userRewardAccount: userRewardAccount, + tokenProgram: TOKEN_PROGRAM_ID, + blacklistEntry: blacklistPda, + systemProgram: anchor.web3.SystemProgram.programId, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }) + .rpc(); +``` + +Unstake: + +```ts +const { + statePda, + userStakeInfoPda, + blacklistPda, + userTokenAccount, + userRewardAccount, +} = await createStakingAccount(publicKey); + +const state = await program.account.globalState.fetch(statePda); + +const transaction = await program.methods + .unstake(new BN(convertToLamports(unstakeAmount))) + .accounts({ + user: publicKey, + state: statePda, + userStakeInfo: userStakeInfoPda, + userTokenAccount: userTokenAccount, + stakingVault: state.stakingVault, + rewardVault: state.rewardVault, + userRewardAccount: userRewardAccount, + tokenProgram: TOKEN_PROGRAM_ID, + blacklistEntry: blacklistPda, + clock: anchor.web3.SYSVAR_CLOCK_PUBKEY, + }) + .rpc(); +``` + +EVM makes you do the approve → stake two-step, tracking allowances along the way. Solana bundles everything into a single transaction: you specify every account at signing time, the signature carries the authorization, and it's done. + +## 5. Parsing Events + +Listening to contract events is how the frontend reacts to on-chain changes — updating the UI when a stake lands, showing a confirmation, that kind of thing. + +### 5.1 EVM + +In EVM, events are `event` definitions in Solidity. When a transaction fires, it emits logs. Frontends can subscribe to live events or query historical ones from the same data source. + +Real-time listening with wagmi's `useWatchContractEvent`: + +```ts +useWatchContractEvent({ + address: STAKING_CONTRACT_ADDRESS, + abi: stakingAbi, + eventName: "Staked", + // Only events for the current user + args: userAddress ? { user: userAddress } : undefined, + onLogs(logs) {}, +}); +``` + +Historical events with viem's [`getLogs`](https://viem.sh/docs/actions/public/getLogs#getlogs) (example only — not used in this demo): + +```ts +const logs = await client.getLogs({ + address: STAKING_CONTRACT_ADDRESS, + event: { + type: "event", + name: "Staked", + inputs: [ + { name: "user", type: "address", indexed: true }, + { name: "amount", type: "uint256", indexed: false }, + ], + }, + fromBlock: actualFromBlock, + toBlock: actualToBlock, +}); +``` + +EVM events are persistent (on chain forever), indexed parameters make filtering fast, and the same mechanism works for both live subscriptions and historical queries. It's a clean design. + +### 5.2 Solana + +Solana programs don't store events on chain. Events are output as program logs during execution, then they're gone. You can listen in real time, but there's no built-in way to query what happened yesterday. + +Real-time listening with Anchor: + +```ts +program.addEventListener("staked", (event, slot, signature) => { + console.log("Staked event received:", { event, slot, signature }); +}); +``` + +This works fine for live UI updates. But if you need historical events — "show me all stakes from last month" — you'll need a third-party indexer. Helius, Triton, and others provide this. It's an extra dependency but the tradeoff is that on-chain storage stays lean. + +## 6. Conclusion + +This staking demo turns the theory from the first two articles into working frontend code. After building both sides, here's what actually differs: + +Wallet integration is the most visible split. EVM: one wallet (MetaMask) plus standard components, done. Solana: multiple wallets, and skipping Wallet Adapter means writing per-wallet code paths that spiral fast. + +Reading state is ABI + contract address on EVM, IDL + PDA on Solana. Anchor gives you something close to the ABI experience, which helps bridge the gap. + +Writing transactions exposes a real design philosophy difference. EVM's approve-then-stake means two transactions and ongoing allowance tracking. Solana folds authorization into the signature itself — specify every account, sign once, done. + +Events diverge hard. EVM gives you persistent logs that work for live and historical queries from the same data source. Solana's program logs are real-time only. Want history? You need a third-party indexer like Helius or Triton. It's extra infrastructure but the tradeoff is a leaner chain. + +Between the preamble, the contracts article, frontend Part 1, and this demo, you've got the full path: concepts → contract migration → frontend adaptation → working code. Layer in fee optimization, retry logic, priority fees, and error handling, and you have a production-ready Solana frontend stack. Not a toy — the real thing. diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/thumb.png b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/thumb.png new file mode 100644 index 0000000..bcbd367 Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/thumb.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/thumb_h.png b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/thumb_h.png new file mode 100644 index 0000000..f06d4f5 Binary files /dev/null and b/articles/How to Migrate an Ethereum Protocol to Solana Frontend (Part 2)/thumb_h.png differ diff --git a/articles/How to Migrate an Ethereum Protocol to Solana-Preamble/README.md b/articles/How to Migrate an Ethereum Protocol to Solana-Preamble/README.md index 3b625e5..4536637 100644 --- a/articles/How to Migrate an Ethereum Protocol to Solana-Preamble/README.md +++ b/articles/How to Migrate an Ethereum Protocol to Solana-Preamble/README.md @@ -33,8 +33,12 @@ Through this series, we hope to help developers not only complete the migration, ### Article Navigation -- [**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 — Preamble](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-preamble): account models, execution, and fees on both chains. +- [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): account model, CPI, PDAs, and Anchor patterns for EVM developers. +- [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): Solana limits (CU, forks, hooks, events) and a staking migration walkthrough. +- [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): wallets, transaction building, errors, and Jito bundles. +- [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): transaction building, account fetching, and event/log handling on the client. +- [How to Migrate an Ethereum Protocol to Solana — Backend](https://57blocks.com/blog/how-to-migrate-an-ethereum-protocol-to-solana-backend): event sync, log parsing, and state management for a production backend. This article approaches the topic from a **system design** perspective, summarizing the core differences between EVM and Solana across four dimensions: account model, execution model, transaction structure, and fee model. Understanding these differences provides a foundational shift in how developers reason about smart contract platforms at the architectural level.