Back the playground faucet rate limit with the durable gate - #2475
Back the playground faucet rate limit with the durable gate#24750x-SquidSol wants to merge 1 commit into
Conversation
…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 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. |
📝 WalkthroughWalkthroughThe 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. ChangesPlayground faucet gate
Estimated code review effort: 3 (Moderate) | ~20 minutes Possibly related issues
Possibly related PRs
Suggested reviewers: 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
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
app/__tests__/api/playground-faucet-durable-gate.test.tsapp/app/api/playground/faucet/route.tsapp/lib/faucet-rate-gate.ts
| // 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 | ||
| }); | ||
| }); |
There was a problem hiding this comment.
📐 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
left a comment
There was a problem hiding this comment.
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 releaseFaucetClaimAnd 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 URLgetDevnetMintSigner() 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.
What
Back the playground faucet's per-wallet rate limit with the durable
faucet_claimsgate so it survives serverless cold starts, matching/api/faucetand
/api/auto-fund.Closes #2474.
Why
/api/playground/faucetgated on a process-local in-memoryMap, which resets ona 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_claimstable(
tryFaucetGate) and only fall back to in-memory when Supabase is unavailable.Changes
faucet-rate-gate— add a backward-compatiblewindowMsparameter totryFaucetGate(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-memoryMapretainedas the fallback, and
releaseFaucetClaimon both post-gate failure paths(missing signer, mint failure) so an error never locks the wallet — matching
/api/faucet.recycle, while a durable gate keyed on (wallet, window) does not.
Testing
npx tsc --noEmit— clean.tryFaucetGatecallers + new test — 10 files, 86 tests, all pass(incl.
faucet-gate-missing-tableexercisingtryFaucetGatedirectly, andfaucet-route's 53 tests), confirming the new default param isbackward-compatible.
Notes
windowMsargument.
Summary by CodeRabbit
Bug Fixes
Tests