ETH TO SOL
$npx skills add solana-foundation/eth-to-sol-skill

Token-for-token escrow

Two parties, one trade, no trust. Per-offer PDAs replace a global offers mapping; rent refunds on take.

Solidity LOC
108
Anchor LOC
373
Cost / tx
$2-50 → $0.00025
Framework
Anchor 1.0.2
Source · SolidityTokenSwapEscrow.solETHEREUM
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";

/// @title TokenSwapEscrow
/// @notice Two-party atomic ERC-20 swap. The maker locks `amountOffered` of
///         `tokenOffered`; any taker can fulfill by transferring `amountWanted`
///         of `tokenWanted` to the maker and receiving the locked tokens.
///         Neither party can be cheated — both legs settle in one transaction.
/// @dev Mirrors the canonical Solana program-examples escrow shape so the
///      translation walkthrough has a 1:1 reference on the Solana side. There
///      is no single OpenZeppelin contract for ERC-20-for-ERC-20 atomic swaps
///      — this is the standard pattern, using OZ's SafeERC20 wrapper for the
///      transfer-return-value handling.
contract TokenSwapEscrow {
    using SafeERC20 for IERC20;

    struct Offer {
        address maker;
        IERC20 tokenOffered;
        uint256 amountOffered;
        IERC20 tokenWanted;
        uint256 amountWanted;
    }

    /// @notice Auto-incrementing offer id.
    uint256 public nextOfferId;

    /// @notice id => offer. Cleared (zeroed) when taken or cancelled.
    mapping(uint256 => Offer) public offers;

    event OfferMade(
        uint256 indexed id,
        address indexed maker,
        IERC20 tokenOffered,
        uint256 amountOffered,
        IERC20 tokenWanted,
        uint256 amountWanted
    );
    event OfferTaken(uint256 indexed id, address indexed taker);
    event OfferCancelled(uint256 indexed id);

    error ZeroAmount();
    error SameToken();
    error OfferDoesNotExist();
    error NotMaker();

    /// @notice Create an offer. Pulls `amountOffered` of `tokenOffered` from
    ///         the maker; held by this contract until taken or cancelled.
    function makeOffer(
        IERC20 tokenOffered,
        uint256 amountOffered,
        IERC20 tokenWanted,
        uint256 amountWanted
    ) external returns (uint256 id) {
        if (amountOffered == 0 || amountWanted == 0) revert ZeroAmount();
        if (address(tokenOffered) == address(tokenWanted)) revert SameToken();

        id = nextOfferId++;
        offers[id] = Offer({
            maker: msg.sender,
            tokenOffered: tokenOffered,
            amountOffered: amountOffered,
            tokenWanted: tokenWanted,
            amountWanted: amountWanted
        });

        tokenOffered.safeTransferFrom(msg.sender, address(this), amountOffered);

        emit OfferMade(
            id,
            msg.sender,
            tokenOffered,
            amountOffered,
            tokenWanted,
            amountWanted
        );
    }

    /// @notice Fulfil an offer. Pulls `amountWanted` from the taker (sent to
    ///         the maker), then releases `amountOffered` to the taker. Atomic.
    function takeOffer(uint256 id) external {
        Offer memory o = offers[id];
        if (o.maker == address(0)) revert OfferDoesNotExist();
        delete offers[id];

        // Pull the wanted token from the taker, send directly to maker.
        o.tokenWanted.safeTransferFrom(msg.sender, o.maker, o.amountWanted);
        // Release the offered (escrowed) token to the taker.
        o.tokenOffered.safeTransfer(msg.sender, o.amountOffered);

        emit OfferTaken(id, msg.sender);
    }

    /// @notice Maker withdraws their offered tokens before any taker arrives.
    function cancelOffer(uint256 id) external {
        Offer memory o = offers[id];
        if (o.maker == address(0)) revert OfferDoesNotExist();
        if (o.maker != msg.sender) revert NotMaker();
        delete offers[id];

        o.tokenOffered.safeTransfer(o.maker, o.amountOffered);

        emit OfferCancelled(id);
    }
}

translate ↓
Output · Anchor / Rustprograms/escrow/src/lib.rsSOLANA
// Pass 2: Solana-native refactor of TokenSwapEscrow.
//
// Structural moves:
// - There is no global state account. Each offer is a self-contained `Offer`
//   PDA seeded by `[b"offer", maker.as_ref(), &id.to_le_bytes()]`. Different
`mapping(uint256 => Offer)` offer book → per-offer PDA

