Skip to content

Commit 4992d40

Browse files
author
CloudLobster
committed
feat($ATTN): claim-based drip — use it or lose it
- Remove auto-drip from cron (no more passive accumulation) - Add POST /api/attn/claim — manual daily claim, no backfill - Balance API: return can_claim + next_claim_in_seconds - Frontend: pulsing Claim button replaces 'Next drip' timer - Scarcity design: miss a day = lose that day's ATTN forever
1 parent 6404a2d commit 4992d40

File tree

3 files changed

+113
-37
lines changed

3 files changed

+113
-37
lines changed

web/src/pages/Dashboard.tsx

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3589,6 +3589,7 @@ function AttnDashboard({ auth }: { auth: AuthState }) {
35893589
const [loading, setLoading] = useState(true);
35903590
const [priceInput, setPriceInput] = useState(1);
35913591
const [saving, setSaving] = useState(false);
3592+
const [claiming, setClaiming] = useState(false);
35923593

35933594
useEffect(() => {
35943595
if (!auth?.token) return;
@@ -3617,12 +3618,36 @@ function AttnDashboard({ auth }: { auth: AuthState }) {
36173618
setSaving(false);
36183619
}
36193620

3621+
async function claimDrip() {
3622+
setClaiming(true);
3623+
try {
3624+
const res = await apiFetch('/api/attn/claim', auth.token, { method: 'POST' });
3625+
const data = await res.json();
3626+
if (data.claimed) {
3627+
setBalance((prev: any) => prev ? {
3628+
...prev,
3629+
balance: data.balance,
3630+
can_claim: false,
3631+
next_claim_in_seconds: data.next_claim_in_seconds,
3632+
} : prev);
3633+
} else if (data.reason === 'already_claimed') {
3634+
setBalance((prev: any) => prev ? {
3635+
...prev,
3636+
can_claim: false,
3637+
next_claim_in_seconds: data.next_claim_in_seconds,
3638+
} : prev);
3639+
}
3640+
} catch {}
3641+
setClaiming(false);
3642+
}
3643+
36203644
if (loading) return <div className="text-gray-500 text-center py-20">Loading...</div>;
36213645

36223646
const TYPE_LABELS: Record<string, { label: string; color: string }> = {
36233647
signup_grant: { label: '🎁 Welcome Grant', color: 'text-green-400' },
36243648
drip: { label: '💧 Daily Drip', color: 'text-blue-400' },
36253649
drip_batch: { label: '💧 Daily Drip (system)', color: 'text-blue-400' },
3650+
drip_claim: { label: '💧 Daily Claim', color: 'text-blue-400' },
36263651
stake: { label: '📤 Staked', color: 'text-amber-400' },
36273652
refund: { label: '✅ Refunded', color: 'text-green-400' },
36283653
reply_bonus: { label: '🎉 Reply Bonus', color: 'text-purple-400' },
@@ -3647,12 +3672,22 @@ function AttnDashboard({ auth }: { auth: AuthState }) {
36473672
<div className="text-right">
36483673
<div className="text-xs text-gray-500">Daily earned</div>
36493674
<div className="text-sm text-gray-300">{balance?.daily_earned ?? 0} / {balance?.daily_earn_cap ?? 200}</div>
3650-
<div className="text-xs text-gray-500 mt-2">Next drip</div>
3651-
<div className="text-sm text-gray-300">
3652-
{balance?.next_drip_in_seconds > 0
3653-
? `${Math.floor(balance.next_drip_in_seconds / 3600)}h ${Math.floor((balance.next_drip_in_seconds % 3600) / 60)}m`
3654-
: 'Available now'}
3655-
</div>
3675+
<div className="text-xs text-gray-500 mt-2">Daily Drip</div>
3676+
{balance?.can_claim ? (
3677+
<button
3678+
onClick={claimDrip}
3679+
disabled={claiming}
3680+
className="mt-1 bg-purple-600 text-white text-sm px-4 py-1.5 rounded-lg hover:bg-purple-500 disabled:opacity-50 transition font-medium animate-pulse"
3681+
>
3682+
{claiming ? 'Claiming...' : `💧 Claim +${balance?.constants?.daily_drip ?? 10} ATTN`}
3683+
</button>
3684+
) : (
3685+
<div className="text-sm text-gray-500">
3686+
Next claim in {balance?.next_claim_in_seconds > 0
3687+
? `${Math.floor(balance.next_claim_in_seconds / 3600)}h ${Math.floor((balance.next_claim_in_seconds % 3600) / 60)}m`
3688+
: '—'}
3689+
</div>
3690+
)}
36563691
</div>
36573692
</div>
36583693

worker/src/cron.ts

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -26,34 +26,9 @@ export async function handleCron(
2626
'UPDATE attn_balances SET daily_earned = 0, last_earn_reset = ? WHERE last_earn_reset < ?'
2727
).bind(now, oneDayAgo).run();
2828

29-
// ── 2. Daily drip (+10 ATTN, respects daily earn cap) ──
30-
// Only drip to accounts that haven't received drip in 24h
31-
// and whose daily_earned + drip is within cap
32-
const dripResult = await env.DB.prepare(`
33-
UPDATE attn_balances
34-
SET balance = balance + ?,
35-
daily_earned = daily_earned + ?,
36-
last_drip_at = ?
37-
WHERE last_drip_at < ?
38-
AND daily_earned + ? <= ?
39-
`).bind(
40-
ATTN.DAILY_DRIP, ATTN.DAILY_DRIP, now,
41-
oneDayAgo, ATTN.DAILY_DRIP, ATTN.DAILY_EARN_CAP,
42-
).run();
43-
44-
// Log drip transactions (batch — one per account would be too many writes)
45-
// Only log aggregate for now
46-
const drippedCount = dripResult.meta?.changes || 0;
47-
if (drippedCount > 0) {
48-
await env.DB.prepare(
49-
'INSERT INTO attn_transactions (id, wallet, amount, type, note, created_at) VALUES (?, \'system\', ?, \'drip_batch\', ?, ?)'
50-
).bind(
51-
`cron-drip-${now}`,
52-
ATTN.DAILY_DRIP,
53-
`Daily drip: ${drippedCount} accounts received ${ATTN.DAILY_DRIP} ATTN each`,
54-
now,
55-
).run();
56-
}
29+
// ── 2. Daily drip — REMOVED (now claim-based) ──
30+
// Users must manually claim their daily ATTN via POST /api/attn/claim.
31+
// Unclaimed drips do NOT accumulate — use it or lose it.
5732

5833
// ── 3. Settle expired escrows ──
5934
const expired = await env.DB.prepare(

worker/src/routes/attn.ts

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ attnRoutes.get('/balance', async (c) => {
115115
}
116116

117117
const now = Math.floor(Date.now() / 1000);
118-
const nextDrip = bal.last_drip_at + 86400;
118+
const oneDayAgo = now - 86400;
119+
const canClaim = bal.last_drip_at < oneDayAgo;
120+
const nextClaimAt = bal.last_drip_at + 86400;
119121
const dailyEarnRemaining = Math.max(0, ATTN.DAILY_EARN_CAP - bal.daily_earned);
120122

121123
return c.json({
@@ -124,8 +126,9 @@ attnRoutes.get('/balance', async (c) => {
124126
daily_earned: bal.daily_earned,
125127
daily_earn_cap: ATTN.DAILY_EARN_CAP,
126128
daily_earn_remaining: dailyEarnRemaining,
127-
next_drip_at: nextDrip,
128-
next_drip_in_seconds: Math.max(0, nextDrip - now),
129+
can_claim: canClaim,
130+
next_claim_at: canClaim ? null : nextClaimAt,
131+
next_claim_in_seconds: canClaim ? 0 : Math.max(0, nextClaimAt - now),
129132
constants: {
130133
daily_drip: ATTN.DAILY_DRIP,
131134
cold_stake: ATTN.COLD_STAKE,
@@ -290,6 +293,69 @@ attnRoutes.put('/settings', async (c) => {
290293
return c.json({ success: true, receive_price });
291294
});
292295

296+
// ══════════════════════════════════════════════
297+
// POST /api/attn/claim — Claim daily drip (manual, no accumulation)
298+
// ══════════════════════════════════════════════
299+
300+
attnRoutes.post('/claim', async (c) => {
301+
const auth = c.get('auth');
302+
if (!auth.handle) return c.json({ error: 'Not registered' }, 403);
303+
304+
const w = auth.wallet.toLowerCase();
305+
const now = Math.floor(Date.now() / 1000);
306+
const oneDayAgo = now - 86400;
307+
308+
// Get current balance row
309+
const bal = await c.env.DB.prepare(
310+
'SELECT balance, daily_earned, last_drip_at FROM attn_balances WHERE wallet = ?'
311+
).bind(w).first<{ balance: number; daily_earned: number; last_drip_at: number }>();
312+
313+
if (!bal) return c.json({ error: 'No ATTN account found' }, 404);
314+
315+
// Already claimed within 24h?
316+
if (bal.last_drip_at >= oneDayAgo) {
317+
const nextClaimAt = bal.last_drip_at + 86400;
318+
return c.json({
319+
claimed: false,
320+
reason: 'already_claimed',
321+
next_claim_at: nextClaimAt,
322+
next_claim_in_seconds: Math.max(0, nextClaimAt - now),
323+
balance: bal.balance,
324+
});
325+
}
326+
327+
// Check daily earn cap
328+
if (bal.daily_earned + ATTN.DAILY_DRIP > ATTN.DAILY_EARN_CAP) {
329+
return c.json({
330+
claimed: false,
331+
reason: 'daily_cap_reached',
332+
balance: bal.balance,
333+
});
334+
}
335+
336+
// Claim! No accumulation — always exactly DAILY_DRIP regardless of days missed
337+
const newBalance = bal.balance + ATTN.DAILY_DRIP;
338+
await c.env.DB.batch([
339+
c.env.DB.prepare(
340+
'UPDATE attn_balances SET balance = ?, daily_earned = daily_earned + ?, last_drip_at = ? WHERE wallet = ?'
341+
).bind(newBalance, ATTN.DAILY_DRIP, now, w),
342+
c.env.DB.prepare(
343+
'INSERT INTO attn_transactions (id, wallet, amount, type, note, created_at) VALUES (?, ?, ?, \'drip_claim\', ?, ?)'
344+
).bind(
345+
`claim-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
346+
w, ATTN.DAILY_DRIP, 'Daily drip claimed', now,
347+
),
348+
]);
349+
350+
return c.json({
351+
claimed: true,
352+
amount: ATTN.DAILY_DRIP,
353+
balance: newBalance,
354+
next_claim_at: now + 86400,
355+
next_claim_in_seconds: 86400,
356+
});
357+
});
358+
293359
// ══════════════════════════════════════════════
294360
// Helpers (exported for use by send.ts, inbox.ts, cron.ts)
295361
// ══════════════════════════════════════════════

0 commit comments

Comments
 (0)