WRIT Protocol on X@writnetwork on Twitterx.com/writnetworkwrit.networkWRIT Protocol official X account: @writnetworkFollow WRIT Protocol on X: https://x.com/writnetworkTwitter: @writnetwork · Website: writ.network
docs·WRIT Protocol
docs / integration

Gating a Program

A full worked example: taking an existing Solana AMM and hardening it so only human-backed, permissioned, reputable agents can place large orders. The pattern applies to any protocol — perps, lending, bridges, matchmaking, airdrops.

The scenario

meatball_amm is a Solana AMM. It has been losing inventory to a small number of wallets that pattern-look like MEV bots: high-frequency, high-slippage trades timed to NAV recalculations. The operators want to rate-limit this traffic without banning anonymous users outright.

The plan: require any trade > 10 SOL notional to come from a WRIT-verified agent with a reputation score of at least 2,500 and a delegation that lists meatball_amm in its program whitelist. Smaller trades remain fully anonymous.

1. Add the dependency

programs/meatball_amm/Cargo.tomltoml
[dependencies]
anchor-lang = "1.0.0"
anchor-spl  = "1.0.0"
writ-gate   = { version = "0.4", features = ["cpi"] }

2. Extend the Swap context

programs/meatball_amm/src/instructions/swap.rsrust
use anchor_lang::prelude::*;
use anchor_spl::token::{ Token, TokenAccount };
use writ_gate::{ cpi, program::WritGate, state::AgentStatus };

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

    // --- WRIT accounts, optional when trade ≤ threshold ---
    /// CHECK: gated by writ_gate CPI, not directly deserialized
    pub writ_account:        Option<UncheckedAccount<'info>>,
    pub scope_0:             Option<UncheckedAccount<'info>>,
    pub scope_1:             Option<UncheckedAccount<'info>>,
    pub scope_2:             Option<UncheckedAccount<'info>>,
    pub scope_3:             Option<UncheckedAccount<'info>>,
    pub scope_4:             Option<UncheckedAccount<'info>>,
    pub reputation_account:  Option<UncheckedAccount<'info>>,
    pub sbt_token_account:   Option<UncheckedAccount<'info>>,
    pub writ_gate_program:   Option<Program<'info, WritGate>>,

    // --- your existing pool / token accounts ---
    #[account(mut)] pub pool_token_a: Account<'info, TokenAccount>,
    #[account(mut)] pub pool_token_b: Account<'info, TokenAccount>,
    pub token_program: Program<'info, Token>,
}

3. Branch in the handler

programs/meatball_amm/src/instructions/swap.rsrust
const LARGE_TRADE_LAMPORTS: u64 = 10 * LAMPORTS_PER_SOL;
const MIN_SCORE: u16 = 2500;

pub fn handler(ctx: Context<Swap>, amount_in: u64) -> Result<()> {
    if amount_in >= LARGE_TRADE_LAMPORTS {
        gate(&ctx, amount_in)?;
    }

    // Unconditional path — anonymous users are welcome below the threshold.
    execute_swap(ctx, amount_in)
}

fn gate(ctx: &Context<Swap>, amount_in: u64) -> Result<()> {
    let program = ctx.accounts.writ_gate_program
        .as_ref()
        .ok_or(Err::WritRequired)?;

    let cpi_ctx = CpiContext::new(
        program.to_account_info(),
        cpi::accounts::Verify {
            agent: ctx.accounts.trader.to_account_info(),
            writ_account:       required(&ctx.accounts.writ_account)?,
            scope_0:            required(&ctx.accounts.scope_0)?,
            scope_1:            required(&ctx.accounts.scope_1)?,
            scope_2:            required(&ctx.accounts.scope_2)?,
            scope_3:            required(&ctx.accounts.scope_3)?,
            scope_4:            required(&ctx.accounts.scope_4)?,
            reputation_account: required(&ctx.accounts.reputation_account)?,
            sbt_token_account:  required(&ctx.accounts.sbt_token_account)?,
            clock: Clock::get()?.into(),
        },
    );

    let status: AgentStatus = cpi::verify(cpi_ctx)?.get();

    require!(status.is_valid,                 Err::NotHumanBacked);
    require!(status.has_scope,                Err::NoDelegation);
    require!(status.score >= MIN_SCORE,       Err::LowReputation);
    require!(
        status.scope.allows_program(&crate::ID) &&
        status.scope.allows_action(0) &&      // SWAP
        status.scope.within_budget(amount_in),
        Err::OutOfScope,
    );

    Ok(())
}

4. Client-side

The client conditionally attaches WRIT accounts. If the trader is opting into the gated path, they supply all nine accounts; otherwise they pass null (Anchor's remainingAccounts: []).

app/swap.tstypescript
import { deriveWritAccounts } from "@writnetwork/sdk";

async function swap(trader: PublicKey, amountIn: BN) {
    const gated = amountIn.gte(new BN(10).mul(LAMPORTS_PER_SOL));

    const writAccounts = gated
        ? await deriveWritAccounts(connection, trader)
        : null;

    return meatballAmm.methods
        .swap(amountIn)
        .accounts({
            trader,
            poolTokenA,
            poolTokenB,
            tokenProgram: TOKEN_PROGRAM_ID,
            ...(writAccounts ?? nullishWritAccounts()),
        })
        .rpc();
}

Testing

Anchor's test harness can drive against devnet directly. The SDK ships a set of test writs with known reputation levels for exercising every branch:

Test keypairScoreMeaning
WRIT_TEST_FRESH100new writ, minimal rep
WRIT_TEST_TRUSTED3000passes the LP threshold
WRIT_TEST_MAX9900top reputation tier
WRIT_TEST_NO_DELEGATION5000human-backed but no active scope
WRIT_TEST_EXPIRED5000all 5 scopes expired
TipLoad test keypairs via import { loadTestWrit } from "@writnetwork/sdk/test". They are hardcoded devnet accounts, refreshed nightly by the WRIT team.

Common pitfalls