This program has no central offer book — each offer owns its own Offer account derived from [b"offer", maker, id]. Solidity would keep the offers at mapping(uint256 => Offer) slots inside a single contract; on Solana the runtime locks every writable account a transaction touches, so a shared offer book would make two takers fulfilling unrelated offers serialize on the same state account. Per-offer PDAs keep the writable sets disjoint and unlock parallel execution.

//   offers write disjoint accounts; cross-maker activity is fully parallel.
// - Each offer also owns its own vault TokenAccount, derived as
//   `[b"vault", offer.key().as_ref()]`. No sharing of vaults between offers
//   even when they involve the same mint — eliminates the "whose tokens are
//   these?" ambiguity the naive port lives with.
// - Vault token-account authority is a per-offer PDA. A bug or compromise
//   in one offer's logic cannot move funds from another offer.
// - The offer ID is supplied by the maker as an instruction argument
//   (`u64`) — typically `Date.now() / 1000` or a random nonce client-side —
//   so the program never needs a global counter. Drops the last shared
//   write-hot field from the naive design.
// - `init` + `close` lifecycle: the Offer and its vault are created at
//   `make_offer` and closed at `take_offer` / `cancel_offer`. Rent flows
//   back to the maker on close.
// - All arithmetic uses checked operations. Canonical PDA bumps are cached
//   on the Offer account and supplied on every subsequent access.

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

declare_id!("EscrowNative11111111111111111111111111111111");

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

    /// Maker locks `amount_offered` of `token_offered_mint`, asking for
    /// `amount_wanted` of `token_wanted_mint` in return. The `id` is chosen
    /// by the maker (any unique u64) so the program has no global counter.
    pub fn make_offer(
        ctx: Context<MakeOffer>,
        id: u64,
Contract-side `++nextOfferId` → client-supplied id

The maker passes their own id into make_offer; nothing in the program increments a shared counter. A global counter would force every concurrent maker to serialize on whichever account stored it — Solidity has no parallel execution to lose anyway, so contract-side auto-increment is free there, but on Solana a write-hot counter is the easiest way to kill throughput. Re-init protection is preserved by Anchor's init constraint: reusing an id reverts at account validation because the PDA already exists.

        amount_offered: u64,
        amount_wanted: u64,
    ) -> Result<()> {
        require!(amount_offered > 0 && amount_wanted > 0, EscrowError::ZeroAmount);
        require_keys_neq!(
            ctx.accounts.token_offered_mint.key(),
            ctx.accounts.token_wanted_mint.key(),
            EscrowError::SameToken
        );

        let offer = &mut ctx.accounts.offer;
        offer.id = id;
        offer.maker = ctx.accounts.maker.key();
        offer.token_offered = ctx.accounts.token_offered_mint.key();
        offer.amount_offered = amount_offered;
        offer.token_wanted = ctx.accounts.token_wanted_mint.key();
        offer.amount_wanted = amount_wanted;
        offer.bump = ctx.bumps.offer;
        offer.vault_authority_bump = ctx.bumps.vault_authority;

        token::transfer(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.maker_token_account_offered.to_account_info(),
                    to: ctx.accounts.vault.to_account_info(),
                    authority: ctx.accounts.maker.to_account_info(),
                },
            ),
            amount_offered,
        )?;

        emit!(OfferMade {
            id,
            maker: ctx.accounts.maker.key(),
            amount_offered,
            amount_wanted,
        });
        Ok(())
    }

    /// Taker fulfils an offer. The wanted token is pulled from the taker
    /// (sent directly to maker); the offered token is released from the vault
    /// to the taker. Vault + Offer accounts are closed atomically; rent refunds
    /// to the maker.
    pub fn take_offer(ctx: Context<TakeOffer>) -> Result<()> {
        let offer = &ctx.accounts.offer;
        let offer_key = offer.key();
        let vault_authority_bump = offer.vault_authority_bump;
        let amount_offered = offer.amount_offered;
        let amount_wanted = offer.amount_wanted;
        let id = offer.id;

        // Wanted token: taker → maker, atomic with the offered-release below.
        token::transfer(
            CpiContext::new(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.taker_token_account_wanted.to_account_info(),
                    to: ctx.accounts.maker_token_account_wanted.to_account_info(),
                    authority: ctx.accounts.taker.to_account_info(),
                },
            ),
            amount_wanted,
        )?;

        // Offered token: vault → taker, signed by per-offer vault_authority PDA.
        let signer_seeds: &[&[u8]] = &[
            b"vault_authority",
            offer_key.as_ref(),
            &[vault_authority_bump],
        ];
        token::transfer(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.vault.to_account_info(),
                    to: ctx.accounts.taker_token_account_offered.to_account_info(),
                    authority: ctx.accounts.vault_authority.to_account_info(),
                },
                &[signer_seeds],
            ),
            amount_offered,
        )?;

        // Close the vault token account; lamports go to the maker.
        token::close_account(CpiContext::new_with_signer(
`selfdestruct`-style cleanup on take, atomically

When the offer is taken, both accounts get torn down atomically: a token::close_account CPI destroys the vault Token Account, and the close = maker constraint on the struct destroys the Offer PDA, with rent deposits flowing back to the maker. Solidity's nearest analog is selfdestruct (deprecated and never really applied to mapping entries anyway). Without this cleanup the accounts would linger forever holding their rent deposits — recoverable in principle, but only via a separate cleanup instruction.

            ctx.accounts.token_program.key(),
            CloseAccount {
                account: ctx.accounts.vault.to_account_info(),
                destination: ctx.accounts.maker.to_account_info(),
                authority: ctx.accounts.vault_authority.to_account_info(),
            },
            &[signer_seeds],
        ))?;

        // The Offer account itself is closed via the `close = maker` constraint
        // on the Accounts struct — Anchor handles it after the handler returns.

        emit!(OfferTaken { id, taker: ctx.accounts.taker.key() });
        Ok(())
    }

    /// Maker withdraws their offered tokens. Closes the offer and vault.
    pub fn cancel_offer(ctx: Context<CancelOffer>) -> Result<()> {
        let offer = &ctx.accounts.offer;
        let offer_key = offer.key();
        let vault_authority_bump = offer.vault_authority_bump;
        let amount_offered = offer.amount_offered;
        let id = offer.id;

        let signer_seeds: &[&[u8]] = &[
            b"vault_authority",
            offer_key.as_ref(),
            &[vault_authority_bump],
        ];

        token::transfer(
            CpiContext::new_with_signer(
                ctx.accounts.token_program.key(),
                Transfer {
                    from: ctx.accounts.vault.to_account_info(),
                    to: ctx.accounts.maker_token_account_offered.to_account_info(),
                    authority: ctx.accounts.vault_authority.to_account_info(),
                },
                &[signer_seeds],
            ),
            amount_offered,
        )?;

        token::close_account(CpiContext::new_with_signer(
            ctx.accounts.token_program.key(),
            CloseAccount {
                account: ctx.accounts.vault.to_account_info(),
                destination: ctx.accounts.maker.to_account_info(),
                authority: ctx.accounts.vault_authority.to_account_info(),
            },
            &[signer_seeds],
        ))?;

        emit!(OfferCancelled { id });
        Ok(())
    }
}

