What Is a P2P Swap?
A peer-to-peer (P2P) swap lets two users exchange tokens directly without an AMM or order book. User A offers 100 USDC for 1 SOL. User B accepts. The swap happens atomically in a single transaction — either both sides settle or neither does.
This is the OTC (over-the-counter) primitive: no slippage, no pool fees, no impermanent loss. We built this pattern for the Guac P2P Swap at Cyber Vision. This tutorial walks through the full implementation.
Prerequisites
- Rust + Solana CLI + Anchor CLI (see Coinflip tutorial for setup)
- Understanding of SPL token accounts and PDAs
1. How It Works
Maker creates an offer:
"I'll give 100 USDC for 1 SOL"
→ 100 USDC transferred to escrow vault
Taker accepts:
→ 1 SOL transferred to maker
→ 100 USDC released from escrow to taker
→ Offer marked as filled
Or Maker cancels:
→ 100 USDC returned from escrow to maker
→ Offer closed
The escrow vault is a PDA-controlled token account. Only the program can release funds from it.
2. State Definitions
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Token, TokenAccount, Transfer, Mint};
declare_id!("YOUR_PROGRAM_ID");
#[account]
pub struct SwapOffer {
pub maker: Pubkey,
pub offer_mint: Pubkey, // Token the maker is offering
pub offer_amount: u64,
pub request_mint: Pubkey, // Token the maker wants in return
pub request_amount: u64,
pub vault: Pubkey,
3. Create Offer Instruction
The maker deposits their tokens into the escrow vault:
#[program]
pub mod p2p_swap {
use super::*;
pub fn create_offer(
ctx: Context<CreateOffer>,
offer_amount: u64,
request_amount: u64,
) -> Result<()> {
require!(offer_amount > 0, SwapError::ZeroAmount);
require!(request_amount > 0, SwapError::ZeroAmount);
let offer = &mut ctx.accounts.offer;
offer.maker = ctx.accounts.maker.key();
4. Accept Offer (Atomic Swap)
The taker sends the requested tokens to the maker and receives the escrowed tokens — all in one transaction:
pub fn accept_offer(ctx: Context<AcceptOffer>) -> Result<()> {
let offer = &mut ctx.accounts.offer;
require!(offer.status == SwapStatus::Open, SwapError::OfferNotOpen);
// Step 1: Taker sends requested tokens to maker
token::transfer(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.taker_request_ata.to_account_info(),
to: ctx.accounts.maker_request_ata.to_account_info(),
authority
The swap is atomic — if either transfer fails, the entire transaction reverts. No partial fills, no stuck funds. This is the power of Solana's transaction model.
5. Cancel Offer
The maker can cancel an open offer and reclaim their tokens:
pub fn cancel_offer(ctx: Context<CancelOffer>) -> Result<()> {
let offer = &mut ctx.accounts.offer;
require!(offer.status == SwapStatus::Open, SwapError::OfferNotOpen);
let seeds = &[
b"offer",
offer.maker.as_ref(),
offer.offer_mint.as_ref(),
offer.request_mint.as_ref(),
&[offer.bump],
];
let signer = &[&seeds[..]];
token::
6. Account Structs
#[derive(Accounts)]
pub struct CreateOffer<'info> {
#[account(
init,
payer = maker,
space = SwapOffer::SIZE,
seeds = [
b"offer",
maker.key().as_ref(),
offer_mint.key().as_ref(),
request_mint.key().as_ref(),
],
bump,
)]
pub offer: Account<'info, SwapOffer>,
#[account(
init,
payer = maker,
token::mint = offer_mint,
token::authority = offer,
)]
7. Error Codes and Events
#[error_code]
pub enum SwapError {
#[msg("Amount must be greater than zero")]
ZeroAmount,
#[msg("Offer is not open")]
OfferNotOpen,
}
#[event]
pub struct OfferCreated {
pub offer: Pubkey,
pub maker: Pubkey,
pub offer_mint: Pubkey,
pub offer_amount: u64,
pub request_mint: Pubkey,
pub request_amount: u64,
}
#[event]
pub struct OfferFilled {
8. TypeScript Client
import * as anchor from "@coral-xyz/anchor";
import { PublicKey } from "@solana/web3.js";
import { getAssociatedTokenAddress, TOKEN_PROGRAM_ID } from "@solana/spl-token";
async function createOffer(
program: anchor.Program,
offerMint: PublicKey,
requestMint: PublicKey,
offerAmount: number,
requestAmount: number
) {
const maker = program.provider.publicKey;
const [
Extending the Pattern
| Feature | Implementation |
|---|---|
| Expiring offers | Add expires_at to SwapOffer, check in accept_offer |
| Multiple offers per pair | Add a nonce to the PDA seeds |
| Partial fills | Track amount_filled, allow multiple takers |
| Protocol fee | Deduct a percentage in accept_offer before releasing tokens |
| Offer discovery | Index offers with getProgramAccounts filtered by mint |
Wrapping Up
A P2P swap is the simplest possible DeFi primitive and one of the most useful. The escrow pattern — deposit to PDA vault on create, atomic release on accept, return on cancel — is the same pattern used in NFT marketplaces, options protocols, and lending platforms.
For locking liquidity after a token launch, see our Liquidity Locker tutorial. For the basics of creating tokens to swap, check SPL Token Guide.