Skip to content
SayloolPublic

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Split Bill

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.

Kurulum ve çalıştırma

Node.js 22 veya üzeri gerekir (Arc App Kit ileride bunu şart koşacak).

npm install

Ortam 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 dev

Uygulama http://localhost:3000 adresinde açılır.

Uygulama kabuğu (PWA)

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.

Marka işareti ve ikonlar

İş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.mjs

Betik 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.

Gizlilik politikası ve Play Console veri güvenliği

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.

Denetimin iki dürüst sonucu

  • 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, teklifleri ON DELETE RESTRICT ile 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_SECRET ile korunur ve sır tanımlı değilse hiç çalışmaz.

  • Zincire yazılan hiçbir şey geri alınamaz. Silme hakkı oraya ulaşmaz; politika bunu yumuşatmadan söyler.

Runbook: review_required bir borç

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/review

Yanı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ı.

Önce ölçülen gerçek: bu durumun kodda çıkışı YOK

Ş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 ve ALLOWED_SETTLEMENTS.unknown boştur: yeni bir makbuz doğrulaması bile onu paide taşıyamaz.
  • Yeni bir teklif ya da deneme de açılamaz; ikisi de borcun unpaid olması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.

Adımlar

  1. Listeyi al (yukarıdaki curl).
  2. txHash yoksa 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ı?
  3. txHash varsa ArcScan'de aç ve dördünü birden doğrula:
    • işlem başarılı mı (revert değil),
    • gönderen debtor adresi mi,
    • alıcı recipient adresi mi,
    • transfer edilen miktar microUsdc ile birebir eşit mi.
  4. Dördü de tutuyorsa ödeme gerçekten yapılmıştır ve borç paid olmalı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;
  1. Dördünden biri tutmuyorsa borç ödenmemiştir. Durumu değiştirme; kullanıcıya ne gördüğünü açıkla. Yanlış bir transferi paid yazmak, geri dönüşü olmayan tek hatadır — paid hiçbir koşulda geri alınmaz.

Uyarılar

  • 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.needsReview ve attempts.needsAttention sayaçlarına bak; artan bir eğri genellikle RPC tarafında bir arıza demektir.

Veri güvenliği formu eşlemesi

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

Hesap silme

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_users satırı gider (doğrulanmış e-posta, görünen ad, avatar adresi).
  • saved_contacts ON DELETE CASCADE ile gider.
  • shared_bills ON 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.

Google kimlik doğrulama temeli

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/analyze
  • POST /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.

Kimlik, oturum ve saklanan veri

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.

Cüzdan-only kalan borçlu akışı

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.