// ---- Accounts ----

#[derive(Accounts)]
#[instruction(id: u64)]
pub struct MakeOffer<'info> {
    #[account(
        init,
        payer = maker,
        space = 8 + Offer::SIZE,
        seeds = [b"offer", maker.key().as_ref(), &id.to_le_bytes()],
        bump,
    )]
    pub offer: Account<'info, Offer>,

    /// CHECK: PDA — signs vault transfers for THIS offer only. Validated by
    /// seeds + bump; never deserialized.
    #[account(
        seeds = [b"vault_authority", offer.key().as_ref()],
        bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    pub token_offered_mint: Account<'info, Mint>,
    pub token_wanted_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = maker,
        seeds = [b"vault", offer.key().as_ref()],
        bump,
        token::mint = token_offered_mint,
        token::authority = vault_authority,
    )]
    pub vault: Account<'info, TokenAccount>,

    #[account(
        mut,
        token::mint = token_offered_mint,
        token::authority = maker,
    )]
    pub maker_token_account_offered: Account<'info, TokenAccount>,

    #[account(mut)]
    pub maker: Signer<'info>,
    pub system_program: Program<'info, System>,
    pub token_program: Program<'info, Token>,
    pub rent: Sysvar<'info, Rent>,
}

#[derive(Accounts)]
pub struct TakeOffer<'info> {
    /// Offer is closed when this instruction returns; rent refunds to maker.
    #[account(
        mut,
        seeds = [b"offer", offer.maker.as_ref(), &offer.id.to_le_bytes()],
        bump = offer.bump,
        has_one = maker @ EscrowError::MakerMismatch,
`require(msg.sender == offer.maker)` → `has_one = maker`

