4.1 Token Custody Models

Each platform has fundamentally different approaches to holding tokens in escrow:

The Three Custody Models

  • CosmWasm: Native tokens sent to contract address via Bank module, CW20 via allowance/transfer
  • Solidity: ERC-20 tokens transferred to contract, held in contract's balance
  • Solana: SPL tokens in Associated Token Accounts (ATAs) controlled by PDAs
CosmWasm Solidity Solana User Trade Contract BankMsg::Send User Escrow Contract transferFrom User ATA Vault ATA transfer Release Flow Release Flow Release Flow Trade Contract Seller Escrow Contract Seller Vault PDA Seller ATA

4.2 Funding the Escrow

CosmWasm - Native Token

pub fn execute_fund_escrow(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    trade_id: u64,
) -> Result<Response, ContractError> {
    let mut trade = TRADES.load(
        deps.storage, trade_id
    )?;

    // Verify sender is the seller
    if info.sender != trade.seller {
        return Err(ContractError::Unauthorized {});
    }

    // Verify funds sent with message
    let sent = info.funds
        .iter()
        .find(|c| c.denom == trade.denom)
        .map(|c| c.amount)
        .unwrap_or(Uint128::zero());

    let required = trade.amount + trade.fees;
    if sent < required {
        return Err(ContractError::InsufficientFunds {
            required,
            sent,
        });
    }

    // Funds are now held by contract
    // (automatically via BankMsg)
    trade.state = TradeState::EscrowFunded;
    trade.escrow_amount = sent;
    TRADES.save(deps.storage, trade_id, &trade)?;

    Ok(Response::new()
        .add_attribute("action", "fund_escrow")
        .add_attribute("trade_id", trade_id.to_string())
        .add_attribute("amount", sent))
}

Solidity - ERC-20

function fundEscrow(
    uint256 tradeId
) external nonReentrant whenNotPaused {
    TradeData storage trade = trades[tradeId];

    // Verify caller is seller
    require(
        msg.sender == trade.seller,
        "Only seller can fund"
    );

    uint256 required = trade.amount + trade.fees;

    // Transfer tokens FROM seller TO this contract
    // Requires prior approval!
    IERC20 token = IERC20(trade.token);

    // Check allowance
    require(
        token.allowance(msg.sender, address(this))
            >= required,
        "Insufficient allowance"
    );

    // Safe transfer from seller
    token.safeTransferFrom(
        msg.sender,
        address(this),
        required
    );

    // Update state
    trade.state = TradeState.EscrowFunded;
    escrowBalances[tradeId] = required;

    emit EscrowFunded(tradeId, required);
}

Solana - SPL Token

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

    #[account(
        mut,
        has_one = seller,
        constraint = trade.state == TradeState::Accepted
    )]
    pub trade: Account<'info, Trade>,

    // Seller's token account
    #[account(
        mut,
        associated_token::mint = token_mint,
        associated_token::authority = seller
    )]
    pub seller_token_account: Account<'info, TokenAccount>,

    // Escrow vault (PDA-controlled ATA)
    #[account(
        init_if_needed,
        payer = seller,
        associated_token::mint = token_mint,
        associated_token::authority = vault_authority
    )]
    pub vault: Account<'info, TokenAccount>,

    // PDA that controls the vault
    /// CHECK: PDA authority
    #[account(
        seeds = [b"vault", trade.key().as_ref()],
        bump
    )]
    pub vault_authority: UncheckedAccount<'info>,

    pub token_mint: Account<'info, Mint>,
    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
    pub associated_token_program: Program<'info, AssociatedToken>,
}

pub fn fund_escrow(ctx: Context<FundEscrow>) -> Result<()> {
    let trade = &mut ctx.accounts.trade;
    let amount = trade.amount + trade.fees;

    // Transfer via CPI to token program
    let cpi_ctx = CpiContext::new(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: ctx.accounts.seller_token_account.to_account_info(),
            to: ctx.accounts.vault.to_account_info(),
            authority: ctx.accounts.seller.to_account_info(),
        },
    );
    token::transfer(cpi_ctx, amount)?;

    trade.state = TradeState::EscrowFunded;
    Ok(())
}
ERC-20 Requires Approval!

In Solidity, users must first call token.approve(escrowContract, amount) before the contract can transfer tokens. This is a two-transaction flow. CosmWasm native tokens don't need this. Solana uses a single transaction with the signer's authority.