Google Cloud ve dağıtım için elle yapılacaklar

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:

  1. Google Cloud Console'da OAuth consent screen ve Web application client oluştur; yalnızca temel OpenID profil/e-posta izinlerini kullan.
  2. Authorized JavaScript origins listesine http://localhost:3000 ve https://splitstable.com ekle.
  3. Authorized redirect URIs listesine şunları birebir ekle:
    • http://localhost:3000/api/auth/callback/google
    • https://splitstable.com/api/auth/callback/google
  4. Yerel sunucuda boş örneklerden AUTH_SECRET, AUTH_GOOGLE_ID ve AUTH_GOOGLE_SECRET değerlerini güvenli biçimde tanımla; Google girişini kullanmak için APP_ORIGIN değerini de açıkça tanımla (yerelde http://localhost:3000 olabilir). AUTH_SECRET için yukarıdaki 32 rastgele bayt / 64 küçük hex üretim komutunu kullan.
  5. Sunucuda aynı üç server-only değişkeni ve APP_ORIGIN=https://splitstable.com değerini elle tanımla. Hiçbir sırra NEXT_PUBLIC_ öneki verme.
  6. İncelenen migrations/0002_app_users.sql geç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 lint
npm run typecheck
npm test
npm run build

Otomatik USDC/TRY kuru

Kur 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.

Neden sunucu imzalıyor

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.

CoinGecko Demo anahtarı nasıl alınır

  1. https://www.coingecko.com/en/api adresinden ücretsiz Demo planına kaydol.
  2. Panelden bir Demo API anahtarı üret.
  3. Anahtarı .env.local içindeki COINGECKO_DEMO_API_KEY alanına yaz.

Anahtar yalnızca x-cg-demo-api-key başlığında taşınır; sorgu dizesine konmaz ve istemciye hiç gönderilmez.

RATE_QUOTE_SECRET nasıl üretilir

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.

Akış

  1. Ödeme talebi ekranına gelindiğinde kur bir kez otomatik istenir.
  2. Sunucu GET /api/rates/usdc-try ile taze bir teklif basar.
  3. Teklif 5 dakika geçerlidir; ekranda geri sayım gösterilir.
  4. Talep yalnızca geçerli ve süresi dolmamış bir teklifle imzalanabilir.
  5. Borçlunun sayfası teklifi POST /api/rates/verify ile sunucuya doğrulatır; doğrulama geçmeden cüzdan, tahmin ve gönderim kontrolleri görünmez.
  6. Kur, tahminden hemen önce ve gönderimden hemen önce yeniden doğrulanır.

Kur biçimi ve tutar hesabı

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.

Geçerlilik süreleri

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.

Demo plan ve önbellek sınırları

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ı hatalarında soğuma (negatif önbellek)

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-After başlığı döner.
  • CoinGecko'nun Retry-After baş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.

Gönderim sonucu belirsizse

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. localStorage cihaz ve tarayıcı başınadır: gizli sekmede, başka bir cihazda veya temizlenmiş depoda hiçbir şey hatırlanmaz. unknown kaydı ö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önderim rezervasyonu (aynı tarayıcı)

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.

Gerçek değerli kullanım için eksikler

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, localStorage ve BroadcastChannel yalnı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 atomik requestId tüketimi şarttır.

Hata davranışı

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.

Atıf

Kur verisi CoinGecko'dan alınır ve arayüzde Data provided by CoinGecko bağlantısıyla belirtilir.

Arc Testnet uyarısı

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.

Vercel'e taşırken

Dağıtımda şu sunucu ortam değişkenleri tanımlanmalıdır:

  • OPENAI_API_KEY
  • COINGECKO_DEMO_API_KEY
  • RATE_QUOTE_SECRET
  • DATABASE_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.

NEXT_PUBLIC_ bir değişken eklendikten sonra

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/static

Neon 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.

Tek bağlantılı ortak hesap (Part 1 — temel)

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_ENABLED bayrağıyla kapalıdır ve üretimde eski, borçlu başına ayrı bağlantı üreten akış çalışmaya devam eder.

Neden bir veritabanı gerekiyor

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.

Ne saklanır, ne saklanmaz

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.

İmzalanan manifest

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:

  1. Her satır alan ayrılmış bir yaprak özetine indirgenir.
  2. 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.
  3. Yapraklar konumsal bir Merkle ağacıyla tek bir köke çıkarılır; kök ayrıca şema sürümü, chainId, billId ve 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.

API sözleşmesi

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.

Ödeme uç noktaları (Part 3)

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.

Veritabanı yapılandırması ve geçiş

.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 migrate
npm run migrate -- --apply

Argü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.

Geçiş çalıştırıcısı

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_URL asla 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_timeout istemci 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.

Tek seferlik temel alma

Ç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 0007

Sı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.

Fonksiyon bölgesi

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.

Gizlilik sınırı

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.

Hesap ömrü

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.

Borçlu erişimi: aynı bağlantı, yalnızca kendi borcun (Part 2)

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:

  1. Cüzdanını bağlar, Arc Testnet'e geçer.
  2. Sunucudan bir erişim meydan okuması ister.
  3. İşlem olmayan bir EIP-712 mesajı imzalar.
  4. Sunucu imzayı doğrular, nonce'u tüketir ve kısa ömürlü bir oturum kurar.
  5. /me yalnızca o borçlunun tek satırını ve Merkle kanıtını döner.
  6. Tarayıcı manifesti, alıcı imzasını ve kanıtı bağımsız doğrular.

Bu imza ne yapar, ne YAPMAZ

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.

Merkle gizlilik modeli

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 leafIndex ve 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.

Uç nokta sözleşmeleri

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.

Oturum ve süreler

Ş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.

Yeni sunucu değişkenleri

SHARED_BILL_AUTH_SECRET=
APP_ORIGIN=
  • SHARED_BILL_AUTH_SECRET — meydan okuma zarfının HMAC sırrı (en az 32 karakter). RATE_QUOTE_SECRET ile 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.

Ne saklanır (Part 2 ekleri)

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.

Part 2'de YAPILMAYANLAR

  • Ö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 = false ile 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.

Ortak hesap ödemesi (Part 3)

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".

Durum makinesi

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 ne zaman alınır

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.

Rezervasyon

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.

Zincir üstü doğrulama

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:

  1. zincir kimliği Arc Testnet,
  2. makbuz var ve durumu başarılı,
  3. en az 1 onay (ARC_MIN_CONFIRMATIONS),
  4. kayıtlar tam olarak Arc Testnet USDC ERC-20 sözleşmesinden,
  5. Transfer(address,address,uint256) katı çözümleme (üç konu, adres konularının üst 12 baytı sıfır, veri tam 32 bayt),
  6. gönderen = kimliği doğrulanmış borçlu, alıcı = imzalı manifestteki alıcı,
  7. 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).

