skip to content
Skill Issue Dev | Dax the Dev
search
Part of series: Rust Side-Quests

Zera Janitor: Closing Solana Dust Accounts in Leptos WASM

Print view

Sections

Solana has a fee model that punishes inactivity: every account on the network owes a rent deposit proportional to its data size, and most of those accounts are SPL token accounts (165 bytes, ~0.002 SOL of rent each). A wallet that has interacted with a hundred different airdrops and DEX pools accumulates a hundred token accounts holding zero balance. They sit there forever unless you closeAccount them, which costs you the cognitive overhead of figuring out which ones are dust and the gas cost of one transaction per close.

The collective sleeping rent across all dusty Solana wallets is in the tens of millions of dollars. The clean-up tool sees obvious value capture. The catch: cleaning isn’t free. You still need to send the transactions, and naive 1-account-per-tx flows hit the network limit immediately.

That’s the project I shipped on 2026-02-10 in 7aeb309 — Initial implementation of Zera Janitor — a Rust workspace with three crates:

  1. shared/ — common constants (program ID, vault seed, fee BPS).
  2. program/ — on-chain Solana program with one instruction (BatchClean) that closes up to 25 token accounts via CPI in a single tx.
  3. app/ — Leptos 0.7 client-side WASM frontend that scans the wallet, lets you select accounts, and submits batched transactions through a JS shim.

This post is about why each crate looks the way it does — particularly the fee-split economics on-chain and the CSR-WASM-with-JS-shim hybrid for transaction signing.

The on-chain economics

The interesting part of program/src/processor.rs is not the close loop. It’s what happens after:

// 5. Calculate rent collected
let lamports_after = vault.lamports();
let rent_collected = lamports_after
    .checked_sub(lamports_before)
    .ok_or(JanitorError::Overflow)?;

msg!("Rent collected: {} lamports", rent_collected);

// 6. Split: fee to treasury, remainder to user
let fee = rent_collected
    .checked_mul(FEE_BPS)
    .ok_or(JanitorError::Overflow)?
    .checked_div(BPS_DENOMINATOR)
    .ok_or(JanitorError::Overflow)?;

let user_payout = rent_collected
    .checked_sub(fee)
    .ok_or(JanitorError::Overflow)?;

// 7. Direct lamport transfer (vault is program-owned PDA)
**vault.try_borrow_mut_lamports()? -= fee + user_payout;
**treasury.try_borrow_mut_lamports()? += fee;
**user.try_borrow_mut_lamports()? += user_payout;

FEE_BPS = 500 and BPS_DENOMINATOR = 10_000, so the fee is 5% and the user keeps 95%. Each closed account returns ~2,039,280 lamports of rent; if you close 25 in one batch you collect ~51M lamports (~0.051 SOL), the program keeps ~2.5M, and the user gets ~48.5M.

Two things to note:

The user signs once. process_batch_clean walks the remaining accounts slice and assumes everything past the first four (user, vault, treasury, token program) is a token account to close. The CPI is invoke_signed because the vault (program PDA) signs as the destination of each closeAccount. The user only has to authorize the outer transaction, not each individual close. That’s the whole point of the batch.

The fee path is direct lamport math. Lines 7 are doing **vault.try_borrow_mut_lamports()? -= fee + user_payout. This is only legal because the vault is a program-owned PDA, and Solana lets a program directly mutate lamports on accounts it owns. If we tried this on the user’s account we’d panic. If we tried it on the treasury (someone else owns it), the runtime would reject the transaction. The PDA-as-vault pattern is what makes the fee-split possible without a CPI to the system program.

Checked arithmetic everywhere. checked_sub, checked_mul, checked_div instead of -, *, /. On a Solana program, an integer overflow in non-checked arithmetic in release mode wraps silently. Wrapping a fee calculation gives an attacker an arithmetic vector. Every program written for production should use checked_* math even when the values are bounded by a 64-bit balance. The cost is cheap — a few extra CU’s per op — and the alternative is worse.

Why batched at 25?

The Solana transaction size limit is 1232 bytes. Each closeAccount CPI requires the destination’s AccountMeta and the token account’s AccountMeta, plus the inner instruction data. After accounting for the four base accounts (user/vault/treasury/token program) and the outer BatchClean instruction header, you can fit ~25 token accounts per transaction before bumping the byte limit.

The frontend respects this:

const MAX_ACCOUNTS_PER_TX: usize = 25;
let chunks: Vec<Vec<TokenAccountInfo>> = selected_accounts
    .chunks(MAX_ACCOUNTS_PER_TX)
    .map(|c| c.to_vec())
    .collect();

for chunk in &chunks {
    let num = chunk.len() as u8;
    let ix_data = build_batch_clean_data(num);
    // build metas, sign, send
}

If you select 100 dusty accounts in the UI, this fans out to 4 transactions. The user signs each one in their wallet. They all hit the same BatchClean instruction and the same fee-split logic.

The Leptos 0.7 frontend, rendered client-side

Leptos is the Rust SolidJS-style framework — fine-grained reactive primitives, server-or-client rendering, compiles to WASM. For Janitor I went pure CSR (app/Trunk.toml set up for --release-mode WASM bundle), because the only thing the frontend needs to do is:

  1. Connect to a wallet via JS shim.
  2. Scan token accounts via Solana RPC (HTTP, no need for a server).
  3. Build instruction data in pure Rust.
  4. Hand the instruction off to a JS shim for signing.
  5. Display tx status.

There’s no server-side data, no SSR benefits. CSR + WASM keeps the deploy as static files on Cloudflare Pages.

The Leptos contexts are how state is shared:

let wallet = expect_context::<ReadSignal<String>>();
let accounts = expect_context::<ReadSignal<Vec<TokenAccountInfo>>>();
let selected = expect_context::<ReadSignal<Vec<usize>>>();
let set_processing = expect_context::<WriteSignal<bool>>();

If you’ve used SolidJS this is identical: Signal<T> for reactive state, ReadSignal/WriteSignal split, expect_context to pull from a parent. The benefit over JS Solid is that the entire pipeline — RPC parsing, instruction encoding, vault PDA derivation — is in Rust, type-checked, with ? propagation for errors. The Leptos UI code feels like 1:1 SolidJS in JSX-via-macro form.

The JS shim is a load-bearing concession

I really wanted to do this entirely in Rust/WASM, no JS. I couldn’t. The reason:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_name = zeraSignAndSend, catch)]
    async fn zera_sign_and_send(
        instruction_bytes: &[u8],
        account_metas: JsValue,
        blockhash: &str,
        rpc_url: &str,
    ) -> Result<JsValue, JsValue>;
}

This is an FFI into a JS function called zeraSignAndSend defined in the page’s <script>. The shim is the bridge between Rust-built instruction data and the wallet adapter ecosystem (@solana/wallet-adapter-react, Phantom, Solflare, etc.). All those wallets expose JS APIs only. There’s no Phantom-via-WASM API. There’s no Solflare Rust crate. The signing handshake has to go through JS.

The architecture I landed on:

  • Rust builds the instruction (Borsh-encoded BatchClean { num_accounts: u8 }), the account metas (vault PDA, treasury, token program, plus N token accounts), and serializes them as a Uint8Array + JSON.
  • JS shim wraps the Rust-built data into a @solana/web3.js TransactionInstruction, builds a Transaction, gets the wallet to sign it, and submits to the RPC.
  • Rust receives the signature back as a JsValue, downcasts to String, displays in UI.

This is ugly. But it’s correct — the wallet adapter ecosystem is JS, the canonical web3.js library is JS, and forcing all of that to go through wasm-bindgen would be a multi-week engineering project for almost no user-facing benefit. The shim is ~80 lines of JS in a page script.

There’s a future where Solana’s wallet adapter publishes a Rust crate and this shim becomes a single FFI call to a typed signer. We’re not there yet in 2026.

The five files that actually matter

If you want to read the codebase: most of it is glue. The five interesting files:

The whole project is ~1300 lines of Rust + ~250 lines of HTML/CSS. Small project. Real product.

Why “Zera Janitor”

The repo is named SolFetc_rs because it started as a fork of an earlier solfetch repo I’d done with a token-balance scanner. The product, however, is named Zera Janitor — same naming convention as the rest of the Zera ecosystem. The “Zera” prefix is the brand; “Janitor” is the function. The repo path is residual from the dev process.

I leave repo names as they are because renaming a repo breaks every external link, every Vercel deploy hook, every old git remote in someone’s local clone. The cost of a rename is paid for years. The benefit of the rename is “the URL matches the marketing.” Not worth it.

The follow-up commits

The two commits after init are small but real:

The reactive-context bug is one of those things that’s invisible in dev because the runtime is forgiving and explodes on production-WASM because the runtime isn’t. It cost me an hour to track down. If you’re new to fine-grained reactive frameworks, internalize the rule: signals belong inside reactive scopes. That’s the bug 80% of the time.

Trade-offs

Why a 5% fee? Because shipping a tool that returns 100% of the rent gives you no path to operate the program. Validators pay rent, RPC nodes cost money, the program is a non-trivial deployment. 5% is enough to cover the operational cost and disincentivize use of the rent-recovery as a pure fee-arbitrage vector (closing accounts you don’t own to get rent — which is impossible because the user signs as the close authority, but the fee adds margin).

Why Leptos instead of Yew or pure JS? Because Leptos 0.7’s signals API is the closest to the SolidJS ergonomics I wanted. Yew is more React-like and feels heavier for this kind of CSR app. Pure JS would have meant rewriting the instruction-building logic in JS, losing the type guarantees that the program/ crate gives you “for free” by sharing types via the shared/ crate.

Why no Anchor? Because the program is one instruction with no state account. Anchor’s PDA + IDL machinery is overkill. Vanilla solana_program keeps the program 100 lines and the build small.

Why CSR instead of SSR? Because a CSR-WASM bundle deploys to Cloudflare Pages or any static host with no backend. SSR Leptos requires a Rust runtime on the server, which means Render, Fly, or self-hosted — more ops surface for no UX benefit.

What this taught me

A “side quest” Solana program teaches you more about Solana than reading docs for a week. Specifically: the lamport math, the PDA signing model, the transaction size limit, the wallet-adapter shim shape — these are concepts you can read about and then forget, but if you’ve shipped a 100-line program that uses all four, you remember them. The Janitor is the smallest thing I’ve shipped that touches the full stack of “Solana program + JS wallet + Rust frontend,” and that’s why it lives on as a reference for me.

Further reading

Hire me — book a 30-min call $ book →