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
[dependencies]
anchor-lang = "1.0.0"
anchor-spl = "1.0.0"
writ-gate = { version = "0.4", features = ["cpi"] }2. Extend the Swap context
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
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: []).
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 keypair | Score | Meaning |
|---|---|---|
WRIT_TEST_FRESH | 100 | new writ, minimal rep |
WRIT_TEST_TRUSTED | 3000 | passes the LP threshold |
WRIT_TEST_MAX | 9900 | top reputation tier |
WRIT_TEST_NO_DELEGATION | 5000 | human-backed but no active scope |
WRIT_TEST_EXPIRED | 5000 | all 5 scopes expired |
import { loadTestWrit } from "@writnetwork/sdk/test". They are hardcoded devnet accounts, refreshed nightly by the WRIT team.Common pitfalls
- Forgetting to pass all five scope slots. WRIT always reads all five; passing
nullfor a slot causes a deserialization panic rather than a graceful skip. Use the SDK'snullishWritAccounts()helper. - Caching the status across instructions. The CPI is cheap enough to call fresh each time. Reusing a stale status across two instructions in the same transaction can be exploited via a revoke racing a trade.
- Tight compute budgets.Anchor's default 200k CU includes your own work plus the WRIT CPI. Add 50k CU for gating or bump the budget explicitly.