Declining Price Token Sales with Anchor - Building a Dutch Auction on Solana

Declining Price Token Sales with Anchor - Building a Dutch Auction on Solana

After building a hello world, a price oracle, and a piggy bank, this exercise introduces the two concepts the piggy bank post promised: PDAs (Program Derived Addresses) and SPL token operations. Together, they’re the foundation for DeFi on Solana.

The project is a Dutch auction — a sale mechanism where the price starts high and decreases linearly over time. The first buyer to accept the current price gets the tokens. It’s used in NFT launches, token sales, and even traditional markets like the Dutch flower auctions that gave it its name.

How It Works

Three instructions:

  1. Init — Create an auction, set the price curve, and escrow tokens into a PDA
  2. Buy — Purchase tokens at the current declining price
  3. Cancel — Seller reclaims tokens and shuts down the auction

Unlike the piggy bank which moved SOL, this program moves SPL tokens — Solana’s standard for fungible and non-fungible tokens. The seller puts up “sell tokens” and receives “buy tokens” as payment. The price is a conversion rate between the two.

Program Structure

Same modular layout as the oracle:

src/
├── lib.rs              # Entry point + transfer helpers
├── state.rs            # Auction account
├── error.rs            # Custom errors
└── instructions/
    ├── mod.rs
    ├── init.rs         # Create auction
    ├── buy.rs          # Purchase tokens
    └── cancel.rs       # Cancel auction

The Auction Account

#[account]
#[derive(InitSpace)]
pub struct Auction {
    pub seller: Pubkey,
    pub sell_mint: Pubkey,
    pub buy_mint: Pubkey,
    pub sell_amount: u64,
    pub start_price: u64,
    pub end_price: u64,
    pub start_time: u64,
    pub end_time: u64,
    pub bump: u8,
}

More state than previous exercises, but the pattern is the same: #[derive(InitSpace)] calculates storage, and every field is explicit. The bump field stores the PDA’s bump seed so it doesn’t need to be recalculated on every instruction.

The price curve is defined by start_price, end_price, start_time, and end_time. At any moment, the current price is a linear interpolation between the two endpoints.

Program Derived Addresses

This is the big conceptual jump. Previous exercises used regular keypair accounts — you generate a keypair, sign with it, and the account belongs to whoever holds the private key. PDAs are different: they’re accounts derived from a set of seeds and the program ID. No private key exists. Only the program can sign for them.

The auction uses two PDAs:

// Auction state — derived from seller + sell_mint
seeds = [b"auction", seller.key().as_ref(), sell_mint.key().as_ref()]

// Token escrow — derived from the auction PDA itself
seeds = [b"auction_sell_ata", auction.key().as_ref()]

The first PDA stores the auction configuration. The second holds the actual tokens in escrow. Because the escrow’s authority is the auction PDA, the program can sign token transfers on its behalf — no human signer needed. This is what makes trustless escrow possible.

In Rails terms, think of a PDA like a database record whose primary key is deterministic based on its attributes. Given the same seller and token mint, you always get the same auction address. No UUIDs, no auto-increment — just a hash of the inputs.

Initializing the Auction

pub fn init(
    ctx: Context<InitCtx>,
    sell_amount: u64,
    start_price: u64,
    end_price: u64,
    start_time: u64,
    end_time: u64,
) -> Result<()> {
    require_keys_neq!(
        ctx.accounts.sell_mint.key(),
        ctx.accounts.buy_mint.key(),
        AuctionError::SameToken
    );

    require!(start_price >= end_price, AuctionError::InvalidPrice);

    let clock = Clock::get()?;
    let current_time = clock.unix_timestamp as u64;
    require!(
        current_time <= start_time && start_time < end_time,
        AuctionError::InvalidTime
    );

    require!(sell_amount > 0, AuctionError::InvalidAmount);

    transfer(
        &ctx.accounts.seller_sell_ata.to_account_info(),
        &ctx.accounts.auction_sell_ata.to_account_info(),
        &ctx.accounts.seller.to_account_info(),
        &ctx.accounts.token_program.to_account_info(),
        sell_amount,
    )?;

    let auction = &mut ctx.accounts.auction;
    auction.seller = ctx.accounts.seller.key();
    auction.sell_mint = ctx.accounts.sell_mint.key();
    auction.buy_mint = ctx.accounts.buy_mint.key();
    auction.sell_amount = sell_amount;
    auction.start_price = start_price;
    auction.end_price = end_price;
    auction.start_time = start_time;
    auction.end_time = end_time;
    auction.bump = ctx.bumps.auction;

    Ok(())
}