4.3 Releasing Escrow with Fee Distribution

When a trade completes, the escrow releases tokens to the buyer and distributes fees:

CosmWasm - Multiple BankMsgs

pub fn execute_release_escrow(
    deps: DepsMut,
    env: Env,
    info: MessageInfo,
    trade_id: u64,
) -> Result<Response, ContractError> {
    let mut trade = TRADES.load(deps.storage, trade_id)?;
    let hub: HubConfig = query_hub_config(deps.as_ref())?;

    // Calculate fee distribution
    let total = trade.escrow_amount;
    let burn_fee = total * hub.burn_fee_pct;
    let chain_fee = total * hub.chain_fee_pct;
    let warchest_fee = total * hub.warchest_fee_pct;
    let buyer_amount = total - burn_fee - chain_fee - warchest_fee;

    // Build multiple bank messages
    let mut msgs: Vec<CosmosMsg> = vec![];

    // Send to buyer
    msgs.push(CosmosMsg::Bank(BankMsg::Send {
        to_address: trade.buyer.to_string(),
        amount: vec![Coin {
            denom: trade.denom.clone(),
            amount: buyer_amount,
        }],
    }));

    // Send to chain treasury
    msgs.push(CosmosMsg::Bank(BankMsg::Send {
        to_address: hub.chain_treasury.to_string(),
        amount: vec![Coin {
            denom: trade.denom.clone(),
            amount: chain_fee,
        }],
    }));

    // Burn tokens
    msgs.push(CosmosMsg::Bank(BankMsg::Burn {
        amount: vec![Coin {
            denom: trade.denom.clone(),
            amount: burn_fee,
        }],
    }));

    trade.state = TradeState::EscrowReleased;
    TRADES.save(deps.storage, trade_id, &trade)?;

    Ok(Response::new()
        .add_messages(msgs)
        .add_attribute("action", "release_escrow"))
}

Solidity - SafeTransfer

function releaseEscrow(
    uint256 tradeId
) external nonReentrant {
    TradeData storage trade = trades[tradeId];

    // Only seller can release after fiat confirmed
    require(
        msg.sender == trade.seller,
        "Only seller"
    );
    require(
        trade.state == TradeState.FiatDeposited,
        "Invalid state"
    );

    uint256 total = escrowBalances[tradeId];
    IERC20 token = IERC20(trade.token);

    // Calculate fees
    uint256 burnFee = (total * hub.burnFeeBps) / 10000;
    uint256 chainFee = (total * hub.chainFeeBps) / 10000;
    uint256 warchestFee = (total * hub.warchestFeeBps) / 10000;
    uint256 buyerAmount = total - burnFee - chainFee - warchestFee;

    // Clear balance before transfers (CEI pattern)
    escrowBalances[tradeId] = 0;
    trade.state = TradeState.EscrowReleased;

    // Distribute - using SafeERC20
    token.safeTransfer(trade.buyer, buyerAmount);
    token.safeTransfer(hub.chainTreasury, chainFee);
    token.safeTransfer(hub.warchest, warchestFee);

    // Burn by sending to dead address
    // (or use burnable token interface)
    token.safeTransfer(BURN_ADDRESS, burnFee);

    emit EscrowReleased(tradeId, buyerAmount);
}

Solana - CPI Transfers

pub fn release_escrow(ctx: Context<ReleaseEscrow>) -> Result<()> {
    let trade = &mut ctx.accounts.trade;
    let hub = &ctx.accounts.hub_config;

    let total = ctx.accounts.vault.amount;

    // Calculate fees
    let burn_fee = total * hub.burn_fee_bps as u64 / 10000;
    let chain_fee = total * hub.chain_fee_bps as u64 / 10000;
    let warchest_fee = total * hub.warchest_fee_bps as u64 / 10000;
    let buyer_amount = total - burn_fee - chain_fee - warchest_fee;

    // PDA signer seeds
    let trade_key = trade.key();
    let seeds = &[
        b"vault",
        trade_key.as_ref(),
        &[ctx.bumps.vault_authority],
    ];
    let signer = &[&seeds[..]];

    // Transfer to buyer
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: ctx.accounts.vault.to_account_info(),
            to: ctx.accounts.buyer_token_account.to_account_info(),
            authority: ctx.accounts.vault_authority.to_account_info(),
        },
        signer,
    );
    token::transfer(cpi_ctx, buyer_amount)?;

    // Transfer fees to treasury
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: ctx.accounts.vault.to_account_info(),
            to: ctx.accounts.treasury.to_account_info(),
            authority: ctx.accounts.vault_authority.to_account_info(),
        },
        signer,
    );
    token::transfer(cpi_ctx, chain_fee)?;

    // Burn by transferring to burn account or using burn CPI
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        Burn {
            mint: ctx.accounts.token_mint.to_account_info(),
            from: ctx.accounts.vault.to_account_info(),
            authority: ctx.accounts.vault_authority.to_account_info(),
        },
        signer,
    );
    token::burn(cpi_ctx, burn_fee)?;

    trade.state = TradeState::EscrowReleased;
    Ok(())
}

