@goonerlabs/claim-guard
v0.1.1
Published
Drain-resistant claim limiter for airdrops, faucets, and reward pools — cooldowns, per-window caps, and a global pool budget. Zero dependencies, pluggable async store.
Maintainers
Readme
@goonerlabs/claim-guard
Drain-resistant claim limiting for airdrops, faucets, and reward pools — the guardrail that stops a popular drop from being emptied by rapid or scripted claims.
It enforces three independent rules per identity (a wallet, phone number, or user id):
- cooldown — a minimum gap between consecutive claims
- window cap — a maximum number of claims within a rolling window
- pool budget — a global ceiling on how much the whole pool can ever pay out
Zero dependencies. State lives in a pluggable async store (in-memory by default), and the clock is injectable, so the logic is fully testable.
npm install @goonerlabs/claim-guardQuick start
const { createClaimGuard } = require('@goonerlabs/claim-guard');
const guard = createClaimGuard({
cooldownMs: 24 * 60 * 60 * 1000, // one claim per 24h per wallet
maxPerWindow: 3, // ...and no more than 3
windowMs: 24 * 60 * 60 * 1000, // ...in any 24h
pool: { total: 1_000_000 }, // and never pay out more than the pool holds
});
async function handleClaim(wallet, amount) {
const decision = await guard.check(wallet, { amount });
if (!decision.allowed) {
return { ok: false, reason: decision.reason }; // 'cooldown' | 'window_cap' | 'pool_exhausted'
}
await payout(wallet, amount); // do the real on-chain transfer
await guard.record(wallet, { amount }); // only record once the payout succeeded
return { ok: true };
}Use check() + record() when the payout can fail (record only after success). Use claim() when you want the check-and-record done in one step.
API
createClaimGuard(options)
| Option | Meaning |
|---|---|
| cooldownMs | Minimum gap between claims (ms) |
| windowMs + maxPerWindow | At most maxPerWindow claims per rolling windowMs |
| pool.total | Global payout ceiling (use integer base units for tokens) |
| store | Async { get(key), set(key, value) } (default: in-memory) |
| now | Clock function (default: Date.now) — injectable for tests |
guard.check(identity, { amount? }) → Decision
Non-mutating. Returns { allowed: true } or { allowed: false, reason, retryAfterMs? , remaining? }.
guard.record(identity, { amount? })
Persists a claim. Call after the payout actually succeeds.
guard.claim(identity, { amount? }) → Decision
check() then record() in one step, serialized in-process so concurrent claims can't over-draw the pool or exceed a cap. For a pool enforced across multiple processes, back it with a store that has atomic counters or an external lock.
guard.release(identity, { amount? })
Refunds pool budget reserved by a claim() whose payout later failed. No-op when no pool is configured.
guard.poolRemaining() → number
Budget left (Infinity if no pool configured).
Using Redis (or any store)
Implement the two-method store interface:
const guard = createClaimGuard({
cooldownMs: 86_400_000,
store: {
async get(key) { const v = await redis.get(key); return v ? JSON.parse(v) : null; },
async set(key, value) { await redis.set(key, JSON.stringify(value)); },
},
});Testing
npm test # node --testLicense
MIT © Owolabi Adeyemi (goonerlabs)