Four validations before any state changes: tokens must be different, price curve must slope downward, time window must be valid, and amount must be positive. Then the tokens move from the seller to the PDA-controlled escrow via CPI to the Token Program.

The accounts struct shows how the two PDAs get created:

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

    pub sell_mint: Account<'info, Mint>,
    pub buy_mint: Account<'info, Mint>,

    #[account(
        init,
        payer = seller,
        space = 8 + Auction::INIT_SPACE,
        seeds = [b"auction", seller.key().as_ref(), sell_mint.key().as_ref()],
        bump,
    )]
    pub auction: Account<'info, Auction>,

    #[account(
        init,
        payer = seller,
        token::mint = sell_mint,
        token::authority = auction,
        seeds = [b"auction_sell_ata", auction.key().as_ref()],
        bump,
    )]
    pub auction_sell_ata: Account<'info, TokenAccount>,

    #[account(
        mut,
        constraint = seller_sell_ata.mint == sell_mint.key(),
        constraint = seller_sell_ata.owner == seller.key(),
    )]
    pub seller_sell_ata: Account<'info, TokenAccount>,

    pub token_program: Program<'info, Token>,
    pub system_program: Program<'info, System>,
}

Notice token::authority = auction on the escrow account. This makes the auction PDA the owner of the token account, meaning only the program can move tokens out of it. The seller’s tokens are locked until someone buys or the seller cancels.

The Price Calculation

The core of a Dutch auction is the declining price formula. Here’s the buy instruction:

let elapsed = current_time.checked_sub(auction.start_time)
    .ok_or(AuctionError::Overflow)?;
let duration = auction.end_time.checked_sub(auction.start_time)
    .ok_or(AuctionError::Overflow)?;
let price_range = auction.start_price.checked_sub(auction.end_price)
    .ok_or(AuctionError::Overflow)?;

let price_decrease: u64 = (price_range as u128)
    .checked_mul(elapsed as u128)
    .ok_or(AuctionError::Overflow)?
    .checked_div(duration as u128)
    .ok_or(AuctionError::Overflow)?
    .try_into()
    .map_err(|_| error!(AuctionError::Overflow))?;

let current_price = auction.start_price
    .checked_sub(price_decrease)
    .ok_or(AuctionError::Overflow)?;

Simple linear interpolation: current_price = start_price - (price_range * elapsed / duration). But the implementation is careful. Every arithmetic operation uses checked_* methods that return None on overflow instead of panicking. The intermediate multiplication uses u128 to prevent overflow when multiplying two u64 values.

Compare this to Ruby where BigDecimal handles arbitrary precision automatically. In Rust on Solana, you’re working with fixed-width integers and paying for every compute unit. The explicitness is verbose but prevents a whole class of bugs that have caused real financial losses in DeFi.

PDA Signatures

When the buyer purchases tokens, the program needs to sign a transfer from the escrow (which the auction PDA controls). Regular CPI uses CpiContext::new. PDA transfers use CpiContext::new_with_signer with the PDA’s seeds:

let seller_key = auction.seller.key();
let sell_mint_key = auction.sell_mint.key();
let seeds = &[
    b"auction",
    seller_key.as_ref(),
    sell_mint_key.as_ref(),
    &[auction.bump],
];

transfer_from_pda(
    &ctx.accounts.auction_sell_ata.to_account_info(),
    &ctx.accounts.buyer_sell_ata.to_account_info(),
    &ctx.accounts.auction.to_account_info(),
    &ctx.accounts.token_program.to_account_info(),
    auction.sell_amount,
    &[seeds],
)?;

The seeds plus the stored bump recreate the PDA address, proving to the runtime that this program has authority over the account. This is the transfer_from_pda helper, which wraps CpiContext::new_with_signer:

pub fn transfer_from_pda<'info>(
    from: &AccountInfo<'info>,
    to: &AccountInfo<'info>,
    authority: &AccountInfo<'info>,
    token_program: &AccountInfo<'info>,
    amount: u64,
    seeds: &[&[&[u8]]],
) -> Result<()> {
    token::transfer(
        CpiContext::new_with_signer(
            token_program.clone(),
            Transfer { from: from.clone(), to: to.clone(), authority: authority.clone() },
            seeds,
        ),
        amount,
    )
}

Account Cleanup

After a buy or cancel, the program closes both the escrow token account and the auction account, returning rent to the seller:

close_account(CpiContext::new_with_signer(
    ctx.accounts.token_program.to_account_info(),
    CloseAccount {
        account: ctx.accounts.auction_sell_ata.to_account_info(),
        destination: ctx.accounts.seller.to_account_info(),
        authority: ctx.accounts.auction.to_account_info(),
    },
    &[seeds],
))?;

The token account closes via CPI to the Token Program. The auction account closes via Anchor’s close = seller constraint. Both return their rent deposits to the seller.

Account Validation with has_one

The buy instruction’s account struct shows Anchor’s declarative validation:

#[account(
    mut,
    close = seller,
    seeds = [b"auction", seller.key().as_ref(), sell_mint.key().as_ref()],
    bump = auction.bump,
    has_one = seller,
    has_one = sell_mint,
    has_one = buy_mint,
)]
pub auction: Account<'info, Auction>,

has_one = seller checks that auction.seller == seller.key(). Same for the mints. Combined with the PDA seed verification, this means a buyer can’t tamper with any of the auction parameters. The constraints are checked before the instruction logic runs — if anything doesn’t match, the transaction fails immediately.

Error Handling

#[error_code]
pub enum AuctionError {
    #[msg("Sell token and buy token must be different")]
    SameToken,
    #[msg("Start price must be greater than or equal to end price")]
    InvalidPrice,
    #[msg("Invalid time range")]
    InvalidTime,
    #[msg("Sell amount must be greater than 0")]
    InvalidAmount,
    #[msg("Auction has not started yet")]
    AuctionNotStarted,
    #[msg("Auction has ended")]
    AuctionEnded,
    #[msg("Price exceeds max price")]
    PriceExceedsMax,
    #[msg("Arithmetic overflow")]
    Overflow,
}

Eight distinct error cases covering initialization, timing, pricing, and arithmetic. The buyer passes a max_price parameter as slippage protection — if the price has increased since they prepared the transaction (due to blockchain timing), the instruction fails with PriceExceedsMax instead of charging more than expected.

Testing

The test setup is more involved than previous exercises because SPL tokens need to be created and distributed before the auction can start:

before(async () => {
    buyer = Keypair.generate();
    await airdrop(buyer.publicKey, 10 * LAMPORTS_PER_SOL);

    // Create two token mints
    sellMint = await createMint(connection, payer, provider.wallet.publicKey, null, 9);
    buyMint = await createMint(connection, payer, provider.wallet.publicKey, null, 9);

    // Create token accounts and mint tokens
    sellerSellAta = await createAccount(connection, payer, sellMint, provider.wallet.publicKey);
    await mintTo(connection, payer, sellMint, sellerSellAta, provider.wallet.publicKey, 1_000_000_000);

    buyerBuyAta = await createAccount(connection, payer, buyMint, buyer.publicKey);
    await mintTo(connection, payer, buyMint, buyerBuyAta, provider.wallet.publicKey, 10_000_000_000);

    // Derive PDAs
    [auctionPda] = PublicKey.findProgramAddressSync(
        [Buffer.from("auction"), provider.wallet.publicKey.toBuffer(), sellMint.toBuffer()],
        program.programId
    );

    [auctionSellAta] = PublicKey.findProgramAddressSync(
        [Buffer.from("auction_sell_ata"), auctionPda.toBuffer()],
        program.programId
    );
});