Auth for cancel_offer is the has_one = maker constraint on the struct, not a require! line at the top of the handler. Solidity would check require(msg.sender == offer.maker) inside the function body; here Anchor verifies the same equality before any handler code runs and the rule surfaces in the program's IDL so off-chain tooling sees the access check too. Easier to audit — a reviewer scans the struct definition to see who can call what.

        constraint = offer.token_offered == token_offered_mint.key() @ EscrowError::MintMismatch,
        constraint = offer.token_wanted == token_wanted_mint.key() @ EscrowError::MintMismatch,
        close = maker,
    )]
    pub offer: Account<'info, Offer>,

    /// CHECK: PDA, cached bump.
    #[account(
        seeds = [b"vault_authority", offer.key().as_ref()],
        bump = offer.vault_authority_bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"vault", offer.key().as_ref()],
        bump,
        token::mint = token_offered_mint,
        token::authority = vault_authority,
    )]
    pub vault: Account<'info, TokenAccount>,

    pub token_offered_mint: Account<'info, Mint>,
    pub token_wanted_mint: Account<'info, Mint>,

    #[account(mut, token::mint = token_offered_mint, token::authority = taker)]
    pub taker_token_account_offered: Account<'info, TokenAccount>,

    #[account(mut, token::mint = token_wanted_mint, token::authority = taker)]
    pub taker_token_account_wanted: Account<'info, TokenAccount>,

    #[account(mut, token::mint = token_wanted_mint, token::authority = maker)]
    pub maker_token_account_wanted: Account<'info, TokenAccount>,

    /// CHECK: validated via offer.has_one. Receives the Offer's rent refund.
    #[account(mut)]
    pub maker: UncheckedAccount<'info>,

    pub taker: Signer<'info>,
    pub token_program: Program<'info, Token>,
}

#[derive(Accounts)]
pub struct CancelOffer<'info> {
    #[account(
        mut,
        seeds = [b"offer", maker.key().as_ref(), &offer.id.to_le_bytes()],
        bump = offer.bump,
        has_one = maker @ EscrowError::NotMaker,
        constraint = offer.token_offered == token_offered_mint.key() @ EscrowError::MintMismatch,
        close = maker,
    )]
    pub offer: Account<'info, Offer>,

    /// CHECK: PDA, cached bump.
    #[account(
        seeds = [b"vault_authority", offer.key().as_ref()],
        bump = offer.vault_authority_bump,
    )]
    pub vault_authority: UncheckedAccount<'info>,

    #[account(
        mut,
        seeds = [b"vault", offer.key().as_ref()],
        bump,
        token::mint = token_offered_mint,
        token::authority = vault_authority,
    )]
    pub vault: Account<'info, TokenAccount>,

    pub token_offered_mint: Account<'info, Mint>,

    #[account(mut, token::mint = token_offered_mint, token::authority = maker)]
    pub maker_token_account_offered: Account<'info, TokenAccount>,

    #[account(mut)]
    pub maker: Signer<'info>,
    pub token_program: Program<'info, Token>,
}

// ---- State ----

#[account]
pub struct Offer {
    pub id: u64,
    pub maker: Pubkey,
    pub token_offered: Pubkey,
    pub amount_offered: u64,
    pub token_wanted: Pubkey,
    pub amount_wanted: u64,
    pub bump: u8,
    pub vault_authority_bump: u8,
}

impl Offer {
    pub const SIZE: usize = 8 + 32 + 32 + 8 + 32 + 8 + 1 + 1; // 122 bytes
}

// ---- Events ----

#[event]
pub struct OfferMade {
    pub id: u64,
    pub maker: Pubkey,
    pub amount_offered: u64,
    pub amount_wanted: u64,
}

#[event]
pub struct OfferTaken {
    pub id: u64,
    pub taker: Pubkey,
}

#[event]
pub struct OfferCancelled {
    pub id: u64,
}

// ---- Errors ----

#[error_code]
pub enum EscrowError {
    #[msg("amount must be > 0")]
    ZeroAmount,
    #[msg("offered and wanted tokens must differ")]
    SameToken,
    #[msg("caller is not the maker of this offer")]
    NotMaker,
    #[msg("offer.maker does not match the supplied maker account")]
    MakerMismatch,
    #[msg("supplied token mint does not match the offer")]
    MintMismatch,
}

Why these changes

This is a textbook two-asset atomic swap: a maker offers some amount of one ERC-20 in exchange for some amount of another; a taker who's willing to trade fulfills the offer atomically; either party can cancel before fulfillment. The Solidity version keeps an offer book at mapping(uint256 => Offer) inside the contract plus a nextOfferId counter, escrows tokens with transferFrom, and tracks each offer's lifecycle with a status field that auth checks gate on.

