DEV Community

Deek Roumy
Deek Roumy

Posted on

The Ranger Hackathon: Building an AI-Powered USDC Yield Vault on Solana (From Zero to Submission)

Ranger Finance recently announced the Build-A-Bear hackathon: build a USDC yield vault using their Voltr SDK, compete for a $1M TVL prize pool, and get your vault listed on the Ranger platform. The pitch is clean — you write the allocation logic, Ranger handles the vault infrastructure, and users deposit USDC into your vault.

I built one. Here's the technical breakdown.


What Ranger's Build-A-Bear Actually Is

Ranger Finance operates a yield aggregator where vaults allocate deposited USDC across Solana lending protocols. The Voltr SDK gives you programmatic control over which strategies (Kamino, Drift, MarginFi, etc.) receive capital and in what proportions.

The hackathon criteria boil down to:

  • TVL — how much USDC your vault attracts
  • APY — the yield you deliver to depositors
  • Risk management — how you handle protocol-level risk

The boring approach: equal-weight allocation across three protocols. The interesting approach: use an AI to dynamically decide where capital goes based on live market data.

I chose the interesting approach.


Architecture: Three Protocols, One AI

The vault allocates USDC across three Solana lending markets:

  • Kamino Finance — the largest Solana USDC market (~$80M TVL, typically 8-9% APY)
  • Drift Protocol — spot lending with solid utilization (~$30M TVL, 7-8% APY)
  • Jupiter Lend — younger market, lower TVL but competitive rates (~$20M, 6-7% APY)

The AI allocator receives real-time data from each protocol and returns allocation weights in basis points (BPS, summing to 10,000).

The APY Fetcher

Each protocol has a public API. The fetcher pulls live data with a 5-second timeout and falls back to historical estimates:

async function fetchKaminoUsdc(): Promise<ProtocolData | null> {
  try {
    const resp = await fetch(
      `${KAMINO_API}/v2/strategies?env=mainnet-beta&status=LIVE`,
      { signal: AbortSignal.timeout(5000) }
    );
    const strategies = await resp.json();
    const usdcStrats = strategies.filter(
      (s) => s.tokenMintA === USDC_MINT || s.tokenMintB === USDC_MINT
    );
    const best = usdcStrats.sort((a, b) => (b.tvl ?? 0) - (a.tvl ?? 0))[0];
    return {
      name: "Kamino USDC Main Market",
      currentApy: (best.apy ?? 0) * 100,
      utilizationRate: 0.82,
      tvlUsd: best.tvl ?? 80_000_000,
      protocol_health_score: 90,
      last_incident_days_ago: 999,
      // ...
    };
  } catch {
    // Return hardcoded fallback with historical values
    return { name: "Kamino USDC Main Market", currentApy: 8.5, ... };
  }
}
Enter fullscreen mode Exit fullscreen mode

The ProtocolData interface carries everything the AI needs to reason about risk vs. return: health score, utilization rate, days since last incident, available liquidity.


The AI Allocator

The core idea: send the protocol data to a local LLM and ask it to return a JSON allocation. No cloud APIs, no latency, no cost per inference.

export async function aiWeightedAllocation(
  protocols: ProtocolData[]
): Promise<AllocationResult> {
  const prompt = buildAllocationPrompt(protocols);

  try {
    const raw = await callOllama(prompt);  // local qwen2.5:14b via Ollama
    return parseAllocationResponse(raw, protocols);
  } catch {
    logger.warn("AI failed, using APY-greedy fallback");
    return apyGreedyAllocation(protocols, true);
  }
}

async function callOllama(prompt: string): Promise<string> {
  const response = await fetch("http://localhost:11434/api/generate", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: cfg.OLLAMA_MODEL,  // "qwen2.5:14b"
      prompt,
      stream: false,
      options: { temperature: 0.1 },  // low temp = more deterministic
    }),
  });
  const data = await response.json();
  return data.response;
}
Enter fullscreen mode Exit fullscreen mode

The response parser validates the output strictly — it rejects any allocation below 500 BPS (5%) or above 8000 BPS (80%), and it normalizes the total to exactly 10,000:

function parseAllocationResponse(text: string, protocols: ProtocolData[]): AllocationResult {
  const jsonMatch = text.match(/\{[\s\S]*\}/);
  if (!jsonMatch) throw new Error("No JSON in AI response");

  const parsed = JSON.parse(jsonMatch[0]);

  // Enforce minimum diversification: 5% floor per protocol
  for (const name of protocols.map(p => p.name)) {
    const bps = parsed.allocations[name];
    if (bps < 500 || bps > 8000) throw new Error(`Invalid BPS ${bps}`);
  }

  // Normalize to exactly 10000 BPS
  const total = Object.values(parsed.allocations).reduce((a, b) => a + b, 0);
  const correction = 10000 - total;
  if (correction !== 0) {
    const top = Object.entries(parsed.allocations).sort((a, b) => b[1] - a[1])[0][0];
    parsed.allocations[top] += correction;
  }

  return { allocations: parsed.allocations, reasoning: parsed.reasoning, ... };
}
Enter fullscreen mode Exit fullscreen mode

If Ollama is down or the output can't be parsed, the bot falls back to a risk-adjusted greedy allocation that scores protocols by APY * health_score, penalizing recent incidents and extreme utilization rates.


The Rebalance Loop

The bot runs on a configurable interval (default: 30 minutes) and only executes transactions when the delta exceeds 2% to avoid unnecessary churn:

private async doRebalance() {
  const protocols = await fetchAllProtocolData();
  let allocation: AllocationResult;

  try {
    allocation = await aiWeightedAllocation(protocols);
  } catch {
    allocation = apyGreedyAllocation(protocols, true);  // deterministic fallback
  }

  const REBALANCE_THRESHOLD_BPS = 200;  // 2% minimum drift

  // Withdrawals first (free up capital), then deposits
  const txPlan: TxStep[] = [];
  for (const [name, targetBps] of Object.entries(allocation.allocations)) {
    const currentBps = getCurrentBps(name, currentPositions, totalValue);
    const deltaBps = targetBps - currentBps;

    if (Math.abs(deltaBps) < REBALANCE_THRESHOLD_BPS) continue;

    if (deltaBps < 0) txPlan.push({ type: "withdraw", name, deltaBps });
    else txPlan.push({ type: "deposit", name, deltaBps });
  }

  // Sort: withdrawals before deposits
  txPlan.sort((a, b) => (a.type === "withdraw" ? -1 : 1));

  for (const step of txPlan) {
    if (this.isDryRun) { logger.info(step, "[DRY-RUN] Would execute"); continue; }
    // Execute via Voltr SDK
    await this.client.createDepositStrategyIx(...) // or withdrawStrategyIx
  }
}
Enter fullscreen mode Exit fullscreen mode

Dry-run mode (DRY_RUN=true pnpm run bot) logs the full transaction plan without touching the chain — useful for testing allocation logic before committing real USDC.


Simulation Results

Before going live, I ran the simulator against $100k and $1M TVL scenarios. The AI (qwen2.5:14b running locally) made this call with the live APY data:

Protocol                      APY      Current  →  Target
──────────────────────────────────────────────────────────
Kamino USDC Main Market       8.50%    33.33%   →  50.00%  ⬆ +16.67%
Drift USDC Spot Lending       7.20%    33.33%   →  30.00%  ⬇ -3.33%
Jupiter Lend USDC             6.50%    33.33%   →  20.00%  ⬇ -13.33%

Naive equal-weight APY:  7.40%
AI-optimized APY:        7.71%  (+0.31%)
Annual gain on $100k:    +$310
Annual gain on $1M:      +$3,100
Enter fullscreen mode Exit fullscreen mode

The AI's reasoning (condensed): "Overweight Kamino for highest APY and healthy utilization at 82%. Drift remains in portfolio for diversification. Reduce Jupiter exposure — lower TVL implies less liquidity buffer for large deposits."

Not groundbreaking analysis, but it's running autonomously on every rebalance cycle without any manual intervention.


What I'd Do Differently

On the AI side: The current prompt is stateless — the LLM has no memory of previous allocations. Adding a brief history of recent moves would let it smooth allocations instead of oscillating. Also, a 14B parameter model is overkill for structured JSON generation; a smaller 7B model would cut inference time in half with comparable output quality.

On the Solana side: Getting the Kamino reserve address right required digging through their docs and on-chain data. The placeholder in strategies.json works for simulation but needs the actual pubkey for mainnet. Testing on devnet with USDC faucet tokens before mainnet is non-negotiable.

On the architecture side: The Voltr SDK abstracts a lot of the Solana complexity, but you still need to handle transaction confirmation properly — confirmTransaction with commitment: "confirmed" isn't the same as "finalized", and rebalancing before the previous tx finalizes can corrupt your position tracking.


Current Status

The code compiles and the simulation runs end-to-end. The pnpm run demo script works without any keypairs — it fetches live APYs, runs the AI, and shows what the vault would do. Actual on-chain deployment requires a funded keypair and a Helius RPC endpoint, which is the remaining step before submission.

The full source is at github.com/DeekRoumy/ranger-vault.

Deadline: April 6, 2026. If you're building something similar, the Ranger Discord has a #build-a-bear channel — worth checking before writing any vault initialization code since the SDK has changed recently.


Tags: solana, defi, typescript, hackathon, ai

Top comments (0)