Ortak hesabı adil biçimde bölmek için küçük bir hackathon MVP'si. Hedeflenen akış: fişin fotoğrafını yükle → fişteki ürünleri kişilere dağıt → herkesin borcunu hesapla → Arc Testnet üzerinden USDC ile öde.
Bu depo fiş analizi, paylaşım/borç hesabı, Arc Testnet ödeme akışı ve creator işlemleri için Google oturum temelini içerir. Kota uygulaması bu değişikliğin parçası değildir.
Node.js 22 veya üzeri gerekir (Arc App Kit ileride bunu şart koşacak).
npm installOrtam değişkenlerini hazırla:
cp .env.example .env.local.env.local içine kendi OpenAI API anahtarını yaz:
| Değişken | Zorunlu | Açıklama |
|---|---|---|
OPENAI_API_KEY |
evet | Fiş analizi için OpenAI anahtarı. Yalnızca sunucuda okunur. |
OPENAI_RECEIPT_MODEL |
hayır | Kullanılacak model. Varsayılan: gpt-5.6-luna |
COINGECKO_DEMO_API_KEY |
evet | USDC/TRY kuru için CoinGecko Demo anahtarı. Yalnızca sunucuda okunur. |
RATE_QUOTE_SECRET |
evet | Kur teklifini imzalayan HMAC sırrı. Yalnızca sunucuda okunur. |
DATABASE_URL |
hayır (Part 1) | Neon Postgres bağlantısı; paylaşılan ortak hesap deposu. Yalnızca sunucuda okunur. |
SHARED_BILL_AUTH_SECRET |
evet (borçlu bağlantısı) | Cüzdan challenge zarfının HMAC sırrı. Yalnızca sunucuda okunur. |
APP_ORIGIN |
üretimde evet | Güvenilen açık uygulama origin'i; OAuth ve cüzdan challenge hedefi istemci Host başlıklarından türetilmez. |
AUTH_SECRET |
Google girişinde evet | Auth.js JWT/cookie şifreleme sırrı: tam 32 rastgele baytı temsil eden tam 64 küçük hexadecimal karakter (^[0-9a-f]{64}$). Yalnızca sunucuda okunur. |
AUTH_GOOGLE_ID |
Google girişinde evet | Google OAuth Web client ID. Yalnızca sunucuda okunur. |
AUTH_GOOGLE_SECRET |
Google girişinde evet | Google OAuth Web client secret. Yalnızca sunucuda okunur. |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
hayır | WalletConnect Cloud proje kimliği. Tanımlı değilse mobil cüzdan seçeneği hiç gösterilmez; masaüstündeki EIP-6963 akışı etkilenmez. Sır değildir, aşağıya bakın. |
ANDROID_PACKAGE_NAME |
hayır | TWA paketinin adı (ters DNS). Tanımlı değilse /.well-known/assetlinks.json 404 döner. Yalnızca sunucuda okunur. |
ANDROID_APP_FINGERPRINTS |
hayır | Virgülle ayrılmış SHA-256 sertifika parmak izleri. İKİSİ de gerekir: yükleme anahtarın ve Google'ın uygulama imzalama anahtarı. Yalnızca sunucuda okunur. |
Yukarıdaki sunucu değişkenlerinin hiçbiri NEXT_PUBLIC_ önekiyle
tanımlanmaz ve hiçbiri istemci paketine girmez.
Tek istisna NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID'dir ve istisna olması
kasıtlıdır: WalletConnect projectId'si tanımı gereği tarayıcı paketinde
bulunur, çünkü eşleşme isteğini röleye gönderen taraf istemcidir. Bir sır
değildir, tek başına hiçbir şeye yetki vermez ve hiçbir sunucu sırrının yerine
geçmez. Sunucu sırlarının NEXT_PUBLIC_ ile tanımlanmaması kuralı
değişmemiştir.
AUTH_SECRET kriptografik rastgelelikle üretilmelidir. macOS'ta değeri terminal
çıktısına yazmadan doğrudan panoya almak için:
openssl rand -hex 32 | tr -d '\n' | pbcopyÜretilen değer değiştirilmeden kullanılmalı; başında/sonunda boşluk, büyük hex
harfi veya başka karakter bulunmamalıdır. AUTH_SECRET, RATE_QUOTE_SECRET ve
SHARED_BILL_AUTH_SECRET üç ayrı sunucu sırrıdır; aynı değer paylaşılmaz.
Hiçbiri NEXT_PUBLIC_ adıyla tanımlanmaz veya istemciye gönderilmez.
APP_ORIGIN, AUTH_SECRET, AUTH_GOOGLE_ID ya da AUTH_GOOGLE_SECRET eksik
veya geçersizse Auth.js başlatılmaz. OAuth uç noktaları ile Google korumalı
creator API'leri ayrıntı sızdırmayan, Cache-Control: no-store taşıyan
503 SERVICE_NOT_CONFIGURED döndürür. Geçerli yapılandırmada yalnızca oturum
yoksa creator API'leri 401 AUTH_REQUIRED döndürür. Borçlu cüzdan ve kur
rotaları bu Google yapılandırmasından bağımsız kalır.
DATABASE_URL tanımlı olmasa bile uygulama ve testler derlenir; yalnızca
POST /api/shared-bills kontrollü bir 503 döner. Şema ve geçiş için
"Tek bağlantılı ortak hesap" bölümüne bak.
Anahtar tanımlı olmasa bile uygulama açılır ve fiş yükleme ekranı çalışır; yalnızca analiz isteği kontrollü bir "servis yapılandırılmamış" hatası döner.
npm run devUygulama http://localhost:3000 adresinde açılır.
Uygulama /manifest.webmanifest yayınlar ve ana ekrana kurulabilir. Kurulan
uygulamanın adı Split Bill'dir. Marka adı ÇEVRİLMEZ: iki sözlükte de
aynıdır, çünkü uygulama her ülkede tek bir isimle tanınmalı — telefonda
ikonun altında görünen ad da budur ve o değer dile göre değişemez.
Manifest tek dillidir: <link rel="manifest"> varsayılan olarak çerez
göndermez, bu yüzden dil çerezine bakan bir manifest güvenilir çalışmaz.
Sayfa metinleri her zamanki gibi kullanıcının dilinde kalır.
Servis çalışanı YOKTUR ve bilerek yoktur. Sayfalar no-store; bir
önbellek ödeme sayfasını ya da imzalı bir yükü saklayabilir, o sınırı delerdi.
Çevrimdışı bir hedef de yok. Android'de "ana ekrana ekle" istemi çıkmazsa ilk
bakılacak yer burasıdır.
İşaret yalnızca geometriyle çizilir ve hiçbir fonta bağlı değildir:
kaynağı src/lib/brand/mark.ts, başlıktaki işaret ile uygulama ikonları aynı
yollardan üretilir. Daha önce bir metin düğümüydü, yani her cihazda o cihazın
fontuyla çiziliyordu.
İşaret bir para birimi simgesi taşımaz. Eskiden Türk lirası simgesiydi; bu hem uygulamayı tek bir ülkeye bağlıyordu hem de yanlıştı — ödemeler lirayla değil, Arc Testnet üzerindeki test USDC'siyle yapılıyor. Yerine yan yana duran, biri diğerinden kısa iki fiş kondu: hangi dil konuşulursa konuşulsun aynı şeyi anlatır — bir hesap, iki pay. Tutar satırları boya değil deliktir, bu yüzden işaret hangi zemine basılırsa basılsın doğru görünür.
İkonlar depoya işlenmiştir; çalışma zamanında hiçbir şey yeniden çizilmez. Geometri değişirse yeniden üretmek gerekir:
node scripts/generate-icons.mjsBetik npm run build'in parçası değildir ve sharp'a ihtiyaç duyar.
sharp bir bağımlılık olarak tanımlı değildir; Next ile birlikte geldiği için
genelde bulunur, bulunmazsa betik bunu açıkça söyler.
İşaret maskelenebilir güvenli alanın (merkezi %80 çaplı daire) içinde kaldığı
için aynı görsel hem any hem maskable amacıyla bildirilir; ayrı bir
maskelenebilir dosya gerekmez. Bu, src/lib/brand/pwa-shell.test.ts içinde
ölçülür — geometri büyürse test kırmızıya döner.
Politika /privacy adresindedir, tek adres iki dil (uygulamanın geri kalanı
gibi dil yol ön eki yoktur). Play Console'a verilecek adres budur. Metnin
kendisi src/lib/legal/privacy.ts içindedir; sayfa yalnızca onu çizer.
Metin kaynak kodun denetiminden çıkarılmıştır. Süreler koddaki sabitlerden
türetilir ve privacy.test.ts şunları zorlar: kaynakta geçen her dış alan
adı politikada bildirilmiş olmalıdır, süreler sabitlerin karşılığı olmalıdır,
ve iki dil aynı belgeyi anlatmalıdır. Yeni bir servise bağlanan kod,
politikayı güncellemeden testlerden geçemez.
-
Süresi dolan ortak hesap kayıtları artık siliniyor. Süre dolunca hesap açılamaz ve ödenemez; kayıt bir süre daha durur, sonra günlük çalışan bir temizlikle (
/api/cron/retention) borç satırlarıyla birlikte kaldırılır. Süreler koddaki sabitlerden (src/lib/db/retention.ts) türetilir ve politikada yazar; burada tekrarlanmaz.Temizliğin iki ayrıntısı kazara bozulabilir:
- Cascade'e güvenilmez.
shared_bill_payment_attempts, teklifleriON DELETE RESTRICTile referanslıyor; tek bir cascade'li silme sıraya bağlı olarak yabancı anahtar hatasıyla düşebilir. Silme, çocuktan ebeveyne AÇIK sırayla ve tek işlemde yapılır. - Bütün deyimler AYNI hedef kümesini kullanır. Ölçüt tek yerde tanımlanıp her deyime gömülür; farklı kümeler, borç satırları silinmiş ama kendisi duran bir hesap bırakabilirdi.
Uç
CRON_SECRETile korunur ve sır tanımlı değilse hiç çalışmaz. - Cascade'e güvenilmez.
-
Zincire yazılan hiçbir şey geri alınamaz. Silme hakkı oraya ulaşmaz; politika bunu yumuşatmadan söyler.
Bu duruma düşen her satır gerçek bir insanın "ödedim ama görünmüyor" anıdır: zincirde bir şey oldu, beklenen transferi kanıtlamadı, kilit kaldı.
GET /api/admin/review bekleyenleri en eski önce listeler; REVIEW_SECRET
ile korunur.
curl -sS -H "Authorization: Bearer $REVIEW_SECRET" https://<alan-adi>/api/admin/reviewYanıt bilerek dardır. Ölçüm ucu yalnızca toplam döndürür ve döndürmelidir;
burası farklıdır çünkü takılı bir ödemeyi çözmek için hangi kayıt olduğunu
bilmek şarttır. İstisna en dar hâliyle tutulur: yalnızca zincirde zaten açık
olanlar döner — adresler ve işlem hash'i — artı hesap kimliği ve tutar. İnsan
adları ve uygulama kullanıcısı kimliği dönmez; mutabakat zincire karşı
yapılır, kişiye karşı değil. METRICS_SECRET değil ayrı bir sır kullanır:
farklı duyarlılık, farklı patlama yarıçapı.
Şema review_required → paid geçişini anlatıyordu ama uygulayan bir kod yolu
yok — bu runbook yazılırken ölçüldü:
- Bağlı olduğu deneme
unknowndur veALLOWED_SETTLEMENTS.unknownboştur: yeni bir makbuz doğrulaması bile onupaide taşıyamaz. - Yeni bir teklif ya da deneme de açılamaz; ikisi de borcun
unpaidolmasını şart koşar.
Yani "elle mutabakat" bir mecaz değil. İki yanıltıcı yorum bu sırada düzeltildi; eskisi takılı bir borcun kendiliğinden düzeleceğini sandırıyordu.
- Listeyi al (yukarıdaki
curl). txHashyoksa zincirde bakılacak bir şey yoktur: deneme hash bildirilmeden belirsizleşmiş. Kullanıcıya cüzdan geçmişini sor — gerçekten bir gönderim var mı?txHashvarsa ArcScan'de aç ve dördünü birden doğrula:- işlem başarılı mı (revert değil),
- gönderen
debtoradresi mi, - alıcı
recipientadresi mi, - transfer edilen miktar
microUsdcile birebir eşit mi.
- Dördü de tutuyorsa ödeme gerçekten yapılmıştır ve borç
paidolmalıdır. Çıkış yalnızca gözden geçirilmiş bir veritabanı işlemidir:
BEGIN;
UPDATE shared_bill_debts
SET payment_status = 'paid', paid_tx_hash = '0x...', paid_at = now()
WHERE bill_id = '0x...' AND lower(debtor_address) = lower('0x...')
AND payment_status = 'review_required';
-- 1 satır beklenir. Değilse ROLLBACK.
COMMIT;- Dördünden biri tutmuyorsa borç ödenmemiştir. Durumu değiştirme;
kullanıcıya ne gördüğünü açıkla. Yanlış bir transferi
paidyazmak, geri dönüşü olmayan tek hatadır —paidhiçbir koşulda geri alınmaz.
- Zincire bakmadan asla yazma. Bu ifadenin tek meşru girdisi ArcScan'de görülen bir makbuzdur.
- Toplu çalıştırma. Her satır ayrı bir insanın parasıdır; koşul
payment_status = 'review_required'ile sınırlıdır ki yanlış bir satır yakalanmasın. - Sayı artıyorsa sorun tek tek kayıtlarda değildir. Ölçüm ucundaki
debts.needsReviewveattempts.needsAttentionsayaçlarına bak; artan bir eğri genellikle RPC tarafında bir arıza demektir.
Faz 5'te Play Console formu doldurulurken kullanılacak karşılıklar. "Paylaşım" Play'in tanımıdır: veri cihazdan çıkıp üçüncü bir tarafa ulaşıyorsa paylaşım sayılır.
| Play veri türü | Toplanır | Paylaşılır | Geçici mi | Zorunlu mu | Amaç |
|---|---|---|---|---|---|
| Kişisel bilgi → E-posta adresi | Evet | Hayır | Hayır | Google girişi seçilirse | Hesap yönetimi |
| Kişisel bilgi → Ad | Evet | Hayır | Hayır | Google girişi seçilirse | Hesap yönetimi |
| Fotoğraf ve video → Fotoğraflar | Evet | Evet (OpenAI) | Evet | Fiş yüklenirse | Uygulama işlevi |
| Finansal bilgi → Diğer finansal bilgi (cüzdan adresi, borç tutarı) | Evet | Evet (herkese açık blok zinciri) | Hayır | Evet | Uygulama işlevi |
| Uygulama etkinliği → Uygulama içi etkileşimler | Evet | Hayır | Hayır | Giriş yapıldıysa | Günlük analiz sınırı, kötüye kullanımın önlenmesi |
| Cihaz veya diğer kimlikler | Hayır | — | — | — | Reklam kimliği kullanılmaz |
| Uygulama bilgileri ve performansı → Tanılama | Evet | Hayır | Hayır | Hayır | CSP ihlal raporları |
En sık yanlış işaretlenen satırlar kalın olanlardır:
- Fiş fotoğrafı OpenAI'ye gider, yani "paylaşılır".
- Cüzdan adresleriyle işlemler herkese açık bir zincire yazılır, yani orası da bir paylaşımdır.
- Günlük analiz sayacı toplanır. Reklam ya da izleme olmaması "etkinlik
toplanmıyor" demek değildir: sayaç
(hesap kimliği, gün, adet)olarak veritabanında durur ve Play'in "uygulama içi etkileşimler" tanımına girer. - CSP ihlal raporları sunucu günlüğüne yazılır. Rapor kullanıcı kimliği ya da sayfa adresi TAŞIMAZ — yalnızca ihlal edilen yönerge ve kaynağın kökeni — ama cihazdan çıkıp sunucuda saklandığı için "toplanmıyor" denemez.
Son iki madde bu rehbere sonradan eklendi. Play Console'daki gönderilmiş form bu iki satır için gözden geçirilmelidir; rehberi düzeltmek formu düzeltmez.
Güvenlik uygulamaları bölümü:
| Soru | Cevap |
|---|---|
| Veri aktarım sırasında şifreleniyor mu | Evet (HTTPS/TLS) |
| Kullanıcı silme talep edebiliyor mu | Evet |
| Uygulama içinde hesap silme yolu var mı | Evet, /account |
| Silme talebi için web adresi | https://splitstable.com/account |
| Bağımsız güvenlik incelemesinden geçti mi | Hayır |
Play, hesap açtıran uygulamalardan iki şey ister: uygulama İÇİNDE bir silme
yolu ve silme talebi için herkesin açabileceği bir web adresi. /account
sayfası ikisini birden karşılar; forma verilecek adres budur.
Oturum çerezi silme yanıtında ölür. Oturum bir JWT'dir ve 30 gün yaşar;
çerez temizlenmezse silinen hesap istek göndermeye devam eder. Çerez adlarını
elle üretmek yerine Auth.js kendi çerezlerini temizler (adlar ortama göre
değişir: __Secure- öneki, parçalı çerezler).
Ama bu yalnızca isteği gönderen tarayıcıyı kapatır. Sunucu bir JWT'yi
iptal edemez; başka bir cihazdaki oturum süresi dolana kadar geçerli kalır.
O yolu kapatan şey /api/receipts/analyze üzerindeki varlık kontrolüdür.
Neden yalnızca o rota: saved_contacts ve shared_bills tablolarının
app_users'a yabancı anahtarı var, silinmiş kullanıcının yazması düşer ve
okuması boş döner. receipt_analysis_quota'nın yabancı anahtarı yok (genel
satır yüzünden) ve o rota para harcıyor — zincirin dışında kalan tek yer
orasıydı.
Erişilememe "yok" ile karıştırılmaz: veritabanı bir an aksadı diye var olan hesabıyla gelen kullanıcı dışarı atılmaz (503, 401 değil).
Silme kapsamı şemadaki yabancı anahtarlarla belirlenir, kodda ayrıca silinmez:
app_userssatırı gider (doğrulanmış e-posta, görünen ad, avatar adresi).saved_contactsON DELETE CASCADE ile gider.shared_billsON DELETE SET NULL ile SAHİPSİZ kalır ama durur. İçlerinde başkalarının borcu vardır; hesabını silen kişi onların ödeme yolunu kapatamaz. Politika bunu ve zincirin silinemezliğini açıkça söyler.
Google girişi yalnızca hesap oluşturan akış için gerekir. Bağlantıyla gelen borçlu, Google hesabı olmadan yalnızca cüzdan imzasıyla kendi borcunu görür ve öder; o akışta e-posta ve ad hiç toplanmaz.
Ana creator arayüzü oturum açmadan görüntülenebilir. Aşağıdaki pahalı/yazıcı
işlemler sunucuda Google oturumu gerektirir ve oturum yoksa gövde okunmadan
genel, önbelleksiz 401 AUTH_REQUIRED JSON yanıtı döner:
POST /api/receipts/analyzePOST /api/shared-bills
Fiş seçimi oturum yönlendirmesinde tutulmaz veya yüklenmez. Kullanıcı Google girişinden döndüğünde görseli yeniden seçer. Fiş görseli yalnızca kimliği doğrulanmış kullanıcı Fişi analiz et işlemini açıkça başlattığında OpenAI'ye gönderilir; sunucuda veya kullanıcı tablosunda saklanmaz.
Auth.js v5 ve resmi Google OIDC provider kullanılır. OAuth state, PKCE, nonce,
callback doğrulaması ve güvenli cookie davranışı kütüphanenin sınırındadır.
İstenen scope tam olarak openid email profiledır; Google API erişimi yoktur.
Uygulama kimliği e-posta değildir: provider = google ile Google'ın kararlı
provider account ID'si (sub) birlikte benzersizdir. Her hesap için rastgele,
opak bir uygulama kullanıcı UUID'si üretilir. Aynı e-postayı bildiren iki ayrı
Google hesabı birleşmez; aynı Google hesabının eşzamanlı ilk girişleri Postgres
ON CONFLICT upsert ile tek kayda çözülür.
app_users yalnızca opak kullanıcı ID'si, provider/provider hesap ID'si,
normalize e-posta, doğrulanma bayrağı, isteğe bağlı görünen ad/avatar ve zaman
damgalarını saklar. E-posta profil metadatasıdır; yetkilendirme anahtarı değildir.
Google access token, refresh token ve ID token hiçbir veritabanına veya oturum
JWT'sine yazılmaz ve loglanmaz. Auth.js adapter kullanılmaz. Oturum, yalnızca
minimal uygulama kullanıcı kimliğini ve güvenli görünen profil alanlarını taşıyan,
HttpOnly, SameSite=Lax ve üretimde Secure şifreli JWT-cookie'dir; browser
storage kullanılmaz.
Google oturumu aşağıdaki sayfa ve API'lerde istenmez; global auth middleware'i yoktur:
/pay,/pay/[billId]/api/shared-bills/[billId]/challenge,/resolve,/me/api/shared-bills/[billId]/payment/*/api/rates/usdc-try,/api/rates/verify
Borçlu kendi borcunu yalnızca mevcut cüzdan challenge/imza oturumuyla görür ve öder. Google oturumu cüzdan sahipliği kanıtının yerini alamaz; borçlu cüzdanı Google kullanıcısıyla ilişkilendirilmez ve creator'ın Google profili paylaşılmaz.
Bu depodaki değişiklik Google Cloud, Vercel veya Neon'u kendiliğinden yapılandırmaz. Daha sonra şu adımlar elle uygulanmalıdır:
- Google Cloud Console'da OAuth consent screen ve Web application client oluştur; yalnızca temel OpenID profil/e-posta izinlerini kullan.
- Authorized JavaScript origins listesine
http://localhost:3000vehttps://splitstable.comekle. - Authorized redirect URIs listesine şunları birebir ekle:
http://localhost:3000/api/auth/callback/googlehttps://splitstable.com/api/auth/callback/google
- Yerel sunucuda boş örneklerden
AUTH_SECRET,AUTH_GOOGLE_IDveAUTH_GOOGLE_SECRETdeğerlerini güvenli biçimde tanımla; Google girişini kullanmak içinAPP_ORIGINdeğerini de açıkça tanımla (yereldehttp://localhost:3000olabilir).AUTH_SECRETiçin yukarıdaki 32 rastgele bayt / 64 küçük hex üretim komutunu kullan. - Sunucuda aynı üç server-only değişkeni ve
APP_ORIGIN=https://splitstable.comdeğerini elle tanımla. Hiçbir sırraNEXT_PUBLIC_öneki verme. - İncelenen
migrations/0002_app_users.sqlgeçişini hedef Neon ortamına ayrıca ve kontrollü biçimde uygula. Uygulama şemayı istek sırasında yaratmaz.
Preview deployment hostları bilerek OAuth origin listesine alınmamıştır. Auth.js
origin'i gelen Host, Origin, Referer veya X-Forwarded-Host başlıklarından
değil, doğrulanmış APP_ORIGIN değerinden kurar.
Diğer komutlar:
npm run lintnpm run typechecknpm testnpm run buildKur artık elle girilmez. Sunucu kuru CoinGecko'dan alır, kanonik biçime indirger ve HMAC-SHA-256 ile imzalar; ödeme talebi bu imzalı teklife bağlanır.
Talebi oluşturan kişi kendi cüzdanıyla istediği alanı imzalayabilir. Bu yüzden EIP-712 imzası kurun piyasadan geldiğini kanıtlamaz — yalnızca alanları kimin imzaladığını kanıtlar. Kuru sunucunun HMAC etiketi korur: etiket kurun kendisini de kapsadığı için, oluşturucu kuru değiştirip tutarı yeniden hesaplasa bile borçlunun tarayıcısında yapılan sunucu doğrulaması düşer.
- https://www.coingecko.com/en/api adresinden ücretsiz Demo planına kaydol.
- Panelden bir Demo API anahtarı üret.
- Anahtarı
.env.localiçindekiCOINGECKO_DEMO_API_KEYalanına yaz.
Anahtar yalnızca x-cg-demo-api-key başlığında taşınır; sorgu dizesine
konmaz ve istemciye hiç gönderilmez.
Sırrı depoya yazmadan yerelde üret:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"Çıktıyı .env.local içindeki RATE_QUOTE_SECRET alanına yapıştır. En az 32
karakter olmalıdır. .env.local git tarafından yok sayılır; sırrı asla
.env.example, test, log veya commit içine koyma.
- Ödeme talebi ekranına gelindiğinde kur bir kez otomatik istenir.
- Sunucu
GET /api/rates/usdc-tryile taze bir teklif basar. - Teklif 5 dakika geçerlidir; ekranda geri sayım gösterilir.
- Talep yalnızca geçerli ve süresi dolmamış bir teklifle imzalanabilir.
- Borçlunun sayfası teklifi
POST /api/rates/verifyile sunucuya doğrulatır; doğrulama geçmeden cüzdan, tahmin ve gönderim kontrolleri görünmez. - Kur, tahminden hemen önce ve gönderimden hemen önce yeniden doğrulanır.
Sağlayıcı değeri sınırda bir kez altı ondalıklı kanonik metne çevrilir
(ör. 42.123456), sonra 42123456 / 1000000 rasyoneline dönüşür. Borç ve
mikro-USDC hesabının tamamı BigInt'tir; kanonikleştirmeden sonra hiçbir yerde
kayan nokta aritmetiği kullanılmaz. USDC tutarı her zaman 6 ondalıklı tam sayı
mikro-USDC'dir.
| Süre | Değer |
|---|---|
| Kur teklifi ömrü | 5 dakika |
| Ödeme talebi ömrü | teklifin bitişini aşamaz |
| Sağlayıcı gözlem yaşı üst sınırı | 10 dakika |
| Sunucu önbelleği | ~60 saniye |
Ödeme talebi dayandığı teklifden uzun yaşayamaz. Bu, paylaşılan bağlantının pratikte 5 dakika içinde ödenmesi gerektiği anlamına gelir.
CoinGecko Demo planı kredi sınırlıdır ve verisi yaklaşık 60 saniye tazeliktedir. Sağlayıcı sonucu sunucu belleğinde ~60 saniye önbelleklenir ve aynı penceredeki eşzamanlı istekler tek bir yukarı akış çağrısında birleşir; böylece her render veya her kullanıcı bir kredi harcamaz.
Önbellek süreç içidir. Sunucusuz ortamda her soğuk başlangıç ve her eşzamanlı örnek kendi önbelleğini tutar; bu yüzden önbellek tek başına bir garanti değil, bir iyileştirmedir.
Üstündeki garantiyi paylaşılan çağrı bütçesi verir: yukarı akışa gerçekten
gidilecekse, önce Postgres'teki provider_call_budget sayacından bir kredi
atomik olarak ayrılır. Ayrılamazsa CoinGecko hiç çağrılmaz. Böylece
dakikadaki yukarı akış çağrısı, örnek sayısından bağımsız olarak sınırlıdır.
Ayrıntı: "Örnekler arası çağrı bütçesi".
Sağlayıcı 429/5xx/zaman aşımı döndüğünde her istek yeni bir yukarı akış çağrısı üretirse Demo kotası hızla tükenir. Bu yüzden ardışık hatalarda sınırlı bir soğuma uygulanır:
| Davranış | Değer |
|---|---|
| Taban soğuma | 5 sn |
| Üstel büyüme | her ardışık hatada iki katı |
| Tavan | 120 sn |
Retry-After üst sınırı |
300 sn |
- Soğuma boyunca CoinGecko hiç çağrılmaz; uygulama kontrollü ve yeniden
denenebilir bir hata ile
Retry-Afterbaşlığı döner. - CoinGecko'nun
Retry-Afterbaşlığı yalnızca saniye biçiminde ve güvenli bir aralığa kırpılarak dikkate alınır. - İlk başarılı yanıt soğumayı sıfırlar.
- Yapılandırma eksikliği (anahtar/sır yok) bir sağlayıcı arızası değildir ve soğutulmaz.
- Bozuk veya bayat veri asla geçerli başarı olarak önbelleklenmez.
Soğuma ve önbellek hâlâ SÜREÇ İÇİDİR ve örnekler arasında paylaşılmaz; tek başlarına toplam yukarı akış hızını sınırlamazlar.
Toplam hızı sınırlayan şey paylaşılan çağrı bütçesidir: soğuma penceresini geçen bir istek bile, yukarı akışa gitmeden önce Postgres'teki sayaçtan kredi ayırmak zorundadır. Bu iki katman farklı işler yapar — soğuma arızalı bir sağlayıcıyı korur, bütçe kotayı korur.
kit.send çağrıldıktan sonra ortaya çıkan her hata "gönderilemedi" demek
değildir: işlem zincire düşmüş olabilir. Bu yüzden sonuçlar ikiye ayrılır.
Sınıflandırma yalnızca belgelenmiş yapısal alanlara bakar (EIP-1193 kodu,
App Kit errorCategory, KitError ad+kod çifti). Hata metni hiçbir karara
girmez: "insufficient ..." veya "user rejected" yazması işlemin yayınlanmadığını
kanıtlamaz.
| Durum | Davranış |
|---|---|
UserRejectedRequestError / user_rejected / ham EIP-1193 4001, hash yok |
Yayın öncesi kesin; yeniden denenebilir |
RPC_ENDPOINT_ERROR, type: "RPC" veya 4001 kullanan ağ KitError'ı |
Belirsiz; 4001 tek başına ret kanıtı DEĞİLDİR |
BALANCE_INSUFFICIENT_TOKEN/GAS/ALLOWANCE (9001–9003), hash yok |
Yayın öncesi kesin; yeniden denenebilir |
state: "success" ve geçerli hash |
Başarı |
state: "error" + geçerli hash + kategori yok |
Revert (kurulu SDK'nın onaylanmış makbuz şekli); asla "ödendi" değil |
chain_revert, reverted_onchain, partial_reverted |
Revert; hash korunur |
pending, noop, tanınmayan durum veya kategori |
Belirsiz; gönderim kilidi açılmaz, hash korunur |
viem WaitForTransactionReceiptTimeoutError |
Belirsiz; hash mesajdan kurtarılır ve korunur |
| Herhangi bir hata hash taşıyorsa | Yayın öncesi sayılmaz; belirsiz |
Revert ve belirsizlikte işlem hash'i kaybedilmez: yerel kayıtta ve ekranda tutulur ki ArcScan'de mutabakat yapılabilsin.
Hata incelemesi hiçbir koşulda fırlatmaz. Cüzdan/sağlayıcı hataları
fırlatan getter, durumlu erişimci veya iptal edilmiş proxy içerebilir; böyle
bir nesnede error.code ya da Array.isArray bile TypeError atar. Bu yüzden
incelenen her alan korumalı okunur, en fazla bir kez okunup düz bir anlık
görüntüye alınır (durumlu bir getter hash analizi ile ret analizi arasında
değer değiştiremez) ve okunamayan bir alan dolaşımı eksik işaretler. Eksik
dolaşımda ya da geçerli bir hash bulunduğunda sonuç submissionUnknown
olur, rezervasyon kilitli kalır; kurtarılmış hash yine de ArcScan için
saklanır. kit.send çağrıldıktan sonra sınıflandırıcının kendisi çökse bile
sonuç asla yeniden denenebilir sendFailed olmaz.
Hata grafiği sınırlı ve döngüye dayanıklı biçimde dolaşılır; yalnızca
beklenen bağlantılar (cause, trace, rawError, errors) izlenir. Bir
bağlantı dizi/kap değerliyse (ör. AggregateError.errors ya da dizi
değerli bir cause) dolaşım eksik işaretlenir: desteklenen tekil nesne
şekli değildir ve içinde gizli bir ret kimliği veya işlem hash'i olabilir.
Elemanlar yine de yalnızca hash kurtarmak için taranır; sonuç hiçbir koşulda
yeniden denenebilir olmaz.
Bağlantı değerleri bir izin listesiyle kabul edilir: yokluk, sıradan
kayıt/hata nesnesi (düz nesne, prototipsiz nesne veya Error türevi) ve
güvenle taranan dizi. Set, Map, WeakSet, WeakMap, tipli diziler,
ArrayBuffer görünümleri, özel yinelenebilirler, dizi benzeri nesneler,
thenable'lar, fonksiyonlar ve tanınmayan kap prototipleri desteklenmez:
içleri güvenle sayılamadığı için dolaşım eksik işaretlenir ve içlerine
girilmez. errors alanı yalnızca dizi şeklinde taranır. Her kap türü için
ayrı bir gezinme uygulaması eklenmez; tercih edilen davranış fail-closed'dur. Kurulu yığın
gerçek cüzdan reddini cause.trace.rawError.rawError gibi derin bir yola
gömebildiği için yalnızca cause zincirini izlemek yetmez. Buna karşılık
kod 4001 tek başına ret kanıtı değildir: kurulu App Kit'te
RpcError.ENDPOINT_ERROR da 4001 kullanır (type: "RPC"), bu bir uç nokta
arızasıdır ve kit.send sonrası belirsiz bir gönderimi temsil edebilir. Ret
yalnızca olumlu bir iptal kimliğinden tanınır ve grafikte geçerli bir
işlem hash'i varsa asla ret sayılmaz.
Kurulu viem (2.55.19) onay bekleme zaman aşımında hash'i tipli bir alanda
tutmaz, yalnızca hata cümlesinin içinde geçirir; kurulu adaptör de bu çağrıyı
sarmalamaz. Hash olmadan işlem ArcScan'de bulunamayacağı için bu tek durumda
metin okunur — ama yalnızca hata adı birebir tutuyorsa, tam cümle
kalıbıyla ve katı hash doğrulayıcısıyla. Rastgele bir mesajın içinden hash
çıkarılmaz. Sonuç yine de belirsizdir: onay alınamamıştır.
Belirsiz durumda kullanıcıya önce MetaMask işlem geçmişini ve ArcScan'i kontrol
etmesi söylenir. Ayrıca chainId + requestId anahtarlı, gizli veri
içermeyen yerel bir işaretçi tutulur; aynı tarayıcıda kazara ikinci gönderimi
azaltır.
Kayıt talep başına ayrı bir localStorage anahtarındadır (v2 şeması).
Tek bir ortak dizi kullanılsaydı, farklı talepler eşzamanlı yazdığında "oku,
diziyi değiştir, yaz" adımları birbirinin kaydını silebilirdi. Yazılan kayıt
geri okunur ve chainId, requestId, sonuç, sahip jetonu, zaman damgası ve
varsa işlem hash'i birebir doğrulanır; doğrulanamazsa gönderime
geçilmez. Sonuç pending / success / reverted / unknown olarak ayrı
ayrı saklanır ve geçerli bir işlem hash'i de kalıcıdır: sayfa yenilense bile
ArcScan mutabakat bağlantısı görünmeye devam eder. Bozuk hash asla yazılmaz;
depoda bozulmuş bir hash sonucu düşürmez, yalnızca bağlantı gösterilmez.
Bu işaretçi yetkili bir koruma değildir.
localStoragecihaz ve tarayıcı başınadır: gizli sekmede, başka bir cihazda veya temizlenmiş depoda hiçbir şey hatırlanmaz.unknownkaydı ödemenin yapıldığını da yapılmadığını da söylemez; yalnızca sonucun doğrulanamadığını söyler.
Sonuç, SDK'nın belgelenmiş BridgeStep sözleşmesine göre yorumlanır. Başarı
için hem state: 'success' hem de geçerli bir işlem hash'i gerekir;
tek başına geçerli hash yeterli değildir. chain_revert / reverted_onchain
gibi kategoriler asla "ödendi" sayılmaz, ayrı bir terminal sonuç üretir ve
hash ArcScan bağlantısı için korunur. pending, noop veya sınıflandırılamayan
durumlar belirsizdir.
kit.send başladıktan sonra serbest metin eşleştirilmez: "insufficient
confirmations" gibi bir mesaj işlemin gönderilmediğini kanıtlamaz. Yalnızca
EIP-1193 kodu 4001 veya SDK'nın errorCategory: 'user_rejected' sınıfı yayın
öncesi sayılır — SDK dokümanı da makine kararları için errorCategory
kullanılmasını önerir.
Gönderime girmeden hemen önce chainId + requestId kaydı eşzamanlı olarak
yeniden okunur; success, unknown veya pending görülürse gönderim
engellenir. Gönderim başlamadan önce pending rezervasyonu yazılır, diğer
sekmeler storage olayı ve BroadcastChannel ile uyarılır ve mümkünse Web
Locks ile aynı anda tek gönderim çalışır. Rezervasyon yalnızca yayın öncesi
olduğu kanıtlanmış hatalarda serbest bırakılır; başarı, revert ve belirsiz
sonuçta korunur.
Cüzdan akışı açılmadan önce en az 60 saniye kalan süre aranır; bu kontrol
preflight ve App Kit kurulumundan sonra, kit.send'den hemen önce bir kez
daha yapılır. Teklif basımı da bu payı gözetir: paydan kısa ömürlü bir teklif
üretilmez.
Sınır: doğrudan bir ERC-20 transferinde, cüzdan istemi açıldıktan sonra son tarihi zincire dayatmanın yolu yoktur. Kullanıcı istemi dakikalarca açık bırakıp onaylarsa işlem, süresi dolmuş bir kurla zincire düşebilir. Gerçek değerli kullanım bunun için zincir üstü son tarih veya ayrık imzala-yayınla mimarisi gerektirir.
Bu sürüm Arc Testnet içindir. Gerçek parasal değer taşıyan bir dağıtım için aşağıdakiler hâlâ gereklidir ve bu depoda yoktur:
- Yetkili tekrar oynatma engeli —
requestId'nin arka uçta veya zincir üstünde atomik olarak tüketilmesi. Tarayıcı içi işaretçi ve bağlantının tek kullanımlık sayılması bunun yerine geçmez. - Zincir üstü son tarih uygulaması — teklif/talep bitişini zincirin kendisinin dayatması için bir ödeme sözleşmesi veya imzalama ile yayınlamanın ayrıldığı bir akış. Şu anki süre kontrolleri istemci ve sunucu tarafındadır; imzalanmış bir işlem gecikmeli olarak yayınlanırsa zincir bunu engellemez.
- Dağıtık kaynaktan pencere tüketimi — iki koruma katmanı da yerinde (aşağıya bakınız), ama yeterince farklı IP'den gelen istek paylaşılan çağrı bütçesini yine de tüketebilir ve kur ~1 dakika boyunca alınamaz hâle gelir. Bu bir maliyet riski değil, kısa süreli bir erişilebilirlik riskidir; kalıcı çözümü kenar katmanında bot yönetimidir.
- Aynı tarayıcı dışında tekrar engeli — Web Locks,
localStorageveBroadcastChannelyalnızca tek tarayıcı içindir. Başka cihaz, başka tarayıcı veya gizli sekme hiçbir şey bilmez; yetkili engel için arka uçta ya da zincir üstünde atomikrequestIdtüketimi şarttır.
Kur alınamaz veya doğrulanamazsa elle girilen bir kura düşülmez ve ödeme talebi oluşturulamaz. Borçlu tarafında teklif doğrulanamazsa cüzdan ve gönderim kontrolleri hiç gösterilmez.
Kur verisi CoinGecko'dan alınır ve arayüzde Data provided by CoinGecko bağlantısıyla belirtilir.
Arc Testnet USDC'sinin gerçek parasal değeri yoktur. Gösterilen kur yalnızca test amaçlıdır ve hiçbir gerçek varlığı temsil etmez.
Dağıtımda şu sunucu ortam değişkenleri tanımlanmalıdır:
OPENAI_API_KEYCOINGECKO_DEMO_API_KEYRATE_QUOTE_SECRETDATABASE_URL(paylaşılan ortak hesap deposu; Part 2'de zorunlu olacak)
Hepsi NEXT_PUBLIC_ olmadan, yalnızca sunucu tarafı değişken olarak eklenir.
Mobil cüzdan bağlantısı isteniyorsa ayrıca NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID
tanımlanır; bu tek değişken bilinçli olarak istemci paketine girer.
Bu değişkenler derleme anında koda gömülür, çalışma anında okunmaz. Vercel'e ekleyip kaydetmek tek başına hiçbir şey değiştirmez: yeni bir derleme gerekir.
Boş bir commit itmek bunun için yeterli OLMAYABİLİR — kaynak ağacı birebir aynı kaldığında Vercel önceki derleme çıktısını yeniden kullanabilir ve değişken yine pakete girmez. Güvenilir iki yol:
- Vercel panelinde Redeploy, ve "Use existing Build Cache" işaretini kaldırarak;
- ya da kaynağı gerçekten değiştiren bir commit itmek.
Değerin pakete girip girmediği doğrudan ölçülebilir: derlemeden sonra istemci paketinde değişkenin ADI kalmamalıdır. Kaldıysa yerine koyma olmamış demektir.
grep -rl "env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID" .next/staticNeon veritabanı bu bölümde sağlanmadı; Marketplace kurulumu, geçişin
uygulanması ve DATABASE_URL'in tanımlanması ayrı bir adımdır.
Ayrıca bir dağıtım gereksinimi vardır ve kod tarafında karşılanamaz: CoinGecko kotasını örnekler arasında koruyacak paylaşılan bir sayaç/oran sınırlayıcı (Redis/KV) ya da Vercel firewall/rate limiting yapılandırması. Uygulamadaki soğuma süreç içidir ve tek başına yeterli değildir.
Bugünkü akışta fişi ödeyen kişi her borçlu için ayrı bir talep imzalar ve
ayrı bir bağlantı üretir. Hedef mimari tek bağlantıdır: ödeyen cüzdanını bir
kez bağlar, her borçlu için bir adres girer, tek bir EIP-712 manifest imzalar
ve herkes aynı /pay/<billId> bağlantısını alır.
Part 1 yalnızca TEMELİ kurar. İmzalı manifest, kalıcı depo, katı doğrulama, oluşturma API'si ve oluşturucu arayüzü hazırdır. Borçlu tarafı henüz yoktur:
/pay/<billId>rotası, ödeme rezervasyonu ve işlem kesinleştirme Part 2'dedir. Bu yüzden yeni akışSHARED_BILL_FLOW_ENABLEDbayrağıyla kapalıdır ve üretimde eski, borçlu başına ayrı bağlantı üreten akış çalışmaya devam eder.
Tek bağlantı, borç listesini URL'ye gömemez: aksi hâlde bağlantıyı eline geçiren
herkes bütün borçluları, adresleri ve tutarları görürdü ve bağlantı
kilometrelerce uzun olurdu. Bu yüzden liste sunucuda saklanır ve URL yalnızca
tahmin edilemez bir kimlik taşır. Üretimde Neon Postgres (Vercel
Marketplace üzerinden) kullanılır; sunucu değişkeni DATABASE_URL'dir.
| Saklanır | Saklanmaz |
|---|---|
| Genel hesap kimliği (0x + 64 hex) | Fiş görseli |
| Şema sürümü, Arc chainId | Ürün satırları, vergi/servis/indirim |
| Alıcı adresi ve etiketi | OpenAI çıktısı |
| Borç listesinin kriptografik taahhüdü | API anahtarları, HMAC etiketi |
| Borç sayısı, veriliş/bitiş anı | Kur teklifi (bilerek) |
| Alıcının EIP-712 imzası | İlgisiz katılımcılar |
| Borç satırları: adres, etiket, borç kimliği, TRY minor tutar |
Kur bilerek saklanmaz. Alıcı yalnızca TRY minor unit borçları imzalar; USDC tutarı, borçlu ödediği anda alınan taze ve sunucu kimliklendirmeli bir USDC/TRY teklifinden türetilir (Part 2). Böylece günlerce yaşayan bir bağlantı, dakikalar ömürlü bir kura çakılmaz.
Ayrı bir EIP-712 alanı kullanılır (Split Bill Shared Bill); ödeme talebi
imzası paylaşılan hesap imzası olarak kullanılamaz. İmza şunları kapsar:
schemaVersion, billId, chainId, recipient, recipientLabel,
debtsRoot, debtCount, issuedAt, expiresAt.
Borç listesi manifeste gömülmez; yerine kanonik bir taahhüt imzalanır:
- Her satır alan ayrılmış bir yaprak özetine indirgenir.
- Satırlar kanonik sıraya dizilir: borçlu adresine göre (küçük harf) artan. Borçlu adresi hesap başına benzersiz olduğu için bu sıra tamdır — girdi sırası ne olursa olsun aynı manifest üretilir.
- Yapraklar konumsal bir Merkle ağacıyla tek bir köke çıkarılır; kök
ayrıca şema sürümü,
chainId,billIdve satır sayısını bağlar.
Ayrıntı ve gerekçe için aşağıdaki Merkle gizlilik modeli bölümüne bak. Part 1'deki toplu (aggregate) hash, bir borçlunun kendi satırını diğerlerini görmeden doğrulamasına izin vermediği için değiştirildi.
JSON.stringify çıktısı taahhüt olarak kullanılmaz: anahtar sırası ve
Unicode kaçışları uygulamadan uygulamaya değişir. Sunucu, gönderilen satırlardan
taahhüdü yeniden hesaplar; istemcinin bildirdiği debtsRoota güvenilmez.
POST /api/shared-bills — Content-Type: application/json, Cache-Control: no-store.
İstek: { manifest, debts, signature }. Yanıt (201 yeni / 200 idempotent):
{ "billId": "0x…", "path": "/pay/0x…", "expiresAt": 1700000000 }Borç listesi, adresler, etiketler, taahhüt ve imza dönmez. Hata kodları:
INVALID_CONTENT_TYPE, BODY_TOO_LARGE, MALFORMED_JSON, DUPLICATE_FIELD,
INVALID_SHARED_BILL, INVALID_SIGNATURE, BILL_ID_UNAVAILABLE,
STORAGE_REJECTED, SERVICE_NOT_CONFIGURED, SERVICE_UNAVAILABLE.
Sıra kritiktir: gövde sınırlanır → yinelenen anahtar taranır → manifest, satırlar ve taahhüt doğrulanır → alıcı imzası doğrulanır → ancak ondan sonra veritabanı işlemi açılır. Hesap ve tüm borç satırları atomik yazılır; kısmi bir hesap kalmaz.
Idempotency: aynı billId ile gelen tekrar, yalnızca depodaki taahhüt,
alıcı ve imza birebir eşleşirse güvenli sayılır ve yeniden yazılmaz. Aksi hâlde
üzerine yazılmaz, BILL_ID_UNAVAILABLE döner.
Hepsi HttpOnly oturum çerezi ister ve Cache-Control: private, no-store
döner. Hiçbiri istemciden tutar, kur, alıcı veya borç kabul etmez.
| Uç nokta | Gövde | Ne yapar |
|---|---|---|
POST …/payment/prepare |
(yok) | Taze sunucu kuruyla yetkili teklif basar. Borcu rezerve etmez, cüzdan çağırmaz. |
POST …/payment/claim |
{ offerId } |
Borcu atomik rezerve eder, tek deneme yaratır, gönderim snapshot'ını döner. |
POST …/payment/outcome |
{ attemptId, outcome, txHash? } |
İstemci sonucunu katı enum ile alır. success kabul edilmez. |
POST …/payment/finalize |
{ attemptId, txHash } |
Arc Testnet'ten makbuzu doğrular; borcu yalnızca kanıt varsa paid yapar. |
GET …/payment/status |
(yok) | Yalnızca kimliği doğrulanmış borçlunun kendi ödeme durumu. |
Yanıtlar başka bir borç satırı, başka bir katılımcı, tam manifest listesi, ham oturum jetonu, HMAC etiketi ya da veritabanı ayrıntısı taşımaz.
.env.local içine (bu dosya asla commit edilmez):
DATABASE_URL=Şema elle uygulanır; tablolar istek işleyicisi içinde tembel oluşturulmaz. Geçişler numara sırasıyla ve eksiksiz uygulanır — biri atlanırsa uygulama derlenir ve testler geçer, ama o tabloya dokunan akış çalışma anında düşer.
Uygulama ve takip scripts/migrate.mjs ile yapılır:
npm run migratenpm run migrate -- --applyArgümansız çalışma hiçbir şey uygulamaz; yalnızca durumu yazar. Ayrıntı ve tek seferlik temel alma adımı için aşağıdaki Geçiş çalıştırıcısı bölümüne bak.
Geçişlerin listesi ve sırası:
| Geçiş | Ne ekler | Atlanırsa ne kırılır |
|---|---|---|
0001_shared_bills.sql |
Ortak hesap şeması; Part 3'ün ödeme tabloları da bunun içindedir | Ortak hesabın tamamı |
0002_app_users.sql |
Uygulama kullanıcıları (Google kimlik eşlemesi) | Google girişi |
0003_shared_bill_owner.sql |
Hesabı oluşturan kullanıcı (sahiplik atfı) | "Hesaplarım" listesi ve sahiplik |
0004_saved_contacts.sql |
Kayıtlı kişiler (kullanıcının adres defteri) | Kişi kaydetme ve öneriler |
0005_receipt_analysis_quota.sql |
Fiş analizi kotası (OpenAI maliyet sınırı) | Kota sayımı — analiz sınırsız çalışır |
0006_provider_call_budget.sql |
Örnekler arası CoinGecko çağrı bütçesi | Örnekler arası koruma — kur servisi tek örneklik korumaya düşer |
0007_provider_rate_cache.sql |
Örnekler arası kur önbelleği (son gözlem) | Paylaşılan önbellek — kur yalnızca süreç belleğinde tutulur; krediyi kapamayan örnekler kullanıcıya hata döner |
0008_schema_migrations.sql |
Uygulanmış geçişlerin kaydı (sürüm, ad, checksum) | Takip — hangi geçişin uygulandığı yine bilinmez |
Bu listenin eksiksizliği bir testle zorlanır (migrations.test.ts):
migrations/ altına eklenen ama burada anılmayan bir dosya kapıyı düşürür.
DATABASE_URL yoksa uygulama ve testler yine derlenir; depo gerektiren rota
kontrollü 503 SERVICE_NOT_CONFIGURED döner ve bellek içi bir yedeğe
asla düşmez.
Geçişlerin hepsi IF NOT EXISTS kullanır, yani bugüne kadar tekrar
çalıştırmak zararsızdı. Bu garantiye yaslanılmaz: IF NOT EXISTS var olan
bir tabloyu değiştirmez, bu yüzden ilk ALTER TABLE ... ADD COLUMN yazıldığı
gün tekrar çalıştırmak ya düşer ya iki kez uygulanır. Çalıştırıcı bu yüzden
neyin uygulandığını kaydeder ve uygulanmışı bir daha çalıştırmaz.
Part 3'ün ödeme tabloları (teklif, deneme ve borç ödeme durumu) ayrı bir dosya
yerine aynı 0001 geçişine eklenmişti; o karar alındığında şema henüz
hiçbir ortamda uygulanmamıştı. Bu gerekçe artık geçerli değildir:
CREATE TABLE IF NOT EXISTS var olan bir tabloyu değiştirmez, bu yüzden
bundan sonra eklenecek her alan kendi geçiş dosyasını ister.
0008'e kadar geçişler elle uygulanıyordu ve hangisinin uygulandığını kaydeden hiçbir şey yoktu; tek öğrenme yolu veritabanına bakıp "şu tablo var mı" diye çıkarım yapmaktı. Onbeş test kullanıcısındayken veritabanı silinip yeniden kurulabilir; binde kurulamaz.
Üç mod vardır ve varsayılan hiçbir şey uygulamaz:
| Komut | Ne yapar |
|---|---|
npm run migrate |
Durumu yazar: uygulanmış, bekleyen, sorunlu. Hiçbir geçiş uygulamaz |
npm run migrate -- --apply |
Bekleyenleri sırayla uygular |
npm run migrate -- --baseline NNNN |
Tek seferlik; aşağıya bak |
Tek istisna: üç mod da, daha ilk okumadan önce kayıt tablosunun kendisini
(0008) koşulsuz uygular. Tavuk-yumurta: kaydı okuyabilmek için tablonun var
olması gerekir, ama tabloyu getiren de bir geçiştir. Dosya IF NOT EXISTS
kullandığı için bu tekrarlanabilir ve uygulama şemasına dokunmaz — yalnızca
takip tablosunu yaratır. Yani argümansız çalıştırmak da veritabanına yazar;
"salt okunur" değildir.
Çalışma biçimi:
- Geçiş ve kaydı AYNI işlemdedir. Dosyanın sarmalayıcı
BEGIN;/COMMIT;satırları çıkarılır, gövde ile kayıt satırı tek işleme alınır. Aksi hâlde arada kalan bir çökme, uygulanmış ama kaydedilmemiş bir geçiş bırakırdı. SQL noktalı virgülden bölünmez — dize içindeki bir noktalı virgülde bozulan klasik hata budur; yalnızca kendi satırında duran iki anahtar sözcük çıkarılır ve dosyanın gerçekten öyle sarıldığı doğrulanır. - Checksum tutulur. Uygulanmış bir dosya sonradan düzenlenirse çalıştırıcı durur. Depo ile veritabanının sessizce ayrışması, başka hiçbir kontrolün görmediği bir hata sınıfıdır.
- Sıra dışı geçiş reddedilir. İki dal paralel çalışıp geç merge edildiğinde 0003 uygulanmışken 0002 sonradan gelebilir; sessizce uygulanırsa şema kimsenin gözden geçirmediği bir sırayla oluşur.
- Dağıtımda otomatik çalışmaz ve bu bilinçlidir: kötü bir geçiş siteyi indirir.
DATABASE_URLasla yazdırılmaz — ne günlüğe, ne hata mesajına.- Bağlantı 15 saniyeyle sınırlıdır. Bu değer olmadan
connect()sonsuza kadar bekler: ağ takılırsa araç hata vermez, sadece susar — ve elle çalıştırılan bir araçta susmak hata vermekten kötüdür. Süre cömerttir çünkü Neon uykudaki bir dalı uyandırırken birkaç saniye harcayabilir; amaç yavaşı kesmek değil, ölünün sonsuza sürmesini engellemek. Bağlantı kurulamazsa şema değişmez ve hiçbir geçiş uygulanmaz. - Sorgulara zaman aşımı KONMAZ, bilerek.
query_timeoutistemci genelindedir; koyulsaydı büyük bir tabloyu dolduran meşru bir geçiş de yarıda kesilirdi. Bir geçişi beklemek, onu kesmekten iyidir.
Çalıştırıcı, geçişleri zaten elle uygulanmış bir veritabanına geliyor. O
geçişler yeniden çalıştırılmamalı, ama kaydedilmezlerse "bekliyor" sanılır.
--baseline, verilen sürüme kadarki geçişleri çalıştırmadan kaydeder:
npm run migrate -- --baseline 0007Sınır elle verilir ve varsayılanı yoktur. "Hepsini işaretle" demek, henüz uygulanmamış bir geçişi de uygulanmış saymak olurdu — sessizce atlanan bir tablo en pahalı hata sınıfıdır. Bu yüzden komut, veritabanında gerçekten uygulanmış son sürümü ister.
İddia ayrıca sınanır: işaretlenecek her geçişin yarattığı tabloların
gerçekten var olduğu doğrulanır; biri yoksa temel alma reddedilir. Yalnızca
ALTER TABLE yapan bir geçiş (ör. 0003) doğrulanamaz ve bu açıkça bildirilir.
Temel alma yalnızca boş bir kayıt tablosunda çalışır, yani bir kez.
Sıra: önce argümansız npm run migrate — hangi geçişin gerçekten
uygulandığını o yazar, tahmin edilmez. Sonra veritabanında gerçekten var
olan son sürümle --baseline, sonra --apply. Bundan sonrası sadece
--apply.
Bugünkü somut durum: 0007 depoya yeni girdi ve elle uygulanmadıysa sınır
0006'dır; --apply o zaman 0007'yi de uygular. 0007 zaten elle
uygulanmışsa sınır 0007'dir. Sadece bu ikisini ayırt etmek için elle SQL
çalıştırmaya gerek yoktur — durum çıktısı zaten söyler. Eksik bir geçişi
elle uygulamak, çalıştırıcının önlemek için var olduğu hatanın ta kendisidir.
Testin kapsamı, migrate.test.ts: karar
mantığının tamamı — hangi geçiş bekliyor, hangisi sorunlu, sarmalayıcı ayıklama,
tablo çıkarımı. Kapsamadığı: gerçek uygulama, çünkü CI'da Postgres yoktur.
Neon veritabanı Frankfurt'tadır (eu-central-1). Vercel fonksiyonları da
vercel.json içindeki "regions": ["fra1"] ile aynı yere sabitlenmiştir —
fra1 Vercel'in Frankfurt bölgesidir ve Neon'unkiyle aynı AWS bölgesine
denk gelir.
Bu alan yazılmazsa Vercel iad1e (Washington) düşer; belgelenmiş
varsayılan budur ve hiçbir yerde uyarı vermez. O hâlde her SQL gidiş-dönüşü
Atlantik'i geçerdi:
| Fonksiyon ↔ veritabanı | Ödeme hazırlama (5-7 ardışık sorgu) | |
|---|---|---|
iad1 (varsayılan) |
~90 ms | ~450-630 ms yalnızca ağ beklemesi |
fra1 (Neon ile aynı) |
birkaç ms | ~10-15 ms |
Kazanç iki yönlüdür: kullanıcılar da Türkiye'de olduğu için fra1 onlara da
Washington'dan çok daha yakındır.
Statik dosyalar etkilenmez — onlar her bölgeye dağıtılır; bu ayar yalnızca fonksiyonların nerede çalıştığını belirler.
Tek bölge bilinçlidir. Hobby planı zaten tek bölge seçtirir; ötesinde, ikinci bir bölge oradaki fonksiyonları veritabanından uzağa koyar ve düzeltilen sorunu geri getirir.
Seçim vercel-region.test.ts ile kapıda
tutulur. Testin kanıtlayamadığı şey: veritabanının gerçekten Frankfurt'ta
olduğu — o bilgi DATABASE_URL içindedir ve bu depoda okunmaz. Veritabanı bir
gün taşınırsa bu bölge de elle değiştirilmelidir.
Bağlantıyı eline geçiren herkes hesabı açabilir. Bağlantı borç listesini taşımaz, ama sayfayı açmak listeyi getirir (Part 2). Bu yüzden bağlantı yalnızca ilgili kişilerle paylaşılmalıdır. Adresler, etiketler, imzalar, manifestler ve üretilen bağlantılar loglanmaz; hata mesajları belirli bir cüzdanın bir hesapta olup olmadığını açığa vurmaz.
Hesaplar sonludur: üst sınır yedi gündür
(SHARED_BILL_MAX_LIFETIME_MS). Süresi dolmuş bir hesap, fiziksel olarak
silinmemiş olsa bile kullanılamaz sayılır; sorgular her hâlükârda bitiş anını
filtreler. Fiziksel temizlik (cleanup job) Part 2'ye ertelenmiştir: bugün
süresi dolmuş satırlar tabloda kalmaya devam eder.
Herkes aynı /pay/<billId> bağlantısını alır. Bir borçlunun yalnızca
kendi borcunu görebilmesi için bağlı cüzdanın kontrolünü kanıtlaması
gerekir:
- Cüzdanını bağlar, Arc Testnet'e geçer.
- Sunucudan bir erişim meydan okuması ister.
- İşlem olmayan bir EIP-712 mesajı imzalar.
- Sunucu imzayı doğrular, nonce'u tüketir ve kısa ömürlü bir oturum kurar.
/meyalnızca o borçlunun tek satırını ve Merkle kanıtını döner.- Tarayıcı manifesti, alıcı imzasını ve kanıtı bağımsız doğrular.
| Yapar | Yapmaz |
|---|---|
| Adresin kontrolünü kanıtlar | Token onaylamaz (approve değildir) |
| Tek bir hesap için görüntüleme yetkisi verir | Transfer yetkisi vermez |
| Beş dakika geçerlidir | İşlem göndermez |
| Ödeme talebi/hesap oluşturamaz |
İmza bir kimlik veya KYC kanıtı değildir ve borcun gerçek dünyada meşru olduğunu kanıtlamaz. Yalnızca "bu adresi kontrol eden kişi buradayım" der.
Erişim imzası ayrı bir EIP-712 alanındadır (Split Bill Shared Bill Access). Ne paylaşılan hesap manifesti ne de ödeme talebi imzası buraya
geçebilir; bu imza da oralarda kullanılamaz.
Part 1'de borç taahhüdü tüm satırların tek bir toplu (aggregate) hash'iydi. Bu, bir borçlunun kendi satırını doğrulayabilmesi için ona tüm borç satırlarını vermeyi gerektirirdi — yani herkes herkesin adresini, adını ve tutarını görürdü. Bu, tek bağlantının gizlilik hedefiyle doğrudan çelişiyordu.
Bu yüzden taahhüt bir Merkle kökü oldu (debtsRoot) ve şema sürümü 2'ye
yükseltildi; sürüm 1 manifestleri fail-closed reddedilir. Sürüm 1 hiçbir
yerde yayımlanmadı ve hiçbir veritabanına yazılmadı.
- Yaprak: alan ayrılmış etiket + şema sürümü + chainId + billId + borçlu
adresi +
keccak256(etiket)+keccak256(borç kimliği)+ TRY minor tutar. - Sıra: borçlu adresi (küçük harf) artan. Adres hesap başına benzersiz olduğu için sıra tamdır; girdi sırası değişse de aynı kök çıkar.
- İç düğüm: konumsal (sol, sağ) — sıralanmış çift kullanılmaz.
- Tek sayıda düğüm: son düğüm bir üst seviyeye taşınır (kopyalanmaz); kopyalama aynı yaprağın iki kez sayılmasına izin verirdi.
- Kök: ağaç kökü + şema + chainId + billId + borç sayısı.
- Kanıt: yalnızca
leafIndexve kardeş özetleri. Yön ve kardeş varlığı kanıttan okunmaz,leafIndex+debtCounttan türetilir; bu yüzden yön çevrilemez, kanıt uzatılamaz veya kısaltılamaz.
Bir borçlu böylece kendi satırını doğrular ama başka hiçbir satırın adresini, etiketini, borç kimliğini veya tutarını öğrenemez.
Hepsi Cache-Control: no-store; hiçbiri hesap kimliği, adres, nonce, imza,
oturum jetonu veya etiket loglamaz.
| Uç nokta | Ne yapar | Ne döner |
|---|---|---|
POST /api/shared-bills/[billId]/challenge |
Meydan okuma üretir. Hesabın var olup olmadığına bakmaz, veritabanına dokunmaz | { challenge, tag } — hiçbir hesap verisi yok |
POST /api/shared-bills/[billId]/resolve |
Etiketi + EIP-712 imzayı doğrular, nonce'u atomik tüketir, oturum kurar | { authenticated: true } + HttpOnly çerez |
GET /api/shared-bills/[billId]/me |
Oturumu doğrular | manifest, alıcı adresi/etiketi, tek borç satırı, Merkle kanıtı, bitiş, durum |
Hedef (audience) yalnızca sunucudaki APP_ORIGINdan gelir. Host,
Origin, Referer ve X-Forwarded-Host başlıklarına asla güvenilmez.
Üyelik sızdırılmaz: hesabın olmaması, kapalı olması, süresinin dolması ve cüzdanın o hesapta bulunmaması aynı genel yanıtı üretir.
| Şey | Süre |
|---|---|
| Erişim meydan okuması | 5 dakika (üst sınır) |
| Oturum | 15 dakika (üst sınır; veritabanı kısıtı da uygular) |
| Paylaşılan hesap | 7 gün (üst sınır) |
Ham oturum jetonu yalnızca HttpOnly; SameSite=Strict; Path=/ çerezinde
taşınır (üretimde ayrıca Secure). Depoda yalnızca SHA-256 özeti saklanır:
veritabanını okuyan biri geçerli bir çerez üretemez. Jeton URL'de, JSON
gövdesinde, tarayıcı deposunda, logda, React state'inde veya HTML'de hiç
yer almaz.
Nonce tek kullanımlıktır ve atomik olarak tüketilir; eşzamanlı iki çözümleme denemesinden en fazla biri başarılı olur.
SHARED_BILL_AUTH_SECRET=
APP_ORIGIN=SHARED_BILL_AUTH_SECRET— meydan okuma zarfının HMAC sırrı (en az 32 karakter).RATE_QUOTE_SECRETile paylaşılmaz: birini ele geçiren diğerinin etiketlerini üretemez.APP_ORIGIN— güvenilen üretim origin'i (https://…, yol/sorgu içermez).
Üretimde ikisinden biri eksik veya bozuksa erişim uçları kontrollü
503 SERVICE_NOT_CONFIGURED döner. Geliştirmede (NODE_ENV !== "production")
APP_ORIGIN için açık http://localhost:3000 yedeği kullanılır; üretimde
bu yedeğe asla düşülmez.
| Tablo | Saklanır | Saklanmaz |
|---|---|---|
shared_bill_auth_nonces |
billId, nonce, borçlu adresi, tüketim/bitiş anı | — |
shared_bill_sessions |
jetonun SHA-256 özeti, billId, borçlu adresi, chainId, bitiş | ham jeton, fiş, ürün, API anahtarı |
Süresi dolmuş satırlar fiziksel olarak silinmemiş olsalar bile kullanılamaz sayılır; sorgular her hâlükârda bitiş anını filtreler. Sınırlı ve fırsatçı bir temizlik vardır (çağrı başına en fazla 50 satır); sınırsız silme yapan bir istek yolu yoktur.
- Ödeme yok: kur çekilmez, tahmin alınmaz, App Kit çağrılmaz, hiçbir işlem gönderilmez.
- Ödeme rezervasyonu, işlem kesinleştirme ve "ödendi" durumu yok.
- Neon/Vercel sağlanmadı; geçiş uygulanmadı.
- Oluşturucu akışı hâlâ
SHARED_BILL_FLOW_ENABLED = falseile kapalı; üretimde eski, borçlu başına bağlantı üreten akış çalışmaya devam ediyor.
Bir veritabanı satırı, zincir üstünde yinelenen transferleri engellemez ve kriptografik bir tek-kullanım garantisi değildir. Cüzdan imzası borcun meşruluğunu kanıtlamaz. Bir istemcinin "başarılı" demesi ödemenin yapıldığını kanıtlamaz. Bu sürüm Arc Testnet içindir ve mainnet'e hazır değildir.
Part 3, kimliği doğrulanmış borçlu sayfasına tam Arc Testnet ödeme yaşam döngüsünü ekler: taze sunucu kuru → tam sayı tutar türetme → tahmin → inceleme → sunucu rezervasyonu → mevcut gönderim sınırı → sunucunun zincirden makbuz doğrulaması → ancak o zaman "ödendi".
Borç (shared_bill_debts.payment_status):
unpaid ──claim──> reserved ──onaylı makbuz──> paid (SON)
^ │
│ ├── KANITLI yayın öncesi hata ──> unpaid
│ ├── onaylı REVERT makbuzu ──────> unpaid
└──────────────────┘
└── belirsiz sonuç ────────> review_required
review_required ── OTOMATİK ──/──> paid (ASLA; elle mutabakat)
review_required ── OTOMATİK ──/──> unpaid (ASLA; elle mutabakat)
paid ──/──> herhangi bir durum (ASLA geri dönmez)
Deneme (shared_bill_payment_attempts.status):
reserved ── istemci hash bildirdi ──────> submitted
reserved ── KANITLI yayın öncesi hata ──> released (SON)
reserved | submitted ── onaylı makbuz ──> confirmed (SON)
reserved | submitted ── revert makbuzu ─> reverted (SON)
reserved | submitted ── çözülemedi ─────> unknown (SON)
confirmed, reverted, unknown ve released son durumlardır.
submitted durumundan serbest bırakma yoktur: kit.send çağrıldıktan
sonra rezervasyon açılmaz. unknown da rezervasyonu tutar — belirsiz bir
deneme kendiliğinden yeniden denenebilir hâle gelmez.
Veritabanı kısıtları: borçlu başına en fazla bir aktif deneme (kısmi
benzersiz indeks), küresel olarak benzersiz işlem hash'i, teklif başına en
fazla bir deneme, numeric(30, 0) tam sayı tutarlar, kanonik kur paydası
(10^0..10^6) ve paid satırın doğrulanmış hash + zaman taşıma zorunluluğu.
Tüm geçişler karşılaştır-ve-yaz'dır; son yazan kazanan güncelleme yoktur.
Kur manifeste gömülmez. Borçlu ödemeye başladığında sunucu taze bir
USDC/TRY teklifi basar (POST /api/shared-bills/<billId>/payment/prepare).
Sunucu bunu kendi kur servisinden doğrudan alır; uygulamanın kendi
/api/rates rotasına HTTP isteği yapmaz. Mevcut CoinGecko önbelleği, soğuma,
doğrulama, HMAC ve altı ondalık kanonikleştirme aynen kullanılır. Elle
girilen bir kura asla düşülmez: kur servisi çalışmıyorsa teklif basılmaz.
Teklif ne kurdan ne de bağlantıdan uzun yaşar (min alınır) ve gönderim
için gereken 60 saniyelik paydan kısa ömürlü bir teklif hiç üretilmez.
POST /api/shared-bills/<billId>/payment/claim gövdesi yalnızca teklif
kimliğini taşır. Tutar, kur, alıcı ve borç istemciden kabul edilmez;
hepsi saklanan hesaptan okunur ve mikro USDC bu adımda yeniden türetilip
teklifle birebir karşılaştırılır. Rezervasyon başarılı olana kadar kit.send
erişilemezdir. Yanıt, mevcut gönderim sınırının beklediği değişmez
snapshot'tır; istemci onu incelediği teklifle birebir karşılaştırır ve tek
bir alan bile farklıysa göndermez.
POST /api/shared-bills/<billId>/payment/finalize yalnızca deneme kimliği ve
aday işlem hash'i alır. Tüm ekonomik alanlar saklanan denemeden gelir.
Sunucu Arc Testnet'e kendisi bağlanır ve şunların hepsini arar:
- zincir kimliği Arc Testnet,
- makbuz var ve durumu başarılı,
- en az 1 onay (
ARC_MIN_CONFIRMATIONS), - kayıtlar tam olarak Arc Testnet USDC ERC-20 sözleşmesinden,
Transfer(address,address,uint256)katı çözümleme (üç konu, adres konularının üst 12 baytı sıfır, veri tam 32 bayt),- gönderen = kimliği doğrulanmış borçlu, alıcı = imzalı manifestteki alıcı,
- eşleşen tüm borçlu→alıcı transferlerinin BigInt toplamı, beklenen mikro USDC'ye birebir eşit.
"En az bir eşleşen olay" yetmez: toplam beklenenden az da fazla da olamaz.
Borç yalnızca bu doğrulama geçerse paid olur; hesap ise her borç bağımsız
olarak onaylandığında kapanır.
Makbuz yoksa veya onay yetersizse: kontrollü beklemede durumu, rezervasyon korunur, sınırlı yoklamaya izin verilir, ödendi denmez.
Yoklama aralığı üstel geri çekilir: 4 → 6 → 9 → 13,5 → 20 saniye, son bekleme pencerenin kalanı. Önceden her 4 saniyede bir soruluyordu; 40 saniye süren bir onayda bu 10 istek, her biri iki veritabanı sorgusu demekti. Onay ilk saniyelerde gelmediyse bir sonraki saniyede de gelme ihtimali düşüktür.
Toplam pencere değişmedi ve bu bilinçli: 60 saniye, yani eski 15 × 4 sn
ile birebir aynı. Kullanıcıya verilen söz "yaklaşık bir dakika izliyoruz,
sonra tekrar bak"tır; geri çekilmeyi pencereyi büyüterek uygulamak o sözü
sessizce üç dakikaya çevirirdi. Değişen tek şey aynı pencerede kaç kez
sorulduğu: 15 istek yerine 7 — yarısından fazlası gitti, ilk saniyelerdeki
deneyim aynı.
Çizelgenin toplamının pencereye tam oturması ve tavanın gerçekten devreye
girmesi testle tutulur
(poll-backoff.test.ts). Tavan olmasaydı
çarpan pencerenin sonunda tek bir uzun beklemeye dönüşürdü: onay 21. saniyede
gelse bile kullanıcı 40. saniyeye kadar bunu görmezdi.
Revert: kanıt
saklanır, hash ArcScan için korunur, borç ödenmemiş kalır. Sonuç
çözülemiyorsa: unknown / review_required, kilit korunur, otomatik tekrar
yoktur.
unknown bir deneme son durumdur: sonradan gelen bir makbuz doğrulaması
bile onu otomatik olarak confirmede taşımaz ve review_required bir borcu
paid yapmaz. Bu bilerek katıdır — belirsiz bir denemenin hash'ine bağlanmış
herhangi bir makbuz, kilidi tek başına açabilirdi. Bu durumdan çıkış, insan
tarafından yürütülen açık bir mutabakat gerektirir (ArcScan + cüzdan geçmişi).
Bu Arc Testnet MVP'si bir onayı yeterli sayar: makbuz bir bloğa girmiştir. Bu, derin bir yeniden düzenleme (reorg) karşısında kesinlik değildir. Test USDC'sinin gerçek parasal değeri olmadığı için bu denge kabul edilmiştir; gerçek değer taşıyan bir ağda bu sayı yükseltilmelidir.
POST /api/shared-bills/<billId>/payment/outcome katı bir enum kabul eder:
rejected, insufficientFunds, preflightFailed, submitted, ambiguous.
success yoktur: istemci bir ödemeyi başarılı ilan edemez. Elinde bir hash
varsa submitted bildirir ve mutabakatı sunucu yapar. Serbest bırakan üç
sonuç hash taşıyamaz ve yalnızca mevcut sınıflandırıcının yayın öncesi
olduğunu kanıtladığı hâllerde üretilir.
Kötü niyetli bir istemci "reddedildi" diye yalan söyleyebilir ve uygulama düzeyindeki rezervasyonu açtırabilir. Bu zincir üstü bir garanti değildir; cüzdan her transfer için borçlunun kendi onayını istemeye devam eder, dolayısıyla bu yalan kimseye para harcatamaz — yalnızca ikinci bir denemeye izin verir.
Gönderim sınırındaki ArcPaymentSnapshot.tryMinor artık number değil,
kanonik ondalık metintir. Gösterilen, tahmin edilen, rezerve edilen,
gönderilen ve mutabakatı yapılan tutarın hepsi aynı tam sayıdan türer;
hiçbir aşamada parseFloat, Number(...) veya kayan nokta para aritmetiği
kullanılmaz. Eski (borçlu başına ayrı bağlantılı) ödeme talebi akışı
davranışını korur: imzalı gövde zaten kanonik ondalık metin taşıdığı için
sınırda daraltma yapılmadan aktarılır.
Manifest katmanının kendi sınırı korunmuştur: canonicalizeSharedBillDebts
borç tutarlarını bilerek Number.MAX_SAFE_INTEGER ile sınırlar ve bu görevde
gevşetilmemiştir. Ödeme yolunun tamamının daraltma yapmadığı, satırı
doğrudan depoya yerleştiren ayrı regresyon testleriyle ölçülür.
- Sunucu veritabanı bir akıllı sözleşme değildir. Rezervasyon, aynı borcun uygulama üzerinden iki cihazdan/oturumdan ödenmesini engeller; kullanıcının kendi cüzdanından, uygulamanın dışında ikinci bir ERC-20 transferi göndermesini engelleyemez.
- Tarayıcı tarafındaki doğrulamalar zincir üstü güvenlik değildir.
- Bir bağlantının varlığı gerçek dünyada bir borcun var olduğunu kanıtlamaz; cüzdan imzası bir kimlik/KYC kanıtı değildir.
- Arc Testnet içindir ve mainnet'e hazır değildir; test USDC'sinin gerçek parasal değeri yoktur.
- Kur servisi iki katmanla korunur: örnekler arası çağrı bütçesi (kod) ve IP başına oran sınırlama (Vercel Firewall). Kalan risk yalnızca dağıtık kaynaktan gelen kısa süreli pencere tüketimidir.
Bu bölüm bir zamanlar Part 4'e ERTELENENLERİ sayıyordu. O maddelerin hepsi kapandı; liste artık yapılmış olanı kaydediyor:
- Neon sağlandı ve geçişlerin hepsi uygulandı
(
0001_shared_bills.sql→0005_receipt_analysis_quota.sql). DATABASE_URLveSHARED_BILL_AUTH_SECRETtanımlandı; dağıtım yapıldı ve uygulama Vercel'de yayında.- PR'lar açıldı ve birleştirildi.
- Gerçek bir cüzdanla Arc Testnet üzerinde canlı transfer tamamlandı. Doğrulama artık yalnızca enjekte edilmiş belirlenimci sahtelere dayanmıyor.
- Oluşturucu akışı açıktır:
SHARED_BILL_FLOW_ENABLED = true. Bunu bir test zorlar, yani sessizce geri kapanamaz.
Listedeki son açık madde olan örnekler arası oran sınırlama da kapandı: kur servisi artık paylaşılan bir çağrı bütçesi kullanıyor (aşağıya bakınız).
Kişi bazlı sınırlama da yerinde: Vercel Firewall'da GET /api/rates/usdc-try
için IP başına 60 saniyede 10 istek kuralı etkindir ve aşıldığında 429
döner (2026-09-06'da panelde doğrulandı). Bu bir dağıtım ayarıdır, depoda
kodu yoktur — firewall kuralı kapatılırsa bu koruma da kalkar.
Fiş analizinin OpenAI maliyeti bundan ayrıdır ve zaten korunuyordu: günlük
genel tavan (DAILY_ANALYSES_TOTAL) kullanıcı başına hakla birlikte
Postgres'te receipt_analysis_quota üzerinde atomik olarak ayrılır, bu
yüzden örnek sayısından bağımsızdır.
Kur servisinin önbelleği, tek uçuşu ve soğuması süreç içidir: yalnızca tek bir Node.js örneğini korur. Vercel'de her soğuk başlangıç ve her eşzamanlı sunucusuz örnek kendi kopyasını taşıdığı için toplam CoinGecko hızını hiçbir şey sınırlamıyordu.
migrations/0006_provider_call_budget.sql bunu kapatır. Yukarı akışa
gerçekten gidilecekse, önce provider_call_budget tablosundan bir kredi
atomik olarak ayrılır; ayrılamazsa CoinGecko hiç çağrılmaz.
- Sayaç çağrıyla orantılıdır, istekle değil. Ayırma yalnızca önbellek ıskaladığında sorulur; önbellekten karşılanan istekler sayaca hiç uğramaz.
- Ayırma tek uçuşun içindedir. Eşzamanlı istekler tek bir krediyi paylaşır; blok dışında beklenseydi ikinci istek aynı pencere için ikinci bir kredi ayırırdı.
- Bütçe reddi bir sağlayıcı hatası değildir. Ardışık hata sayacı artmaz ve üstel soğuma tetiklenmez — sağlayıcı sapasağlamken arızalı sanılmaz.
- Pencere ve sınır
src/lib/rates/provider-budget.tsiçindedir. Kova hesabı tam sayı bölmedir; SQL ve TypeScript aynı hesabı yapar, sunucu saat dilimine bağlı değildir.
Kesintide engellenmez (bilinçli karar): sayaca ulaşılamazsa bugünkü süreç içi korumaya düşülür — bugünkünden kötü değildir, ve kur servisini kapatmak bir veritabanı hıçkırığını ödeme akışının durmasına çevirirdi. Sessiz de kalmaz: pencere başına bir kez günlüğe düşer.
DATABASE_URL tanımlı değilse bu bir olay sayılmaz ve günlüğe düşmez;
yerel geliştirmede ve testlerde beklenen durumdur. Ama o zaman örnekler
arası koruma da yoktur — koruma geçişin uygulanmış olmasına bağlıdır.
Ayrıca APP_ORIGIN yerelde tanımlı değildir. Geliştirmede sorun çıkarmaz,
çünkü DEVELOPMENT_APP_ORIGIN (http://localhost:3000) devreye girer. Üretimde
ise zorunludur: eksikse ortak hesap erişim uçları
(challenge ve resolve) 503 SERVICE_NOT_CONFIGURED döner.
0006 çağrıların sayısını paylaştırdı ama sonucunu paylaştırmadı. Tek
başına bu bileşim zararlıdır: limit paylaşılıp sonuç paylaşılmayınca korumanın
bedelini önbellek isabeti değil kullanıcı öder. Penceredeki krediyi
kapamayan eşzamanlı örnekler — taze kur yan taraftaki örneğin belleğinde
dururken — exhausted görüp "kur alınamadı" döndürüyordu. Arıza kullanıcı
sayısıyla değil eşzamanlılıkla büyür; ilk kalabalık akşamda görülür.
migrations/0007_provider_rate_cache.sql bunu kapatır. Önbellek artık iki
katmanlıdır:
| Katman | Nerede | Ne yapar |
|---|---|---|
| L1 | süreç belleği | Bedava ve anlık; soğuk başlangıçta boş, örnekler arası paylaşılmaz |
| L2 | provider_rate_cache |
Bütün örneklerin gördüğü son gözlem |
Sıra: L1 → L2 → soğuma → bütçe → sağlayıcı.
- L2, soğuma kontrolünden ÖNCE okunur. Soğuma yukarı akışa gitmeyi durdurmak içindir; paylaşılan önbellekten okumak yukarı akışa gitmez. Ters sırada, bütçe reddi yüzünden soğumaya girmiş örnek — düzeltmenin konusu tam da o örnek — elinin altındaki taze kuru yine kullanamazdı.
- Tazelik ölçütü L1 ile birebir aynıdır: hem depolama TTL'i hem de
QUOTE_MAX_OBSERVATION_AGE_MSyaş sınırı. Verinin paylaşılan bir yerden gelmesi hiçbir doğrulamayı gevşetmez; teklif her durumda taze basılır (yeniquoteId, yeni pencere, yeni HMAC etiketi). - Yazma monotondur ve bunu SQL zorlar: depoda daha yeni bir gözlem varsa üzerine yazılmaz. Yavaş dönen bir yanıt taze veriyi ezemez.
- Yazma beklenir, ateşle-unut değil: sunucusuz çalışmada yanıt döndükten sonra bekleyen iş çalışmayabilir. Bedeli yalnızca sağlayıcıya gidilen yolda tek bir sorgudur.
- L2 okunamazsa engellenmez: geçiş uygulanmadan da kod doğru çalışır ve eski tek örneklik davranışa düşer. Sessiz kalmaz — pencere başına bir kez günlüğe düşer, ki uygulanmamış geçiş görünür olsun.
TTL 60 → 240 saniye. Teklif ömrü min(300 sn, 600 sn − gözlem yaşı)
olduğu için 300 saniyeye kadar eskimiş bir gözlem hâlâ tam 300 saniyelik
teklif verir; 240 saniyelik TTL bu eşiğin altında kalır ve teklif ömründen
hiçbir şey götürmez. Eski 60 saniye, izin verilen gözlem yaşının altıda
biriydi ve bedelini CoinGecko Demo kotasından ödüyordu.
Kalan sınır: iki örnek tam aynı anda gelip ikisi de L2'yi boş bulursa, kazanan yazana kadar kaybeden yine hata döndürebilir. Bunu tümüyle kapatmak dağıtık bir kilit ister; TTL başına bir kez ve saniyenin altında süren bu artık, düzeltilen sürekli duruma göre önemsizdir.
GET /api/admin/metrics. Bu uçtan önce depoda console.error dışında hiçbir
ölçüm yoktu: Vercel'in runtime günlükleri kısa saklamalı, toplanmıyor ve alarmı
yok. Onbeş test kullanıcısında bu sorun değildi; binde, biri "çalışmıyor"
dediğinde bunun herkeste mi tek kişide mi olduğunu söyleyecek hiçbir şey
yok demektir.
Yeni tablo gerektirmez. Veritabanı bu sayıları zaten tutuyordu —
shared_bill_payment_attempts.status bedava bir huni, receipt_analysis_quota
günlük analizi, provider_call_budget sağlayıcı çağrılarını veriyor. Eksik
olan tek şey onları okuyan bir yoldu. Altı sorgu tek işlemde çalışır:
sayaçlar tutarlı bir andan gelir ve tek gidiş-dönüş olur.
Yanıt ham sayıların yanında eşikleri de taşır, çünkü ham bir sayı "harekete geç" demez:
| Alan | Ne zaman bakılmalı |
|---|---|
analyses.warning |
Günlük tavanın %80'i aşıldı; bugün içinde bakılmalı |
retention.behind |
Silinmeye uygun sayısı parti sınırına ulaştı — temizlik geriye düşüyor |
attempts.needsAttention |
reverted + unknown; zincirde ne olduğu bilinmeyen ödemeler |
debts.needsReview |
review_required borç satırları |
provider.windowsAtCap |
Bütçesi dolmuş pencereler; her biri reddedilmiş bir kur çağrısı |
SALT OKUR. Hiçbir sayacı tüketmez, hiçbir satır yazmaz; ölçmek için ölçüleni değiştirmez.
Yalnızca toplam döner. Kullanıcı kimliği, cüzdan adresi, hesap kimliği,
etiket ya da işlem hash'i bu yanıttan geçmez — sayım için gereken "kaç
tane", "kim" değil. Bu uç tanımı gereği ayrıcalıklıdır ve ayrıcalıklı bir ucun
sızdırdığı şey en çok sızan şeydir. Sınır iki yerde zorlanır: SQL'in çıktı
sütunları izinli bir listeyle sınırlıdır
(metrics-driver.test.ts) ve yanıtın
tamamı gerçek şekilli veriyle taranır
(metrics-route.test.ts).
Uç METRICS_SECRET ile korunur ve sır tanımlı değilse hiç çalışmaz —
/api/cron/retention ile aynı karar. Yetkisiz yanıt tek biçimdir; sırrın var
olup olmadığını bile sızdırmaz.
curl -sS -H "Authorization: Bearer $METRICS_SECRET" https://<alan-adi>/api/admin/metricsSayaçlar "kaç tane" der; hata takibi "ne kırıldı" der. İkisi ayrı işler ve biri ötekinin yerine geçmez.
SENTRY_DSN tanımlıysa src/instrumentation.ts
sunucu ve edge çalışma zamanlarında Sentry'yi başlatır. Tanımlı değilse SDK
hiç yüklenmez — yerel geliştirme, testler ve CI hata takibi olmadan çalışır
ve bu bir hata sayılmaz.
Kapsam bilinçli olarak SUNUCU + EDGE ile sınırlı. Tarayıcıya hiçbir şey gönderilmez:
connect-srchâlâ ölçülüyor ve kapatılmayı bekliyor; tarayıcı tarafı bir SDK oraya kalıcı bir üçüncü taraf hedefi ekler ve o işi zorlaştırır.- Uygulama mobil öncelikli; paket ağırlığının bedeli var, karşılığı ise sunucuda zaten yakalanan hatalar.
Sınır bir testle tutulur: instrumentation-client.ts ya da
sentry.client.config.ts diskte belirirse
sentry-config.test.ts düşer ve
CSP kararı yeniden sorulur. DSN de bilerek NEXT_PUBLIC_ değildir.
Hata takibi, tanımı gereği uygulamanın iç durumunu üçüncü bir tarafa
gönderir. Varsayılan haliyle bağlamak bu depodaki gizlilik sınırını sessizce
delerdi — en sinsi yol URL'dir: bill_id bir yol parametresidir
(/pay/0x…), yani istisna mesajında hiçbir şey olmasa bile her olayla
birlikte giderdi.
scrub.ts bu yüzden tek tek alanlara değil,
olayın tamamına uygulanır — anahtarlar dahil. Yaklaşım şekil tanımadır,
alan listesi değil: alan saymak, yeni bir alan eklendiği gün sessizce eksik
kalır.
| Şekil | Yerine | Kapsadığı |
|---|---|---|
0x + 20 ve üzeri hex |
0x<hex:N> |
Adres (40), hesap/işlem/nonce/oturum (64), imza (130) |
| UUID | <uuid> |
Uygulama kullanıcısı, kayıtlı kişi |
| E-posta | <email> |
Google girişi |
Değer silinmez, uzunluğu bırakılır: hata ayıklayan kişi "burada bir hesap
kimliği vardı" ile "burada bir adres vardı"yı ayırt edebilmeli. Kısa
onaltılık sayılar (0x2b74 gibi chainId) korunur — kimlik değiller ve
gerçekten gerekiyorlar.
Şekil tanımayla korunamayan şeyler tümüyle silinir: user, ve istekteki
cookies / headers / data / env. Bir yetkilendirme başlığı ya da istek
gövdesi, tanınacak hiçbir desene uymadan sır taşır. request.url kalır
(temizlenmiş olarak): hangi rotanın düştüğü görülmeli.
Ayrıca sendDefaultPii: false açıkça yazılır ve başarım izleme kapalıdır
(tracesSampleRate: 0) — işlem adları URL'den türer, ve aranan şey "ne
kırıldı", "ne kadar sürdü" değil.
withSentryConfigsarmalayıcısı eklenmedi. Tek getirisi kaynak haritası yüklemesi ve sürüm etiketlemedir; ikisi de Sentry kimlik bilgileri ister ve karşılığında derleme yapılandırması değişir. Yığın izleri derlenmiş dosyaya işaret eder — hangi rotanın neyle düştüğünü görmek için yeterli. Kimlik bilgileri geldiğinde ayrı bir iş olarak eklenebilir.- Tarayıcı SDK'sı yok (yukarıdaki gerekçe).
- Analiz OpenAI Responses API ile yapılır:
openai.responses.parse(...). - Yapılandırılmış çıktı için Zod şeması
openai/helpers/zodpaketindekizodTextFormat(...)iletext.formatolarak verilir. - Görsel, doğru MIME type ile base64 data URL olarak
input_imageiçinde gönderilir. - İstek
store: falseile yapılır; yanıt OpenAI tarafında saklanmaz. - Çağrı yalnızca
POST /api/receipts/analyzeroute handler'ında, Node.js runtime'da çalışır. API anahtarı istemciye hiçbir biçimde gönderilmez,NEXT_PUBLIC_*değişkeni yoktur. - Yüklenen görsel yalnızca istek süresince bellekte tutulur; diske yazılmaz, veritabanı yoktur.
- Sunucu, istemcinin bildirdiği dosya türüne güvenmez: boyut, MIME type ve dosyanın gerçek imza baytları yeniden doğrulanır. Bildirilen tür boşsa dosya yalnızca imza baytları geçerli bir JPEG/PNG/WEBP gösteriyorsa kabul edilir; uzantıya asla bakılmaz. Data URL daima imzadan tespit edilen türle kurulur.
Tüm para değerleri minor unit (tam sayı) olarak taşınır: 1 TL = 100 kuruş. Uygulama state'inde hiçbir yerde floating-point para tutulmaz.
Sunucudaki OpenAI çağrısı 30 saniye sonra kesilir (timeout), otomatik retry
kapalıdır (maxRetries: 0) — böylece demoda bekleme süresi öngörülebilir kalır.
Timeout, SDK'nın kendi APIConnectionTimeoutError sınıfıyla yakalanır ve
ANALYSIS_TIMEOUT koduyla 504 olarak döner. İstemci ayrıca AbortController
ile 35 saniyelik bir üst sınır uygular; istek takılırsa "Analiz zaman aşımına
uğradı" mesajı gösterilir, seçilen görsel korunur ve tekrar denenebilir.
Yukarıdaki 30 saniye uygulamanın kendi zaman aşımıdır. Bir de altında platformun kestiği süre vardır ve ikisi karıştırılmamalıdır.
Vercel'in varsayılanı — fluid compute açıkken, her planda — 300 saniyedir. Bu uygulamada hiçbir rotanın bütçesi 60 saniyeyi geçmez, yani varsayılan gerçek ihtiyacın 5-20 katıdır. Fark bedava değildir: asılı kalan bir istek beş dakika boyunca bir örneği meşgul eder ve kullanıcı o süre boyunca temiz hata mesajını göremez.
Bu yüzden her rota kendi tavanını export const maxDuration ile bildirir:
| Kademe | Süre | Nerede | Bütçe |
|---|---|---|---|
fast |
15 sn | 16 rota | Yalnızca veritabanı; ve varsa 5 sn'lik kur çağrısı |
rpc |
30 sn | payment/finalize |
Üç ARDIŞIK Arc RPC çağrısı × 8 sn |
slow |
60 sn | receipts/analyze, cron/retention |
OpenAI 30 sn + 10 MB gövde · altı tabloda 500'lük partiler |
Kural: tavan, rotanın kendi bütçesinin hemen ÜSTÜNDEDİR. Sıra önemlidir —
uygulama kendi zaman aşımını yakalayıp anlamlı bir hata döndürebilmelidir
(ANALYSIS_TIMEOUT → 504); platform onu daha önce keserse o hata yolu hiç
çalışmaz ve kullanıcı kopuk bir bağlantı görür.
Değer sayı sabiti olmak zorundadır: Next.js rota segmenti yapılandırmasını
derleme anında statik okur, içe aktarılmış bir sabiti çözemez. Bu yüzden ortak
bir sabit modülü yoktur; kademeler
function-duration.test.ts içinde
tanımlıdır ve orada koddaki gerçek zaman aşımı sabitlerine bağlanır. Analiz
zaman aşımı bir gün 30 saniyeden büyütülürse test düşer.
Kapsam rota işleyicileridir. Sayfalar platform varsayılanında kalır ve bu bilinçlidir: sunucu tarafında dış çağrı yapmazlar, oturum durumu JWT'den okunur.
Türkiye'deki fişlerde KDV çoğunlukla ürün satır fiyatlarının içindedir ve fişte yalnızca bilgilendirme amacıyla yazılır. Böyle bir KDV'yi ürün toplamının üzerine tekrar eklemek yanlış genel toplam ve ileride çift borçlandırma üretir.
Bu yüzden vergi, servis ücreti ve indirimin her biri kendi treatment değerini taşır:
| Değer | Anlamı |
|---|---|
included_in_items |
Tutar ürün fiyatlarının içinde; toplama tekrar uygulanmaz |
separate |
Vergi/servis ürünlerin üzerine eklenir, indirim düşülür |
unknown |
Fişten güvenle anlaşılamadı |
Model bu değeri fişte görünen matematikten belirler (ürün toplamları, ara
toplam ve genel toplam ilişkisinden), "KDV" satırının varlığından değil.
Belirleyemezse unknown döner.
Toplam kontrolü yalnızca separate işaretli kalemleri uygular. Sıfırdan farklı
bir kalem unknown ise yanlış bir uyuşmazlık iddiası üretilmez; sonuç
"belirsiz" olarak gösterilir. Kullanıcı her satırdaki seçimi arayüzden
değiştirebilir ve toplam kontrolü anında güncellenir; hiçbir değer otomatik
olarak değiştirilmez.
- Fiş yükleme ve analiz: sürükle-bırak, dosya doğrulama (JPG/PNG/WEBP, en fazla 10 MB), OpenAI Responses API ile ürün çıkarımı, düzenlenebilir sonuç.
- Vergi / servis / indirim için "fiyatlara dahil mi, ayrı mı" seçimi ve buna göre doğru genel toplam kontrolü.
- Ürünleri kişilere atama ve borç hesabı: tam sayı minor unit ile, kayan nokta kullanmadan.
- Arc Testnet USDC ödemesi iki akışta (zincire dokunan tek yol
src/lib/arc/transfer-client.ts, doğrudan viem ile: simüle et → gönder → makbuzu oku):- Borçlu başına ayrı bağlantı (imzalı ödeme talebi, EIP-712 şema 3:
zincir, USDC sözleşme adresi, alıcı, borçlu, tutar ve sunucu kur
teklifi imzanın içindedir; şema 2 token'ı imzalamıyordu ve artık
reddedilir).
Yalnızca test ağında (
standalone-request-feature.ts): akış sunucusuzdur, tekrar oynatma engeli yereldir ve gerçek parada bu kabul edilemez; test ağı olmayan bir profilde/payödeyiciyi hiç kurmaz ve oluşturma ekranı bu akışı sunmaz. - Tek bağlantılı ortak hesap: alıcı tek bir manifest imzalar, herkes aynı bağlantıyı alır, her borçlu cüzdanıyla kimliğini doğrulayıp yalnızca kendi borcunu görür ve öder.
- Borçlu başına ayrı bağlantı (imzalı ödeme talebi, EIP-712 şema 3:
zincir, USDC sözleşme adresi, alıcı, borçlu, tutar ve sunucu kur
teklifi imzanın içindedir; şema 2 token'ı imzalamıyordu ve artık
reddedilir).
Yalnızca test ağında (
- Otomatik USDC/TRY kuru: CoinGecko'dan alınan, sunucu tarafından HMAC ile kimliklendirilen taze teklif; elle kur girişi yoktur.
- Sunucu tarafı ödeme yaşam döngüsü: yetkili teklif, atomik rezervasyon, zincir üstü makbuz doğrulaması ve ancak ondan sonra "ödendi".
- Kalıcı depo: Neon Postgres; şema gözden geçirilmiş bir SQL geçişiyle elle uygulanır.
Başlıklar src/lib/security/headers.ts içinde VERİ olarak durur;
next.config.ts yalnızca listeyi bağlar. Böylece testler gerçek değerleri
okuyabiliyor.
Bugün uygulananlar: X-Frame-Options: DENY, X-Content-Type-Options,
Referrer-Policy, Permissions-Policy. Hepsi CSP'den bağımsız çalışır ve
hiçbir isteği kırma riski taşımaz.
En somut kazanç çerçeveleme: ödeme sayfası daha önce birinin sitesinde
iframe'e alınabiliyordu. X-Frame-Options bunu bugün kapatıyor.
CSP uygulanıyor — connect-src hariç. İkisi birden gönderiliyor:
| Başlık | Kapsam |
|---|---|
Content-Security-Policy |
connect-src dışında her şey zorlayıcı |
Content-Security-Policy-Report-Only |
yalnızca connect-src katı, ölçüyor |
Kapsamı kanıt belirledi. Üretimde tam bir ödeme akışı rapor kipiyle
çalıştırıldı ve connect-src dışında tek bir ihlal bile çıkmadı; yani
frame-ancestors, frame-src, img-src, style-src, worker-src
kısıtlarının hepsi ölçülmüş, tahmin edilmemiştir.
script-src nonce ile katı; 'unsafe-inline' yok. Saklamasız bir cüzdan
uygulamasında tek gerçek saldırı yüzeyi ön yüzdür: sayfaya sızan bir satır
içi betik alıcı adresini değiştirir ve kullanıcı cüzdanda onaylar — her
doğrulama geçer, çünkü doğrulamayı yapan kod da ele geçmiştir. Bu yüzden CSP
statik bir başlık olamaz: src/proxy.ts her isteğe 16 baytlık taze bir nonce
üretir (src/lib/security/csp-proxy.ts), onu isteğin
content-security-policy başlığına (Next kendi önyükleme betiklerini oradan
damgalar) ve x-nonce başlığına (düzen, tema betiğini oradan damgalar) yazar.
İstemciden gelen x-nonce üzerine yazılır; dışarıdan seçilmiş bir nonce
saldırganın kendi betiğini damgalaması olurdu. next.config.ts'teki statik
liste artık CSP taşımaz — iki yerden basılsaydı tarayıcı ikisini birden
uygular, nonce'suz olan her betiği engellerdi; bir test bunu zorlar.
style-src bilerek 'unsafe-inline' kalıyor: Tailwind ve Next satır içi
stil üretir, stil enjeksiyonu betik gibi cüzdana ulaşamaz.
connect-src neden hâlâ ölçümde: o ölçüm yalnızca masaüstü/eklenti borçlu
akışını kapsadı. Mobil WalletConnect yolu ve hesabı oluşturan akış henüz
çalıştırılmadı. Eksik bir liste parayı göndermeyi kırar; onlar da temiz
çıktığında uygulanana taşınacak.
Bir test, iki politikanın connect-src dışında birebir aynı kalmasını
zorluyor — ayrışırlarsa ölçüm, uygulananla ilgisiz bir şeyi ölçmeye başlar.
Uygulama yalnızca hukuken çalışabildiği ülkelerde açılır. Liste
ALLOWED_COUNTRIES ortam değişkeninden gelir (ISO 3166-1 alpha-2, virgülle:
US,DE). Değişken tanımsız ya da boşsa kapı yoktur ve herkes girer —
bugünkü testnet dağıtımı böyle çalışır. Kod src/lib/security/geo-gate.ts,
sıralama src/proxy.ts: önce kapı, sonra CSP; engellenen istek betiksiz bir
451 sayfası alır (dil çerezden ya da tarayıcıdan, metin sözlükten) ve o
sayfa da nonce'lu CSP taşır.
Üç davranış bilinçlidir:
- Ülke vekilin başlığından okunur — Cloudflare
cf-ipcountry, Vercelx-vercel-ip-country. Başlık yoksa ülke bilinmiyor sayılır ve kapı açıkken içeri alınmaz (fail-closed). Vekili atlayıp doğrudan sunucuya gelen istek kapıyı da atlayamaz. - Yanlış yazılmış liste sessizce açmaz:
USAya daD3ilk istekte fırlatır ve dağıtım hemen görünür şekilde düşer. Sessizce açık kalan bir kapı, hiç kapı olmamasından kötüdür. - Kapı hukuki bir muafiyet değildir. Hizmeti sunan kişinin kendi ülkesindeki yükümlülüğünü, kullanıcıyı engellemek kaldırmaz. Kapı niyeti kanıtlar ve mağazaların istediği ülke sınırını uygular; o kadar.
Başlığa güvenmenin ön koşulu sunucudadır ve koddan yapılamaz: 80/443
yalnızca Cloudflare'ın IP aralıklarına açık olmalı, yoksa origin'e doğrudan
gelen biri cf-ipcountry: DE yazıp geçer. Kontrol listesi (bir kez, VPS'te):
for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do ufw allow proto tcp from "$ip" to any port 80,443; doneufw delete allow 80/tcp && ufw delete allow 443/tcp && ufw status numberednginx'e de gerçek istemci adresini öğretmek gerekir (set_real_ip_from ile
aynı aralıklar, real_ip_header CF-Connecting-IP); bugün IP tabanlı hiçbir
kısıt Cloudflare'ın adresini görmesin diye bu da bekliyordu. Cloudflare
aralıkları nadiren değişir; listeyi yenilemek aynı iki komuttur.
Play Console'daki ülke listesi ayrı bir katmandır ve mağaza görünürlüğünü belirler; bu kapı ise siteye kim girerse girsin uygulanır.
İki politika da report-uri /api/csp-report taşıyor. Konsola bakmak
masaüstünde kolay ama telefonda neredeyse imkânsız; connect-src'yi
engelleyici yapabilmek için mobil yolun ne yaptığını da bilmemiz gerekiyor.
Uç sayesinde cihaz kendisi bildiriyor ve satırlar Vercel günlüklerine düşüyor:
[csp] connect-src <- https://ornek.invalid (report)
Gizlilik, bu ucun en önemli kısıtı. Rapor gövdesi sayfanın TAM ADRESİNİ
taşır ve ortak hesap adresleri billId içerir — o bağlantıyı bilen herkes
hesabı açabilir. Bu yüzden günlüğe yalnızca yönerge ve engellenen adresin
KÖKENİ yazılır; sayfanın adresi ve engellenen adresin yolu/sorgusu atılır.
Bir test bunu zorluyor: billId benzeri bir dizge günlük satırında geçerse
test düşer.
Uç kimlik doğrulaması İSTEMEZ ve isteyemez — raporu tarayıcı, oturumdan bağımsız gönderir. Bu yüzden hiçbir şey okumaz, hiçbir şey yazmaz ve veritabanına dokunmaz; tek yaptığı kısa bir satır günlüğe düşürmek.
connect-src, tarayıcının bağlandığı adreslerle sınırlıdır ve bir test bu
listeyi gizlilik politikasındaki DISCLOSED_HOSTS ile karşılaştırır — yeni bir
dış bağlantı, politikaya da yazılmadan geçemez. OpenAI ve veritabanı burada
YOKTUR: onlara sunucu bağlanır.
Politikanın alan adı taraması kaynak kodu okur. Bir adres bağımlılığın içindeyse tarama onu göremez.
Üretimde gerçek bir ödeme akışı sırasında tam olarak bu oldu: uygulama
rpc.testnet.arc.io kullanıyor, ama Circle App Kit viem'in gömülü
arcTestnet tanımını kullandığı için tarayıcı ayrıca
rpc.testnet.arc.network adresine de bağlanıyor. İkinci sunucu politikada
bildirilmemişti ve hiçbir test bunu yakalayamazdı.
Ortaya çıkaran şey CSP'nin rapor kipi oldu. Bu, kipin varlık sebebi: bağımlılıkların gerçekte nereye bağlandığını çalışırken ölçmek.
Makbuz doğrulaması önceden tek genel adrese bağlıydı: rpc.testnet.arc.io
yavaşladığında ya da hız sınırına takıldığında makbuz okunamıyor ve ödeme
kesinleştirilemiyordu. Genel testnet RPC'leri bunu düzenli olarak yapar, ve
bu, düzeltilmesi en pahalı hata sınıfının kapısıdır: zincirde işlem
başarılıyken uygulamanın onu görememesi.
Artık Arc'ın resmî dokümanındaki adresler sırayla denenir — birincil,
sonra Blockdaemon, dRPC ve QuickNode. Hepsi aynı zinciri sunar ve
arc-receipt.ts her çağrıda chainId'yi ayrıca doğrular: yanlış zincire bağlı
bir yedek sonucu kabul ettiremez.
Süre bütçesi DEĞİŞMEDİ ve bu bilinçli. finalize üç ardışık RPC çağrısı
yapar; her uca ayrı ayrı 8 saniye verilseydi en kötü durum uç sayısıyla
çarpılır ve rotanın süre tavanını aşardı. Toplam sabit kalır, uçlara bölünür.
Takas açık: yavaş ama çalışan bir birincilden erken devredilir — sağlıklı bir
yedeğe geçmek, ölü bir uçta sekiz saniye beklemekten iyidir.
Yeniden deneme kapalıdır (retryCount: 0): burada "tekrar denemek",
sıradaki uca geçmektir. Aynı ölü uca ısrar etmek bütçeyi yer ve yedeğe hiç
sıra gelmezdi. Sıralama da ölçüme bırakılmaz (rank: false) — gecikme ölçen
bir sıralayıcı fazladan istek atar ve devretmeyi öngörülemez kılar.
Tarayıcının yüzeyi büyümez. Yedeklere yalnızca sunucu bağlanır: cüzdana
bildirilen adres (wallet_addEthereumChain) hâlâ tek, ve CSP'nin
connect-src listesi olduğu gibi kalır. Üçü de testle tutulur
(rpc-fallback.test.ts).
Gizlilik politikası ise genişler: tarayıcı gitmiyor diye bildirim
gerekmiyor değil — uygulama o sunuculara bağlanıyor. Üç adres de
DISCLOSED_HOSTS içindedir ve politika metni artık sunucunun da birden fazla
sağlayıcıya sorduğunu söyler.
Kalan yarım: RPC'ye hiç ulaşılamadığında sonuç zaten "başarısız" değil
unavailable sayılıyor ve kilit korunuyor — ama sunucu tarafında o kaydı
sonradan yeniden denemeye alan bir görev yok. Kullanıcı uygulamayı
kapatırsa uzlaştırma yalnızca istemcinin yoklamasına bağlı kalır.
script-src bilerek 'unsafe-inline' içerir. Ölçüldü: sayfa 10 satır içi
script taşıyor (Next.js önyüklemesi ve temanın yanıp sönmeyi önleyen script'i)
ve hiçbirinde nonce yok; kaldırılırsa uygulama açılmaz.
Sıkılaştırmak, her istekte nonce üreten bir middleware ister. O gelene kadar
katı sürüm Content-Security-Policy-Report-Only olarak yayında duruyor ve
ihlalleri tarayıcı konsoluna yazıyor — önce ölç, sonra uygula.
Google girişi vardı ama sayı sınırı yoktu: oturum açmış bir kullanıcı istediği kadar analiz çağırabiliyordu ve her çağrı OpenAI'de gerçek para harcıyor. Üst sınırı olmayan tek maliyet buydu.
İki sayaç, iki farklı iş (src/lib/receipt/quota.ts):
| Sayaç | Ne yapar | Değer |
|---|---|---|
| Kullanıcı başına | Adalet — bir kişi diğerlerinin hakkını yiyemez | 25/gün |
| Genel tavan | Fatura — hesap sayısından bağımsız | 250/gün |
Kullanıcı başına kota tek başına faturayı korumaz: Google hesabı açmak bedava ve sınırsızdır. Genel tavan bunun içindir.
Atomiklik — iki sayaç, TEK işlem. Genel tavan ve kullanıcı hakkı birlikte
ayrılır: ya ikisi de düşülür ya da hiçbiri. Üç deyim tek işlemde çalışır —
iki satırı sayaç artırmadan var et, quota_key sırasına göre kilitle
(FOR UPDATE), sonra ikisini de oku ve ikisi de sınırın altındaysa tek
UPDATE ile ikisini birden artır. Sınır kontrolü yazan değil okuyan
CTE'ye dayanır; kardeş CTE'ler birbirinin yazdığını görmez.
Düzeltilen kusur. Eskiden ikisi ayrı ayrı düşülüyordu ve burada sıranın "güvenli yöne saptığı" yazıyordu. Yanlıştı: genel tavan ÖNCE düşüldüğü için hakkı dolmuş bir kullanıcının reddedilen her isteği genel sayacı artırıyordu. O kullanıcı tek başına genel tavanı tüketip diğer herkesi kilitleyebiliyordu — hiç OpenAI çağrısı yapmadan. Sırayı ters çevirmek çözüm değildi; o zaman da genel tavan dolduğunda kullanıcı hakkı boşuna yanardı.
Reddedilen istek hak tüketmez. Hangi sınıra takılırsa takılsın hiçbir sayaç artmaz. Sağlayıcı hatası ise ayrı: kabul edilmiş bir istek OpenAI tarafında düşerse hak geri verilmez, bu bilinçli bir üründür kararıdır.
Arayüz iki sınırı ayırır. DAILY_LIMIT_REACHED kişinin kendi hakkının
bittiğini söyler ve kalan sıfıra çekilir; SERVICE_BUSY genel tavanın
dolduğunu söyler ve kişisel hakka dokunulmaz. Karar
src/lib/receipt/quota-feedback.ts içinde saf bir fonksiyondur.
Ne zaman düşülür. Bütün doğrulamalardan sonra, OpenAI'ye gitmeden önce. Bozuk bir dosya yüzünden hak yanmaz; sağlayıcıya ulaşan her deneme sayılır.
Gün UTC'dir. current_date veritabanının saat dilimine bağlıdır; sınırın
ne zaman sıfırlandığı sunucu ayarına bırakılmaz.
Kullanıcı kalan hakkını yükleme ekranında görür; sayıyı sunucu bildirir, istemci saymaz.
Kapı (lint, typecheck, test, build, git diff --check) her PR'da
GitHub Actions ile zorunlu çalışır: .github/workflows/ci.yml.
Asıl kazanç tek tek PR'lar değil, birleşme sonucu. Bu projede bir kez
yaşandı: #14–#18 ayrı ayrı yeşilken, birleşmiş hâlde bir test kırıldı —
gizlilik politikasının alan adı taraması, başka bir daldan gelen SVG'nin
xmlns değerini yakaladı. GitHub pull_request olayında PR'ı hedef dalla
birleştirip çalıştırdığı için tam da o sınıfı yakalar.
CI'ın yapamadıkları: cüzdan akışı, fiş analizi ve gerçek bir Postgres burada çalışmaz. Kapsam artmıyor, mevcut kapı zorunlu hâle geliyor.
Kapının kendisi de korunuyor: src/lib/ci/workflow.test.ts, package.json
betikleriyle CI'da çalıştırılanları karşılaştırır — bir adım sessizce
düşerse test kırılır.
.nvmrc ve engines bilerek eklenmedi: ikisi de Vercel'in derleme
imajını değiştirir ve bu ayrı, açıkça istenmesi gereken bir karardır.
src/lib/db/migrations.test.ts numaralandırmayı zorlar. Sebep bu depoya
özgü: birden fazla dal art arda merge ediliyor ve iki dal da 0005_ eklerse
merge sırasında çakışma olmaz — iki dosya sorunsuzca yan yana durur ve
sıra belirsizleşir. Git bunu yakalamaz, çünkü metin çakışması yoktur.
Bir denetimde çıkan ve kasıtlı olarak ele alınmayan maddeler. Her incelemede yeniden tartışılmasın diye gerekçeleriyle burada:
-
Merkezi hata izleme (Sentry vb.) eklenmedi. Yeni bir üçüncü taraf demektir; bu projede bedeli yüksektir çünkü gizlilik politikası kodda geçen her dış alan adını bildirmeye zorlar ve bir test bunu denetler. Hata yükleri cüzdan adresi taşıyabilir. Vercel'in kendi günlükleri şu ölçek için yeterli.
-
Playwright/Cypress E2E yok. En riskli yollar — cüzdan bağlama, imza, transfer — tarayıcı otomasyonuyla sürülemez. Geriye kalan kısım zaten birim testleriyle kaplı; kazanç "parçalar tarayıcıda birbirine bağlanıyor mu" sorusuyla sınırlı kalır.
-
Büyük dosyalar bölünmedi.
neon-shared-bill-repository.ts'te bütün SQL'in tek yerde olması bilinçlidir (eşleşme testleri bu dosyayı okur),tr.tsDictionarytipinin kaynağıdır,send.ts'e dokunulmaz. Satır sayısı için yeniden düzenleme, kullanıcıya hiçbir şey kazandırmadan gerileme riski taşır. -
npm auditbulguları düzeltilmedi, takip ediliyor. Aşağıdaki tablo ölçümdür, tahmin değil: derlenmiş istemci ve sunucu paketleri tarandı.24 bulgu 5 kök uyarıdan ve 4 paketten geliyor; dördü de
@circle-fin/app-kit/@circle-fin/adapter-viem-v2zincirinden.Paket Şiddet İstemci paketinde Sunucu paketinde Uygulama yolunda Güvenilmeyen girdi tomlhigh ×2 hayır evet hayır hayır stream-jsonmoderate hayır hayır hayır hayır ellipticlow evet evet dolaylı hayır uuidmoderate evet evet hayır hayır Ayrıntılar:
toml(@coral-xyz/anchor→ app-kit). Uyarılar güvenilmeyen TOML ayrıştırmakla ilgili. Sunucu paketinde bulunuyor ama çağıran kod, Anchor'ın Solana çalışma alanı yükleyicisi: girdisireadFileSync("Anchor.toml"), yani yerel bir dosya, kullanıcı girdisi değil. Bu uygulamanın kaynağındaanchor/solanahiç geçmiyor ve dağıtımda böyle bir dosya yok.stream-json(@solana/web3.js→jayson). Hiçbir pakette yok.elliptic(ethers v5 →@ethersproject/signing-key). İstemci paketinde var. Ama uygulamanın kaynağındaethershiç geçmiyor; imzayı kullanıcının cüzdanı atar. Ayrıca yamalı sürüm yok: kurulu 6.6.1, npm'deki en son sürüm de 6.6.1.uuid. Uyarıv3/v5/v6yıbufargümanıyla çağırmayı gerektiriyor. Uygulamauuidpaketini hiç kullanmıyor; kimliklernode:cryptorandomUUID'den geliyor.
npm'in önerdiği tek çare, dördü için de
@circle-fin/app-kit'i 1.12.1'den 1.8.1'e DÜŞÜRMEK. Çalıştırılmayan kod yollarındaki uyarılar için ödeme kütüphanesini geri almak kötü bir takas; bu yüzden yapılmadı. Nenpm audit fixne de--forceçalıştırılır.Yeniden bakma koşulu: app-kit yükseltildiğinde ya da
ellipticiçin yamalı bir sürüm çıktığında tablo yeniden ölçülür. Tarama komutu:npm audit --omit=dev --json, sonra derlenmiş paketlerde paket izleri.
- Yalnızca Arc Testnet. Mainnet'e hazır değildir ve mainnet/Ethereum Sepolia desteklenmez. Test USDC'sinin gerçek parasal değeri yoktur.
- Sunucu veritabanı bir akıllı sözleşme değildir: rezervasyon yalnızca uygulama üzerinden yinelenen denemeyi engeller, kullanıcının uygulama dışından ikinci bir transfer göndermesini engelleyemez.
- Google oturumu KYC değildir: hesap yalnızca fiş analizi kotasını kişiye bağlamak içindir. Ortak hesapta borçlunun kimliği hâlâ cüzdan sahipliği kanıtıyla belirlenir; oturum açmış olmak bir borcu görme hakkı vermez.
- Kur servisi iki katmanla korunur: örnekler arası çağrı bütçesi Postgres'te (kod), IP başına oran sınırlama Vercel Firewall'da (dağıtım ayarı). İkincisi depoda görünmez; kapatılırsa sessizce kalkar.
- Bağlantıyı ele geçiren biri hesabı açabilir; borç yalnızca doğru cüzdanla görülebilir ama bağlantının kendisi gizli sayılmalıdır.