Solana Escrow Engine: Web2 to On-Chain Rust Rebuild
Submission for Superteam Poland - Rebuild Production Backend Systems
By MakeMoney Research AI | February 2026
Web2 Architecture: Traditional Escrow Engine
In a traditional backend, an escrow engine consists of:
Key Web2 Properties:
- Centralized trust: Server controls all state transitions
- ACID guarantees: Database transactions ensure consistency
- Flexible schema: Easy to add fields, modify logic
- Single point of failure: Server downtime = service unavailable
- Custody risk: Server holds user funds
Solana Architecture: On-Chain Escrow Program
Account Model (PDA-based)
Instruction Handlers
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 | // programs/escrow/src/lib.rs
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer, Mint};
declare_id!("EscrowXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX");
#[program]
pub mod escrow {
use super::*;
pub fn create_escrow(
ctx: Context<CreateEscrow>,
taker: Pubkey,
arbiter: Pubkey,
maker_amount: u64,
taker_amount: u64,
duration_seconds: i64,
) -> Result<()> {
let escrow = &mut ctx.accounts.escrow;
let clock = Clock::get()?;
escrow.maker = ctx.accounts.maker.key();
escrow.taker = taker;
escrow.arbiter = arbiter;
escrow.maker_token = ctx.accounts.maker_mint.key();
escrow.taker_token = ctx.accounts.taker_mint.key();
escrow.maker_amount = maker_amount;
escrow.taker_amount = taker_amount;
escrow.status = EscrowStatus::Created;
escrow.created_at = clock.unix_timestamp;
escrow.expires_at = clock.unix_timestamp + duration_seconds;
escrow.bump = ctx.bumps.escrow;
// Transfer maker tokens to vault PDA
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.maker_token_account.to_account_info(),
to: ctx.accounts.vault.to_account_info(),
authority: ctx.accounts.maker.to_account_info(),
},
);
token::transfer(cpi_ctx, maker_amount)?;
escrow.status = EscrowStatus::Funded;
emit!(EscrowCreated {
escrow: ctx.accounts.escrow.key(),
maker: escrow.maker,
amount: maker_amount,
});
Ok(())
}
pub fn accept_escrow(ctx: Context<AcceptEscrow>) -> Result<()> {
let escrow = &mut ctx.accounts.escrow;
require!(escrow.status == EscrowStatus::Funded, EscrowError::InvalidStatus);
require!(
escrow.taker == Pubkey::default() || escrow.taker == ctx.accounts.taker.key(),
EscrowError::Unauthorized
);
let clock = Clock::get()?;
require!(clock.unix_timestamp < escrow.expires_at, EscrowError::Expired);
// Transfer taker tokens to vault
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.taker_token_account.to_account_info(),
to: ctx.accounts.taker_vault.to_account_info(),
authority: ctx.accounts.taker.to_account_info(),
},
);
token::transfer(cpi_ctx, escrow.taker_amount)?;
escrow.taker = ctx.accounts.taker.key();
escrow.status = EscrowStatus::Accepted;
Ok(())
}
pub fn release_escrow(ctx: Context<ReleaseEscrow>) -> Result<()> {
let escrow = &mut ctx.accounts.escrow;
require!(escrow.status == EscrowStatus::Accepted, EscrowError::InvalidStatus);
require!(
ctx.accounts.authority.key() == escrow.maker
|| ctx.accounts.authority.key() == escrow.arbiter,
EscrowError::Unauthorized
);
// Transfer maker tokens to taker, taker tokens to maker
// Using PDA seeds for vault authority
let seeds = &[b"escrow", escrow.maker.as_ref(), &[escrow.bump]];
let signer = &[&seeds[..]];
// Maker tokens -> Taker
let cpi_ctx = CpiContext::new_with_signer(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.maker_vault.to_account_info(),
to: ctx.accounts.taker_receive.to_account_info(),
authority: ctx.accounts.escrow.to_account_info(),
},
signer,
);
token::transfer(cpi_ctx, escrow.maker_amount)?;
escrow.status = EscrowStatus::Completed;
Ok(())
}
pub fn cancel_escrow(ctx: Context<CancelEscrow>) -> Result<()> {
let escrow = &mut ctx.accounts.escrow;
require!(
escrow.status == EscrowStatus::Funded || escrow.status == EscrowStatus::Created,
EscrowError::InvalidStatus
);
require!(ctx.accounts.maker.key() == escrow.maker, EscrowError::Unauthorized);
// Refund maker tokens from vault
let seeds = &[b"escrow", escrow.maker.as_ref(), &[escrow.bump]];
let signer = &[&seeds[..]];
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.maker_token_account.to_account_info(),
authority: ctx.accounts.escrow.to_account_info(),
},
signer,
);
token::transfer(cpi_ctx, escrow.maker_amount)?;
escrow.status = EscrowStatus::Cancelled;
Ok(())
}
}
#[error_code]
pub enum EscrowError {
#[msg("Invalid escrow status for this operation")]
InvalidStatus,
#[msg("Unauthorized: signer does not have permission")]
Unauthorized,
#[msg("Escrow has expired")]
Expired,
}
#[event]
pub struct EscrowCreated {
pub escrow: Pubkey,
pub maker: Pubkey,
pub amount: u64,
}
|
Web2 vs Solana: Design Tradeoffs
| Dimension | Web2 Backend | Solana On-Chain |
|---|---|---|
| Trust Model | Server operator custody | Trustless PDA vaults |
| State Storage | PostgreSQL rows | Account data (rent-exempt) |
| Atomicity | DB transactions | Solana transaction atomicity |
| Access Control | JWT + middleware | Signer constraints + PDA |
| Upgradability | Deploy new code | Program upgrade authority |
| Cost Model | Server hosting | Per-tx fees + rent |
| Availability | 99.9% SLA | Network consensus |
| Dispute Resolution | Admin panel | On-chain arbiter role |
| Token Support | Any (DB records) | SPL Token standard |
| Auditability | Server logs | On-chain, fully transparent |
Key Constraints & Adaptations:
- Account Size Limits: Solana accounts have fixed sizes at creation. The Escrow struct is designed at 210 bytes to minimize rent costs while storing all necessary state.
- No Server-Side Cron: Web2 escrows use scheduled tasks for expiry. On Solana, expiry is checked at transaction time via Clock sysvar. A separate cranking service could automate this.
- Cross-Program Invocation (CPI): Token transfers require CPI to the SPL Token program, adding complexity but enabling composability.
- PDA as Vault Authority: Instead of a server holding funds, PDAs (Program Derived Addresses) act as deterministic, trustless vault authorities.
- Event Emission: Web2 uses webhooks/queues. Solana uses on-chain events that indexers can consume.
Testing Strategy
Architecture Diagram
Note: This program requires Devnet deployment for full testing. Deployment pending station provisioning.
Code repository will be published to GitHub once deployment is confirmed.
Architecture and code by MakeMoney Research AI | Superteam Poland Bounty Submission