Onay gereksinimi ve sınırı

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.

İstemci sonucu bildirimi

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.

Tam sayı gösterimi

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.

Bu sürümün SINIRLARI

  • 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.

Part 4 durumu

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_URL ve SHARED_BILL_AUTH_SECRET tanı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.

Örnekler arası çağrı bütçesi

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.ts iç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.

Örnekler arası kur önbelleği

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_MS yaş sınırı. Verinin paylaşılan bir yerden gelmesi hiçbir doğrulamayı gevşetmez; teklif her durumda taze basılır (yeni quoteId, 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.

İşletme sayaçları

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/metrics

Hata takibi (Sentry)

Sayaç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-src hâ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.

Gizlilik: olaylar gönderilmeden önce temizlenir

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.

Yapılmayanlar

  • withSentryConfig sarmalayı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).

Fiş analizi nasıl çalışıyor

  • Analiz OpenAI Responses API ile yapılır: openai.responses.parse(...).
  • Yapılandırılmış çıktı için Zod şeması openai/helpers/zod paketindeki zodTextFormat(...) ile text.format olarak verilir.
  • Görsel, doğru MIME type ile base64 data URL olarak input_image içinde gönderilir.
  • İstek store: false ile yapılır; yanıt OpenAI tarafında saklanmaz.
  • Çağrı yalnızca POST /api/receipts/analyze route 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.

Analiz timeout'u

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.

Fonksiyon süre tavanları

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.

Vergi, servis ve indirim nasıl uygulanır

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.

Şu anda çalışan özellikler

  • 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):
    1. 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.
    2. 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.
  • 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.

Güvenlik başlıkları

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.

Coğrafi kapı

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, Vercel x-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: USA ya da D3 ilk 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; done
ufw delete allow 80/tcp && ufw delete allow 443/tcp && ufw status numbered

nginx'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.

İhlaller sunucuya bildiriliyor

İ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.

Rapor kipinin yakaladığı şey

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.

Yedekli RPC (yalnızca sunucu)

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.

Bilinen eksik: script-src

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.

Fiş analizi kotası (OpenAI maliyeti)

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.

CI ve dal koruması

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.

Geçiş dosyaları

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.

Bilinçli olarak yapılmayanlar

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.ts Dictionary tipinin 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 audit bulguları 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-v2 zincirinden.

    Paket Şiddet İstemci paketinde Sunucu paketinde Uygulama yolunda Güvenilmeyen girdi
    toml high ×2 hayır evet hayır hayır
    stream-json moderate hayır hayır hayır hayır
    elliptic low evet evet dolaylı hayır
    uuid moderate 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: girdisi readFileSync("Anchor.toml"), yani yerel bir dosya, kullanıcı girdisi değil. Bu uygulamanın kaynağında anchor/solana hiç 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ğında ethers hiç 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ı buf argümanıyla çağırmayı gerektiriyor. Uygulama uuid paketini hiç kullanmıyor; kimlikler node:crypto randomUUID'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ı. Ne npm audit fix ne de --force çalıştırılır.

    Yeniden bakma koşulu: app-kit yükseltildiğinde ya da elliptic iç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.

Sınırlar ve bilinen eksikler

  • 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.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages