Skip to content

Back the playground faucet rate limit with the durable gate - #2475

Open
0x-SquidSol wants to merge 1 commit into
dcccrypto:playgroundfrom
0x-SquidSol:fix/playground-faucet-durable-gate
Open

Back the playground faucet rate limit with the durable gate#2475
0x-SquidSol wants to merge 1 commit into
dcccrypto:playgroundfrom
0x-SquidSol:fix/playground-faucet-durable-gate

Conversation

@0x-SquidSol

@0x-SquidSol 0x-SquidSol commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

What

Back the playground faucet's per-wallet rate limit with the durable
faucet_claims gate so it survives serverless cold starts, matching /api/faucet
and /api/auto-fund.

Closes #2474.

Why

/api/playground/faucet gated on a process-local in-memory Map, which resets on
a cold start / a different warm instance — the same wallet could re-claim inside
its 1h window by hitting a fresh instance, each claim minting from the shared mint
authority. The sibling faucets already use the shared faucet_claims table
(tryFaucetGate) and only fall back to in-memory when Supabase is unavailable.

Changes

  • faucet-rate-gate — add a backward-compatible windowMs parameter to
    tryFaucetGate (defaults to the existing 24h; existing callers unchanged).
  • playground/faucet — use the durable insert-as-gate
    (fund_type: "playground-faucet", 1h window) with the in-memory Map retained
    as the fallback, and releaseFaucetClaim on both post-gate failure paths
    (missing signer, mint failure) so an error never locks the wallet — matching
    /api/faucet.
  • regression test — an in-memory gate re-allows a wallet after an instance
    recycle, while a durable gate keyed on (wallet, window) does not.

Testing

  • npx tsc --noEmit — clean.
  • Gate + all tryFaucetGate callers + new test — 10 files, 86 tests, all pass
    (incl. faucet-gate-missing-table exercising tryFaucetGate directly, and
    faucet-route's 53 tests), confirming the new default param is
    backward-compatible.

Notes

  • Frontend + devnet scope only; no program, keeper, or mainnet changes.
  • The 1h window is preserved (not silently widened to 24h) via the new windowMs
    argument.

Summary by CodeRabbit

  • Bug Fixes

    • Improved playground faucet rate limiting so claim restrictions persist across service restarts.
    • Added a one-hour claim window for the playground faucet.
    • Failed mint attempts or missing configuration no longer incorrectly prevent future claims.
    • Added fallback protection when the durable rate-limit service is unavailable.
  • Tests

    • Added regression coverage for rate-limit persistence, expiration, and instance recycling.

…le gate

/api/playground/faucet gated on a process-local in-memory Map, so a serverless
cold start (or landing on a different warm instance) reset it — the same wallet
could re-claim inside its 1h window by hitting a fresh instance. /api/faucet and
/api/auto-fund already back the gate with the shared faucet_claims table
(tryFaucetGate) and only fall back to in-memory when Supabase is unavailable.

Make tryFaucetGate's window a backward-compatible parameter (default 24h) and
have the playground faucet use the durable insert-as-gate with fund_type
"playground-faucet" and a 1h window, keeping the in-memory Map as the fallback.
Release the durable claim slot on every post-gate failure so a config/mint error
does not lock the wallet — matching /api/faucet.

- faucet-rate-gate: optional windowMs param (defaults to the existing 24h)
- playground/faucet: durable gate + in-memory fallback + releaseFaucetClaim on
  the missing-signer and mint-failure paths
- add a regression test: an in-memory gate re-allows after an instance recycle,
  a durable gate keyed on (wallet, window) does not

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@0x-SquidSol
0x-SquidSol requested a review from dcccrypto as a code owner August 4, 2026 23:28
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

@0x-SquidSol is attempting to deploy a commit to the Khubair Nasir's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The playground faucet now uses a durable Supabase per-wallet gate with a one-hour window, an in-memory fallback, and claim release on mint setup or transaction failures. The shared gate accepts custom windows. Regression tests cover instance recycling and expiration.

Changes

Playground faucet gate

Layer / File(s) Summary
Configurable rate-limit window
app/lib/faucet-rate-gate.ts
tryFaucetGate accepts an optional windowMs value. Active and concurrent claims calculate expiration from that value.
Durable faucet route and regression coverage
app/app/api/playground/faucet/route.ts, app/__tests__/api/playground-faucet-durable-gate.test.ts
The route uses Supabase durable claims with an in-memory fallback and releases claims after mint setup or transaction failures. Tests cover recycling and one-hour expiration.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related issues

  • #2471 — The durable gate addresses the playground faucet’s per-wallet rate-limit weakness across instance recycling.
  • #2335 — The changes use durable claim state and fallback gating around faucet mint operations.

Possibly related PRs

Suggested reviewers: dcccrypto

Sequence Diagram(s)

sequenceDiagram
  participant FaucetRoute
  participant SupabaseGate
  participant InMemoryFallback
  participant MintService
  FaucetRoute->>SupabaseGate: Claim wallet slot
  SupabaseGate-->>FaucetRoute: Allow or return rate-limit timestamp
  FaucetRoute->>MintService: Mint USDC and SOL
  MintService-->>FaucetRoute: Return success or failure
  FaucetRoute->>SupabaseGate: Release claim after mint failure
  FaucetRoute->>InMemoryFallback: Gate request when Supabase is unavailable
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: using a durable gate for the playground faucet rate limit.
Linked Issues check ✅ Passed The changes implement the durable one-hour gate, preserve fallback behavior, release failed claims, and add regression coverage for issue #2474.
Out of Scope Changes check ✅ Passed All changes support issue #2474 by updating faucet gating, failure cleanup, and related regression tests.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/__tests__/api/playground-faucet-durable-gate.test.ts`:
- Around line 19-65: The tests currently exercise only local Map-based gate
implementations instead of the production tryFaucetGate and POST flow. Replace
or supplement makeInMemoryGate/makeDurableGate tests with mocked Supabase and
route dependencies, invoke separate route instances, and assert shared durable
claims, the "playground-faucet" fund_type, and RATE_LIMIT_MS. Cover claim
retention on successful and failed mint outcomes and claim release where
required.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 881506db-a756-4244-af2b-6db7ddc07ca8

📥 Commits

Reviewing files that changed from the base of the PR and between f2a3bbe and 5ae1fd4.

📒 Files selected for processing (3)
  • app/__tests__/api/playground-faucet-durable-gate.test.ts
  • app/app/api/playground/faucet/route.ts
  • app/lib/faucet-rate-gate.ts

Comment on lines +19 to +65
// A per-instance in-memory gate (what the route uses today).
function makeInMemoryGate(windowMs: number) {
const store = new Map<string, number>();
return {
allow(wallet: string, now: number): boolean {
const last = store.get(wallet);
if (last !== undefined && now - last < windowMs) return false;
store.set(wallet, now);
return true;
},
};
}

// A durable gate — shared state that survives an instance recycle (models the DB row).
function makeDurableGate(windowMs: number, shared: Map<string, number>) {
return {
allow(wallet: string, now: number): boolean {
const last = shared.get(wallet);
if (last !== undefined && now - last < windowMs) return false;
shared.set(wallet, now);
return true;
},
};
}

const ONE_HOUR = 60 * 60 * 1000;

describe("playground faucet: durable per-wallet gate", () => {
it("in-memory gate lets one wallet re-claim after an instance recycle (the bug)", () => {
const now = 1_000_000;
let gate = makeInMemoryGate(ONE_HOUR);
expect(gate.allow("W", now)).toBe(true); // first claim
expect(gate.allow("W", now + 60_000)).toBe(false); // blocked, same instance
gate = makeInMemoryGate(ONE_HOUR); // <-- cold start / new instance
expect(gate.allow("W", now + 60_000)).toBe(true); // re-claims within the hour
});

it("durable gate denies re-claim within the window even across recycles (the fix)", () => {
const now = 1_000_000;
const shared = new Map<string, number>(); // survives instance recycle
let gate = makeDurableGate(ONE_HOUR, shared);
expect(gate.allow("W", now)).toBe(true);
gate = makeDurableGate(ONE_HOUR, shared); // new instance, same shared store
expect(gate.allow("W", now + 60_000)).toBe(false); // still blocked — 1h window holds
expect(gate.allow("W", now + ONE_HOUR + 1)).toBe(true); // window elapsed → allowed
});
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Test the production gate and route flow.

These tests only compare two local Map implementations. They do not execute tryFaucetGate or POST. A route regression can remove the durable gate, use the wrong fund_type, or pass the wrong window while these tests still pass.

Mock Supabase and route dependencies. Assert that separate route instances share the durable claim, use "playground-faucet" with RATE_LIMIT_MS, and retain or release claims for each mint outcome.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/api/playground-faucet-durable-gate.test.ts` around lines 19 -
65, The tests currently exercise only local Map-based gate implementations
instead of the production tryFaucetGate and POST flow. Replace or supplement
makeInMemoryGate/makeDurableGate tests with mocked Supabase and route
dependencies, invoke separate route instances, and assert shared durable claims,
the "playground-faucet" fund_type, and RATE_LIMIT_MS. Cover claim retention on
successful and failed mint outcomes and claim release where required.

@dcccrypto dcccrypto left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed on head 5ae1fd48. Suite is clean (2921 passed / 0 failed, 282 files),
and the durability change is the right call — threading windowMs through
tryFaucetGate keeps the 1h window while gaining the shared table, and the
fund_type: "playground-faucet" slot correctly avoids colliding with
sol/usdc/auto-fund.

But the gate is now claimed before the work, and only two of the failure paths
release it.
I think this needs a fix before merge.

The release coverage is incomplete

This route's existing design is explicit about not locking a wallet that got
nothing — line 241, unchanged by this PR:

// Record claim AFTER on-chain success — don't lock wallet on partial failure
const nextClaimAt = recordClaim(walletAddress);

The durable gate inverts that: tryFaucetGate inserts the claim row up front,
so the slot is taken from line ~140 onward. The PR releases it in two places
(missing mint signer, mint failure), but the handler's outer catch does not:

} catch (err) {                                    // :281
  Sentry.captureException(err, { ... });
  const msg = err instanceof Error ? err.message : String(err);
  return NextResponse.json({ error: msg || "Internal server error" }, { status: 500 });
}                                                  // no releaseFaucetClaim

And there is unguarded code between the gate and the mint try-block that reaches
it — lines 171–182, outside any inner try:

const mintAuthPk = new PublicKey(mintSigner.publicKey());   // throws on a malformed key
const usdcMint   = new PublicKey(SIM_USDC_MINT);
...
const connection = new Connection(rpcUrl, "confirmed");     // throws on a bad endpoint URL

getDevnetMintSigner() at :153 can also throw rather than return null. Any of
these gives the user a 500, no USDC, no SOL — and a durable claim row that locks
their wallet for the full hour. That is a worse user-facing outcome than the
cold-start bug being fixed, and it's newly introduced here: with the in-memory
Map, recordClaim ran only after on-chain success.

Suggestion: release in one place instead of at each call site — wrap the
post-gate body so every non-success exit releases, e.g. track let claimed = gate.claimId and release in the outer catch (plus any early return that isn't
a successful mint). A try/finally that releases unless a success flag was set
is the smaller diff and can't be missed by a future early-return.

Worth a test for it too: force a throw between the gate and the mint (mock
Connection to throw), assert 500 and that releaseFaucetClaim was called.

The regression test again doesn't bind the fix — fourth in a row

playground-faucet-durable-gate.test.ts imports nothing from the app at all. It
hand-writes both a makeInMemoryGate and a durable model and compares them:

import { describe, it, expect } from "vitest";
// A per-instance in-memory gate (what the route uses today).
function makeInMemoryGate(windowMs: number) { ... }

So it doesn't exercise the route, and unlike #2467/#2470/#2472 it doesn't even
exercise the real helper — tryFaucetGate and its new windowMs parameter, which
is the actual change in lib/faucet-rate-gate.ts, are never called. It would pass
unchanged against playground before this PR.

Running the 30-second check from my #2472 review: delete the fix, run the new
test. Here you don't even need to delete anything — the test never touches it.

This is the fourth PR in a row with the same shape, and the fixes have been right
every time, so the gap is purely in what the tests bind. Concretely for this one:
call tryFaucetGate with a stub Supabase client and assert (a) the 1h windowMs
is honoured rather than the 24h default, and (b) the route denies a second claim
for the same wallet across two separate handler invocations.

To be clear on the overall verdict: the durability fix is correct and worth
landing — it's the release path I'd want closed first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants