Next.js tabanlı, 5 adımlı raffle/giveaway başvuru sistemi. Sosyal medya
adımları + on-chain gm doğrulamasıyla başvurular toplanır; doğrulanmış
girişler imzalı olarak Discord webhook'una gönderilir, aynı kişinin tekrar
girmesi Redis ile engellenir.
- Akış
- Özet teknik yığın
- Dosya yapısı
- Lokal geliştirme
- Deploy mimarisi
- Ortam değişkenleri
- Konfigürasyon —
src/lib/config.ts - gm doğrulama mantığı
- Günlük sıfırlama kuralı
- Dedup (tekrar başvuru engelleme)
- Discord webhook ve imza doğrulama
- Health endpoint —
/api/health - Sık karşılaşılan sorunlar
- Nasıl genişletilir / değiştirilir
Kullanıcı siteye girer ve 5 adımı sırayla tamamlar:
- Follow OnChainGM on X — link açar, birkaç saniye sonra otomatik ✓
- Follow Blobsters on X — link açar, otomatik ✓
- Like the tweet — link açar, otomatik ✓
- Comment on the tweet — link açar, otomatik ✓
- Send gm on Ethereum (today) — cüzdan bağlanır,
CHECKbutonuna basılır → kontratta bugün gm atılmış mı kontrol edilir
5/5 olduğunda alttaki form aktive olur. Kullanıcı X kullanıcı adını girer
(cüzdan adresi otomatik dolu gelir), Submit'e basar. Cüzdan kısa bir
sahiplik mesajı imzalar (gas yok). Sunucu imzayı doğrular, dedup'ı kontrol
eder, sonra Discord kanalına embed mesaj olarak düşürür.
| Katman | Teknoloji |
|---|---|
| Framework | Next.js 14 (App Router) |
| Diller | TypeScript, React 18 |
| Stil | Tailwind CSS |
| Cüzdan / on-chain | wagmi v2 + viem (Ethereum mainnet, injected connector) |
| State | React state + localStorage (cüzdan-başına ilerleme) |
| Dedup veritabanı | Upstash Redis (REST API, SDK yok) |
| Bildirim | Discord webhook (server-side fetch) |
| Hosting | Vercel (Hobby plan yeter) |
.
├── README.md # Bu dosya
├── package.json
├── next.config.js
├── tailwind.config.ts
├── postcss.config.js
├── tsconfig.json
├── .env.example # Ortam değişkenleri şablonu
├── .env.local # (gitignored) — gerçek değerler
└── src/
├── app/
│ ├── layout.tsx # Kök layout
│ ├── providers.tsx # WagmiProvider + QueryClient
│ ├── page.tsx # Anasayfa (navbar + StepsCard)
│ ├── globals.css # Tailwind + özel stiller
│ └── api/
│ ├── submit/route.ts # POST /api/submit (imza + Discord + dedup)
│ └── health/route.ts # GET /api/health (teşhis)
├── components/
│ ├── ConnectButton.tsx # Wallet connect/disconnect butonu
│ ├── ExternalIcon.tsx # SVG ikonlar
│ └── StepsCard.tsx # 5 adım UI + form (ana komponent)
└── lib/
├── config.ts # Tüm linkler + gm kontrat adresi
├── wagmi.ts # Wagmi config (mainnet + fallback RPC'ler)
├── gm.ts # gm doğrulama (auto check + tx hash)
├── progress.ts # localStorage ilerleme ve submission tipleri
├── submission.ts # İmza mesaj formatı + TTL
└── redis.ts # Upstash REST wrapper
# Bağımlılıklar
npm install
# Ortam değişkenlerini hazırla
cp .env.example .env.local
# .env.local'ı aç ve değerleri doldur (aşağıdaki bölüme bak)
# Dev server
npm run dev
# → http://localhost:3000Ortam değişkenleri sadece sunucu başladığında okunuyor.
.env.local'ı her değiştirdiğinde dev server'ı durdurup tekrar başlat.
Frontend statik olarak cPanel'e, API Vercel Functions'a deploy edilir. İkisi de aynı repo'dan üretilir.
Statik frontend (out/) ──► cPanel (public_html)
↓ fetch
API + dedup + Discord ──► Vercel (Functions)
↓
Upstash Redis + Discord webhook
cPanel sadece HTML/CSS/JS serve eder. Bütün gizli işler (Discord webhook, Redis token, imza doğrulama) Vercel tarafındadır. Frontend → API çağrısı cross-origin olduğu için API tarafında CORS başlıkları gönderilir.
- Repo'yu GitHub'a push'la.
- https://vercel.com → Add New → Project → repo'yu seç → Deploy.
- Settings → Environments → Production + Preview için şu değişkenleri ekle:
DISCORD_WEBHOOK_URLCORS_ORIGIN(cPanel'deki domain'in tam adresi, örn.https://yourdomain.com. Test sırasında*da bırakabilirsin ama yayında pin'le.)
- Storage → Marketplace → Upstash → Redis seç → projeye bağla
(Custom Prefix:
UPSTASH_REDIS). - Deployments → en üstteki → ⋯ → Redeploy.
- Vercel sana bir URL verir, örn.
https://raffel-xyz.vercel.app.https://.../api/healthadresini ziyaret edip konfigürasyonu doğrula.
Proje kökündeki .env.local'a Vercel API URL'ini ekle:
NEXT_PUBLIC_API_BASE_URL=https://raffel-xyz.vercel.app
Sonra:
npm install
npm run build:staticKomut sırasıyla:
src/app/api/klasörünü güvenli şekilde geçici olarak_api.disabledaltına taşır (Next.jsoutput: 'export'API route'larla aynı anda çalışmıyor).STATIC_EXPORT=1ilenext buildçalıştırır.out/klasörünü üretir vesrc/app/api/klasörünü geri taşır.- Süreç yarıda kalsa bile (CTRL+C, hata) klasör otomatik restore edilir.
- cPanel → File Manager →
public_html/'i aç. - Eski dosyaları yedekle.
out/klasörünün içeriğini (klasörün kendisini değil)public_html/içine yükle.- Domain'i tarayıcıda aç. Submit'te tx Vercel API'sine gidip Discord'a düşmeli.
Apache
.htaccessile özel rewrite gerektirmez;trailingSlash: truesayesinde her sayfaindex.htmlüzerinden serve edilir.
Hepsi server-side. Tarayıcıya hiçbiri gönderilmez.
| Anahtar | Zorunlu | Açıklama |
|---|---|---|
DISCORD_WEBHOOK_URL |
Evet | Submit'lerin düşeceği Discord kanalının webhook URL'i. Discord → Server Settings → Integrations → Webhooks → New Webhook → Copy URL. |
UPSTASH_REDIS_REST_URL |
Önerilen | Upstash Redis REST endpoint. Tanımsızsa dedup atlanır. |
UPSTASH_REDIS_REST_TOKEN |
Önerilen | Upstash Redis REST token. |
CORS_ORIGIN |
Önerilen | Vercel API'nin CORS izin verdiği origin. cPanel domain'in (örn. https://yourdomain.com). Tanımsızsa *. |
NEXT_PUBLIC_API_BASE_URL |
Statik build için zorunlu | npm run build:static çalıştırırken kullanılır. Frontend bu URL'e fetch atar. Örn. https://raffel-xyz.vercel.app. Trailing slash yok. |
Alias desteği: src/lib/redis.ts aşağıdaki isimleri de tanır
(Vercel Marketplace farklı prefix verirse otomatik bulur):
UPSTASH_REDIS_KV_REST_API_URL/UPSTASH_REDIS_KV_REST_API_TOKENKV_REST_API_URL/KV_REST_API_TOKENSTORAGE_KV_REST_API_URL/STORAGE_KV_REST_API_TOKENSTORAGE_REST_URL/STORAGE_REST_TOKEN
Tüm public linkler ve gm kontrat adresi tek dosyada. Düzenlemek için:
import { type Address } from "viem";
// Doğrulanacak on-chain gm kontratı (Ethereum mainnet)
export const GM_CONTRACT: Address = "0xcd21a60fb9f981dc1274f15ecaa250941edabd4e";
export const LINKS = {
followOnchainGm: "https://x.com/onchaingm",
followBlobsters: "https://x.com/blobsters",
likeTweet: "https://x.com/onchaingm/status/PLACEHOLDER_TWEET_ID",
commentTweet: "https://x.com/onchaingm/status/PLACEHOLDER_TWEET_ID",
gmDapp: "https://www.onchaingm.com/",
};
PLACEHOLDER_TWEET_ID'leri gerçek tweet URL'leriyle değiştir. Blobsters için doğru X handle'ını da düzelt.
Kontratı/zinciri değiştirmek istersen ayrıca src/lib/wagmi.ts (chain) ve
src/app/api/submit/route.ts (verifyClient için aynı chain) dosyalarını da
güncelle.
Tamamı src/lib/gm.ts. İki bağımsız yol var; biri başarısız olursa diğeri
devreye giriyor.
Sırayla view fonksiyonlarını denerek bir "son gm timestamp"i bulmaya çalışır:
lastGm, lastGM, lastSaidGm, lastGmAt, lastGmTime, gmTime, userLastGm, mintedAt
Hangisi başarılı sonuç dönerse, dönen değer bugünün UTC 00:00 saniye timestamp'inden büyük veya eşit olmalı. Aksi halde "bugünkü gm yok" sayar.
View fonksiyonu yoksa event log taraması devreye girer:
- Son ~14 400 blok (~2 gün) taranır
- Kontratın emit ettiği loglarda kullanıcının adresi
topicsveyadataiçinde aranır (indexed olmayan adresleri yakalamak için ikisi de) - Eşleşen log'un block'unun
timestamp'i alınır, bugün UTC içinde mi kontrol edilir
Auto-check bulamazsa, gm satırının altındaki kutuya tx hash yapıştırılır.
Sunucu/istemci eth_getTransactionReceipt ile şunları doğrular:
receipt.status === "success"(revert değil)receipt.to === GM_CONTRACT(gerçekten gm kontratı)receipt.from === bağlı cüzdan(başkasının tx'i değil)block.timestamp >= bugün UTC 00:00(taze)
Dördü de tutarsa adım onaylanır.
src/lib/wagmi.ts viem'in fallback transport'u ile şu RPC'leri sırayla
dener (biri rate-limit'lerse diğeri devreye girer):
https://eth.llamarpc.com
https://ethereum-rpc.publicnode.com
https://rpc.ankr.com/eth
https://cloudflare-eth.com
- gm doğrulaması yalnızca bugün UTC 00:00'dan sonra atılmış işlemleri kabul eder.
- Dünden kalan gm reddedilir — kullanıcıya net hata gösterilir
(
"Tx is from <ISO timestamp>, before today 00:00 UTC. Send a fresh gm today."). - UI'da 5. adımın altyazısı:
"Send a fresh gm today — resets daily at 00:00 UTC".
Saat dilimi: Blockchain timestamp'leri UTC olduğu için UTC referans
alındı. Türkiye saatiyle UTC+3 fark var (yerelde sabah 03:00'te yeni gün
başlar). Yerel saate çevirmek için src/lib/gm.ts içindeki
todayStartUtcSec() fonksiyonunu düzenle:
function todayStartUtcSec(): bigint {
const d = new Date();
d.setUTCHours(0, 0, 0, 0); // ← şu an UTC midnight
// Türkiye için örneğin:
// d.setUTCHours(-3, 0, 0, 0); // önceki gün 21:00 UTC = TR 00:00
return BigInt(Math.floor(d.getTime() / 1000));
}/api/submit her başarılı imza doğrulamasından sonra Upstash Redis'te
iki anahtarı atomik olarak kilitler:
raffel:submitted:wallet:<lowercased-address>
raffel:submitted:x:<lowercased-handle>
Redis komutu: SET key value NX — anahtar yoksa kurar ("OK" döner),
varsa hiçbir şey yapmaz (null döner). İkincisinin döndüğü durumda istek
409 Conflict ile reddedilir; Discord webhook'una bir şey gitmez.
Bu sayede:
- Aynı cüzdan ikinci kez submit edemez.
- Aynı X kullanıcı adı başka cüzdanla bile submit edemez.
Redis env değişkenleri tanımsızsa dedup atlanır ve aynı kişi tekrar tekrar gönderebilir. Üretimde mutlaka set edilmiş olmalı;
/api/healthbunu teyit etmek için var.
Upstash konsolundan veya CLI'dan ilgili anahtarları silmek yeter. Komut örneği (Upstash REST):
# Tüm raffel:submitted:* anahtarlarını sil
curl -s "$UPSTASH_REDIS_REST_URL/scan/0/match/raffel:submitted:*/count/1000" \
-H "Authorization: Bearer $UPSTASH_REDIS_REST_TOKEN"
# Çıkan listeyi DEL ile geç:
# curl -s "$UPSTASH_REDIS_REST_URL/del/<key1>/<key2>/..." -H "Authorization: Bearer $TOKEN"Veya Upstash dashboard'unda Data Browser üzerinden tek tek silebilirsin.
Browser /api/submit (server) Discord
│ │ │
│ 1. cüzdanla mesajı imzala (eth_sign) │ │
│ ─────────────────────────────────────►│ │
│ 2. POST { wallet, xUsername, │ │
│ issuedAt, signature } │ │
│ ─────────────────────────────────────►│ │
│ │ 3. mesajı yeniden inşa et │
│ │ imzayı doğrula │
│ │ (mainnet client) │
│ │ 4. Redis SETNX (dedup) │
│ │ 5. embed POST │
│ │ ──────────────────────────►│
│ ◄──────────────────────────────────── │ 6. { ok: true } │
Raffel entry signature
Wallet: 0xAbC...
X: @handle
Issued: 2024-05-24T11:23:45.000Z
issuedAt'in 10 dakikadan eski olduğu istekler reddedilir
(replay attack koruması — SIGNATURE_TTL_MS).
Sunucu tarafında publicClient.verifyMessage({...}) kullanılır.
Bu, hem EOA hem de EIP-1271 smart account imzalarını doğrular
(Safe, Argent, vb. de çalışır).
{
"username": "Raffel",
"embeds": [{
"title": "New Raffel entry",
"color": 0x22d3ee,
"fields": [
{ "name": "X", "value": "@handle", "inline": true },
{ "name": "Wallet", "value": "[0x...](https://etherscan.io/address/0x...)" }
],
"timestamp": "2024-05-24T11:23:45.000Z"
}]
}Webhook URL kesinlikle frontend'e koyulmamalı — yalnızca server-side
process.env.DISCORD_WEBHOOK_URL üzerinden okunuyor.
Deploy sonrası env değişkenlerinin doğru bağlandığını teyit etmek için:
GET https://your-site.vercel.app/api/health
Örnek cevap:
{
"ok": true,
"discordWebhookConfigured": true,
"redis": {
"configured": true,
"selectedUrlVar": "UPSTASH_REDIS_KV_REST_API_URL",
"selectedTokenVar": "UPSTASH_REDIS_KV_REST_API_TOKEN",
"urlVarsPresent": ["UPSTASH_REDIS_KV_REST_API_URL"],
"tokenVarsPresent": ["UPSTASH_REDIS_KV_REST_API_TOKEN"]
}
}configured: false görürsen — Vercel'de değişkenler eklenmemiş ya da
deployment onları henüz almamış (yeni bir redeploy gerekir).
| Hata mesajı | Sebep | Çözüm |
|---|---|---|
Server is missing DISCORD_WEBHOOK_URL. |
Webhook env değişkeni yok ya da deploy'a henüz inject edilmemiş | Vercel → Environments → ekle → Redeploy |
Signature does not match the wallet. |
İmza eskidi veya farklı cüzdan kullanılıyor | Submit'e tekrar bas (yeni issuedAt üretir) |
Signature expired. Try Submit again. |
İmza 10 dakikadan eski | Submit'e tekrar bas |
This wallet has already submitted an entry. |
Dedup çalışıyor — beklenen davranış | Yeni cüzdanla dene; veya Redis'ten anahtarı sil |
@handle has already submitted an entry. |
Aynı X handle başka cüzdanla bile kullanılmış | Farklı handle ya da Redis'ten temizle |
No gm found from today (after 00:00 UTC). |
Bugün UTC içinde gm yok | Taze gm at; veya tx hash'i yapıştır |
Tx is from <date>, before today 00:00 UTC. |
Eski bir gm tx hash'i verildi | Bugün yeni bir gm at, onun hash'ini yapıştır |
Discord rejected the webhook (4xx) |
Webhook silinmiş / URL hatalı / oran limiti | Yeni webhook oluştur, env'i güncelle, redeploy |
src/components/StepsCard.tsx içindeki STEPS dizisi tek kaynak. Yeni
satır ekle:
const STEPS: Step[] = [
{ kind: "link", key: "followOnchainGm", label: "Follow ...", url: LINKS.x },
// ekle:
{ kind: "link", key: "newStep", label: "...", url: "https://..." },
// veya başka bir on-chain check:
{ kind: "check", key: "newCheck", label: "...", subtitle: "...", url: "..." },
];Progress tipini (src/lib/progress.ts) ve TOTAL_STEPS'i de aynı anda
güncelle.
src/lib/config.ts→GM_CONTRACTyeni adressrc/lib/wagmi.ts→chainsvetransportsyeni zincirsrc/app/api/submit/route.ts→verifyClient'ı aynı zincire al- UI metinleri (
StepsCard.tsx,subtitle, hata mesajları) - Etherscan/explorer linkleri (
route.tsiçindeetherscan.io/address/...)
src/app/api/submit/route.ts içinde embed nesnesi. Discord embed
referansı: https://discord.com/developers/docs/resources/webhook
/api/submit route'unda Discord fetch'inden hemen önce/sonra ikinci bir
fetch çağrısı ekle. Aynı kalıp:
await fetch(process.env.SHEETS_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wallet: normalized, xUsername, timestamp: issuedAt }),
});Sonra Vercel'e SHEETS_WEBHOOK_URL env değişkenini ekle.
Bkz. Günlük sıfırlama kuralı bölümü.
src/lib/gm.ts içindeki todayStartUtcSec() yerine:
function lastNDaysStartUtcSec(days: number): bigint {
return BigInt(Math.floor(Date.now() / 1000) - days * 24 * 60 * 60);
}ve >= today yerine >= lastNDaysStartUtcSec(7) kullan.
npm install # bağımlılıklar
npm run dev # lokal dev (http://localhost:3000)
npm run build # production build (Vercel bunu çalıştırıyor)
npm run start # build sonrası lokal prod sunucu
npx tsc --noEmit # tip kontrolü- Discord webhook URL'i asla repo'ya, frontend'e veya log'a sızmamalı. Sızdıysa Discord'dan webhook'u silip yenisini oluştur.
- Submit endpoint'i imza doğrulama yapıyor — başkası, başka birinin cüzdan adresini yazıp giriş yapamaz. EIP-1271 destekli olduğu için smart account cüzdanları da çalışır.
issuedAt10 dakikalık TTL ile replay attack koruması sağlıyor.- Dedup
SETNXile atomik — race condition yok. - Public RPC'ler kullanıldığı için yüksek trafikte rate-limit yiyebilirsin.
Üretim trafiği büyürse Alchemy/Infura key'i alıp
wagmi.ts'ye veroute.ts'dekiverifyClient'a koyman önerilir.