Module 4: Escrow Implementation
Token custody patterns - Bank module, ERC-20 vaults, and SPL token accounts.
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
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(())
}
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(())
}
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?