The PDA derivation on the client side mirrors the seeds defined in the program. findProgramAddressSync is the TypeScript equivalent of Anchor’s seeds constraint — same inputs, same output address.

The buy test waits for the auction to start, then verifies the declining price works:

it("buys from the auction", async () => {
    await new Promise((resolve) => setTimeout(resolve, 4000));

    const maxPrice = new anchor.BN(2_000_000_000);
    const buyerBuyBefore = await getAccount(connection, buyerBuyAta);

    await program.methods
        .buy(maxPrice)
        .accountsStrict({
            buyer: buyer.publicKey,
            seller: provider.wallet.publicKey,
            sellMint, buyMint, auction: auctionPda,
            auctionSellAta, buyerBuyAta, buyerSellAta, sellerBuyAta,
            tokenProgram: TOKEN_PROGRAM_ID,
        })
        .signers([buyer])
        .rpc();

    // Buyer received all sell tokens
    const buyerSellAccount = await getAccount(connection, buyerSellAta);
    expect(Number(buyerSellAccount.amount)).to.equal(1_000_000_000);

    // Buyer paid less than start price (price has decreased)
    const buyerBuyAfter = await getAccount(connection, buyerBuyAta);
    expect(Number(buyerBuyAfter.amount)).to.be.lessThan(Number(buyerBuyBefore.amount));
});

The cancel test creates a separate auction and verifies tokens return to the seller:

it("cancels an auction and returns tokens", async () => {
    await program.methods
        .cancel()
        .accountsStrict({
            seller: provider.wallet.publicKey,
            sellMint: cancelSellMint,
            auction: cancelAuctionPda,
            auctionSellAta: cancelAuctionSellAta,
            sellerSellAta: cancelSellerSellAta,
            tokenProgram: TOKEN_PROGRAM_ID,
        })
        .rpc();

    const sellerSellAccount = await getAccount(connection, cancelSellerSellAta);
    expect(Number(sellerSellAccount.amount)).to.equal(1_000_000_000);
});

What I Learned

PDAs change everything. With regular accounts, someone holds the private key. With PDAs, the program is the authority. This enables trustless escrow, automated market makers, and any pattern where a program needs to hold and release assets without human intervention.

SPL tokens add complexity. Moving SOL is one CPI call. Moving SPL tokens requires creating token accounts, validating mints, and managing token account ownership. Every participant needs a token account for each mint they interact with. It’s more plumbing, but it’s what real DeFi programs deal with.

Safe math is non-negotiable. The price calculation uses checked_* arithmetic and u128 intermediates to prevent overflow. In a financial program, an arithmetic bug isn’t a 500 error — it’s a loss of funds. The verbosity is the price of correctness.

Account cleanup is part of the design. The program closes both the escrow and the auction account after a buy or cancel. On Solana, accounts cost rent. A program that creates accounts without closing them leaves money locked up. Good programs clean up after themselves.

The Progression So Far

  1. Hello World — Basic program structure
  2. Price Oracle — State management and access control
  3. Piggy Bank — Time logic, CPI, lamport manipulation
  4. Dutch Auction — PDAs, SPL tokens, escrow, price curves

Each exercise has layered new concepts on the previous ones. The Dutch auction combines time-based logic from the piggy bank with the modular architecture from the oracle, and adds PDAs and token operations on top. These are the patterns that production DeFi programs are built from.

The code is on GitHub: anchor-dutch-auction

Jose

Written by Jose

Full-stack developer with 20 years of Rails experience, currently building on Solana.

Follow me on X
Back to blog