On Solana the same protocol becomes one Offer PDA per offer (derived from [b"offer", maker, id]) plus a per-offer vault Token Account that holds the escrowed tokens. There's no offer-book contract — the offers exist as standalone accounts that come into existence on make_offer and get deleted on take_offer / cancel_offer, so account existence is the lifecycle and no status field is needed. The reference Solana shape is tokens/escrow in solana-developers/program-examples.

01state model

mapping(uint256 => Offer) offer book → per-offer PDA

On Ethereum

In Solidity, mapping(uint256 => Offer) puts every offer at a deterministic storage slot inside one contract; you look up offers[42] and get the slot back. The naive port tried to imitate the Solidity layout by stuffing a Vec<Offer> into one shared state account.

On Solana

The Solana equivalent is one account per offer — a PDA (Program-Derived Address; same "deterministic address from a key" idea, but each entry is its own on-chain account at the derived address). That works mechanically but breaks Solana's parallel-execution model: the runtime locks every writable account a transaction touches, so two takers fulfilling two unrelated offers would serialize on the shared state account. Per-offer PDAs put each offer on its own account, so writes to different offers don't block each other.

The payoff

Unbounded open offers — the naive's MAX_OFFERS = 50 cap is gone. Two unrelated offers' take/cancel transactions run in parallel on Solana's scheduler (no shared writable surface). Every take/cancel touches only that offer's own account, not the whole book. Adding maker to the seeds means each maker has their own id-space — they can't accidentally collide with another maker's id.

02parallelism

Cross-offer activity fully parallel

On Ethereum

Solana's runtime executes transactions in parallel when their writable account sets are disjoint — a key difference from EVM's strictly serialized execution. With no singleton state account, no global counter, and no shared vault, two unrelated offers have completely disjoint footprints.

On Solana

The scheduler picks them up on different cores.

The payoff

Throughput scales with offer count instead of being floored by a single hot account. Two unrelated makers creating offers run concurrently. Two unrelated takers fulfilling offers run concurrently. This is the cleanest parallelism profile across the curated examples — no residual contention floor of any kind.

03security

PDA bumps cached + canonicalization enforced

On Ethereum

Two reasons:

  1. **Compute. ** Pubkey::find_program_address (the bump derivation) is expensive — about 1500 compute units per call. Storing the canonical bump once and reading it on every subsequent instruction saves that on every call. 2. **Security.
On Solana

** A PDA's address has a canonical bump (the highest valid one), but technically several bumps can produce valid PDAs from the same seeds. An instruction that accepts any valid bump opens a class of bug where an attacker passes a non-canonical bump and the program signs for a different address than the one it thinks. Storing the canonical bump pins the PDA identity at init time and refuses anything else.

The payoff

Saves ~1500 CU per instruction; structurally eliminates the non-canonical-bump bug class. The full pattern is documented in security/pda-canonicalization.md.

04cpi & program reuse

selfdestruct-style cleanup on take, atomically

On Ethereum

In the naive design, vault Token Accounts persist forever after the offer is fulfilled — their rent deposits are stranded forever. Closing them as part of the take matches the intent of the instruction ("the offer is over, clean up").

On Solana

On Solana you can structure cleanup as part of the same atomic transaction; on Solidity, the analog would be selfdestruct (deprecated and never really applicable to mappings anyway).

The payoff

Maker recovers 0.0035 SOL of rent on every fulfilled offer (0.0015 SOL for the Offer PDA + ~0.002 SOL for the vault Token Account). The protocol's on-chain footprint goes back to zero when offers complete.

05compute & rent

No global state → no protocol-level rent

On Ethereum

With no global counter and per-offer PDAs, nothing belongs on a singleton account anymore.

On Solana

The protocol has no persistent shared state.

The payoff

Protocol carries no permanent rent burden. All rent is per-offer and refunded on close, so the protocol pays nothing at steady state.

06idioms

Mapping status flag → account-existence lifecycle

On Ethereum

In Solidity, entries in a mapping are always present — there's no way to delete them; "cleared" entries just hold zero values, and you need an if status == Cleared flag elsewhere to track what's actually live.

On Solana

Solana lets accounts come into and go out of existence, so "is this offer still open? " becomes "does this account exist? ". The runtime tells you for free at account validation, before any handler code runs.

The payoff

No bookkeeping bugs around "did we forget to clear this flag?". The runtime gives you the lifecycle property structurally.