PDA Signing in Solana

In Solana, the vault is controlled by a PDA. To transfer tokens FROM the vault, we need to sign with the PDA's seeds. The CpiContext::new_with_signer allows the program to "sign" on behalf of the PDA since only the program knows the seeds.

4.4 Refund Pattern (Dispute/Cancel)

CosmWasm Refund

pub fn execute_refund_escrow(
    deps: DepsMut,
    info: MessageInfo,
    trade_id: u64,
) -> Result<Response, ContractError> {
    let mut trade = TRADES.load(deps.storage, trade_id)?;

    // Verify state allows refund
    if trade.state != TradeState::EscrowFunded {
        return Err(ContractError::InvalidState {});
    }

    // Return full amount to seller (no fees)
    let msg = CosmosMsg::Bank(BankMsg::Send {
        to_address: trade.seller.to_string(),
        amount: vec![Coin {
            denom: trade.denom.clone(),
            amount: trade.escrow_amount,
        }],
    });

    trade.state = TradeState::EscrowRefunded;
    TRADES.save(deps.storage, trade_id, &trade)?;

    Ok(Response::new()
        .add_message(msg)
        .add_attribute("action", "refund"))
}

Solidity Refund

function refundEscrow(
    uint256 tradeId
) external nonReentrant {
    TradeData storage trade = trades[tradeId];

    require(
        trade.state == TradeState.EscrowFunded,
        "Invalid state"
    );

    uint256 amount = escrowBalances[tradeId];
    IERC20 token = IERC20(trade.token);

    // CEI: Update state before transfer
    escrowBalances[tradeId] = 0;
    trade.state = TradeState.EscrowRefunded;

    // Return full amount (no fees)
    token.safeTransfer(trade.seller, amount);

    emit EscrowRefunded(tradeId, amount);
}

Solana Refund

pub fn refund_escrow(
    ctx: Context<RefundEscrow>
) -> Result<()> {
    let trade = &mut ctx.accounts.trade;

    require!(
        trade.state == TradeState::EscrowFunded,
        TradeError::InvalidState
    );

    let amount = ctx.accounts.vault.amount;

    // PDA signer
    let trade_key = trade.key();
    let seeds = &[
        b"vault",
        trade_key.as_ref(),
        &[ctx.bumps.vault_authority],
    ];
    let signer = &[&seeds[..]];

    // Transfer back to seller
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        Transfer {
            from: ctx.accounts.vault.to_account_info(),
            to: ctx.accounts.seller_token_account.to_account_info(),
            authority: ctx.accounts.vault_authority.to_account_info(),
        },
        signer,
    );
    token::transfer(cpi_ctx, amount)?;

    // Close vault account, return rent to seller
    let cpi_ctx = CpiContext::new_with_signer(
        ctx.accounts.token_program.to_account_info(),
        CloseAccount {
            account: ctx.accounts.vault.to_account_info(),
            destination: ctx.accounts.seller.to_account_info(),
            authority: ctx.accounts.vault_authority.to_account_info(),
        },
        signer,
    );
    token::close_account(cpi_ctx)?;

    trade.state = TradeState::EscrowRefunded;
    Ok(())
}
💡
Solana Rent Recovery

In Solana, token accounts require rent (SOL). When closing the vault with CloseAccount, the rent is returned to the specified destination. This is a common pattern to recover SOL when accounts are no longer needed.

Knowledge Check

Test Your Understanding

1. Why does ERC-20 token escrow require a two-transaction flow?

2. How does a Solana program sign for a PDA to transfer tokens from a vault?

3. What is the CEI (Checks-Effects-Interactions) pattern in Solidity?