Ana içeriğe atla

Gizliliği koruyan uygulamaya özel bir Plasma yazın

sıfır bilgi
sunucu
zincir dışı
gizlilik
İleri
Ori Pomerantz
15 Ekim 2025
27 dakikalık okuma

Giriş

Toplamaların aksine, Plasma'lar bütünlük için Ethereum Ana Ağı'nı kullanır, ancak erişilebilirlik için kullanmaz. Bu makalede, Ethereum'un bütünlüğü (yetkisiz değişiklik olmamasını) garanti ettiği ancak erişilebilirliği garanti etmediği (merkezi bir bileşen çökebilir ve tüm sistemi devre dışı bırakabilir), bir Plasma gibi davranan bir uygulama yazıyoruz.

Burada yazdığımız uygulama, gizliliği koruyan bir bankadır. Farklı adreslerin bakiyeleri olan hesapları vardır ve diğer hesaplara para (ETH) gönderebilirler. Banka, durumun (hesaplar ve bakiyeleri) ve işlemlerin hash'lerini yayınlar, ancak gerçek bakiyeleri gizli kalabilecekleri zincir dışı bir ortamda tutar.

Tasarım

Bu, üretime hazır bir sistem değil, bir öğretim aracıdır. Bu nedenle, birkaç basitleştirici varsayımla yazılmıştır.

  • Sabit hesap havuzu. Belirli sayıda hesap vardır ve her hesap önceden belirlenmiş bir adrese aittir. Bu, sıfır bilgi ispatlarında değişken boyutlu veri yapılarını yönetmek zor olduğundan çok daha basit bir sistem sağlar. Üretime hazır bir sistem için, durum hash'i olarak Merkle kökü kullanabilir ve gerekli bakiyeler için Merkle ispatları sağlayabiliriz.

  • Bellek depolama. Üretim sisteminde, yeniden başlatma durumunda korunmaları için tüm hesap bakiyelerini diske yazmamız gerekir. Burada, bilginin basitçe kaybolması sorun değildir.

  • Sadece transferler. Bir üretim sistemi, bankaya varlık yatırmak ve çekmek için bir yol gerektirir. Ancak buradaki amaç sadece kavramı göstermektir, bu yüzden bu banka transferlerle sınırlıdır.

Sıfır bilgi ispatları

Temel düzeyde, bir sıfır bilgi ispatı, kanıtlayıcının bazı verileri (Dataprivate) bildiğini gösterir; öyle ki bazı açık veriler (Datapublic) ile Dataprivate arasında bir ilişki (Relationship) vardır. Doğrulayıcı, Relationship ve Datapublic verilerini bilir.

Gizliliği korumak için durumların ve işlemlerin gizli olması gerekir. Ancak bütünlüğü sağlamak için durumların kriptografik hash'inin (opens in a new tab) açık olması gerekir. İşlem gönderen kişilere bu işlemlerin gerçekten gerçekleştiğini kanıtlamak için işlem hash'lerini de yayınlamamız gerekir.

Çoğu durumda, Dataprivate sıfır bilgi ispatı programının girdisi, Datapublic ise çıktısıdır.

Dataprivate içindeki bu alanlar:

  • Staten, eski durum
  • Staten+1, yeni durum
  • Transaction, eski durumdan yeni duruma geçişi sağlayan bir işlem. Bu işlemin şu alanları içermesi gerekir:
    • Transferi alan Hedef adres
    • Transfer edilen Miktar
    • Her işlemin yalnızca bir kez işlenebilmesini sağlamak için Nonce. Kaynak adresin işlemde bulunmasına gerek yoktur, çünkü imzadan kurtarılabilir.
  • Signature, işlemi gerçekleştirmeye yetkili bir imza. Bizim durumumuzda, bir işlemi gerçekleştirmeye yetkili tek adres kaynak adrestir. Sıfır bilgi sistemimiz bu şekilde çalıştığı için, Ethereum imzasına ek olarak hesabın açık anahtarına da ihtiyacımız vardır.

Datapublic içindeki alanlar şunlardır:

  • Hash(Staten) eski durumun hash'i
  • Hash(Staten+1) yeni durumun hash'i
  • Hash(Transaction) durumu Staten değerinden Staten+1 değerine değiştiren işlemin hash'i.

İlişki birkaç koşulu kontrol eder:

  • Açık hash'ler gerçekten de gizli alanlar için doğru hash'lerdir.
  • İşlem, eski duruma uygulandığında yeni durumla sonuçlanır.
  • İmza, işlemin kaynak adresinden gelir.

Kriptografik hash fonksiyonlarının özellikleri nedeniyle, bu koşulları kanıtlamak bütünlüğü sağlamak için yeterlidir.

Veri yapıları

Birincil veri yapısı, sunucu tarafından tutulan durumdur. Her hesap için sunucu, hesap bakiyesini ve tekrarlama saldırılarını (replay attacks) (opens in a new tab) önlemek için kullanılan bir nonce (opens in a new tab) değerini takip eder.

Bileşenler

Bu sistem iki bileşen gerektirir:

  • İşlemleri alan, işleyen ve hash'leri sıfır bilgi ispatlarıyla birlikte zincire gönderen sunucu.
  • Hash'leri depolayan ve durum geçişlerinin meşru olduğundan emin olmak için sıfır bilgi ispatlarını doğrulayan bir akıllı sözleşme.

Veri ve kontrol akışı

Çeşitli bileşenlerin bir hesaptan diğerine transfer yapmak için iletişim kurma yolları şunlardır.

  1. Bir web tarayıcısı, imzalayanın hesabından farklı bir hesaba transfer isteyen imzalı bir işlem gönderir.

  2. Sunucu işlemin geçerli olduğunu doğrular:

    • İmzalayanın bankada yeterli bakiyeye sahip bir hesabı vardır.
    • Alıcının bankada bir hesabı vardır.
  3. Sunucu, transfer edilen miktarı imzalayanın bakiyesinden çıkarıp alıcının bakiyesine ekleyerek yeni durumu hesaplar.

  4. Sunucu, durum değişikliğinin geçerli olduğuna dair bir sıfır bilgi ispatı hesaplar.

  5. Sunucu, Ethereum'a şunları içeren bir işlem gönderir:

    • Yeni durum hash'i
    • İşlem hash'i (böylece işlemi gönderen kişi işlemin işlendiğini bilebilir)
    • Yeni duruma geçişin geçerli olduğunu kanıtlayan sıfır bilgi ispatı
  6. Akıllı sözleşme sıfır bilgi ispatını doğrular.

  7. Sıfır bilgi ispatı doğrulanırsa, akıllı sözleşme şu eylemleri gerçekleştirir:

    • Mevcut durum hash'ini yeni durum hash'i ile günceller
    • Yeni durum hash'i ve işlem hash'i ile bir günlük girdisi yayınlar

Araçlar

İstemci tarafı kodu için Vite (opens in a new tab), React (opens in a new tab), Viem (opens in a new tab) ve Wagmi (opens in a new tab) kullanacağız. Bunlar endüstri standardı araçlardır; eğer onlara aşina değilseniz, bu öğreticiyi kullanabilirsiniz.

Sunucunun büyük bir kısmı Node (opens in a new tab) kullanılarak JavaScript ile yazılmıştır. Sıfır bilgi kısmı Noir (opens in a new tab) ile yazılmıştır. 1.0.0-beta.10 sürümüne ihtiyacımız var, bu yüzden Noir'ı belirtildiği gibi kurduktan (opens in a new tab) sonra şunu çalıştırın:

noirup -v 1.0.0-beta.10

Kullandığımız blokzincir, Foundry (opens in a new tab)'nin bir parçası olan yerel bir test blokzinciri olan anvil'dir.

Uygulama

Bu karmaşık bir sistem olduğu için onu aşamalar halinde uygulayacağız.

Aşama 1 - Manuel sıfır bilgi

İlk aşama için, tarayıcıda bir işlem imzalayacağız ve ardından bilgileri manuel olarak sıfır bilgi ispatına sağlayacağız. Sıfır bilgi kodu, bu bilgileri server/noir/Prover.toml içinde almayı bekler (burada (opens in a new tab) belgelenmiştir).

Bunu çalışırken görmek için:

  1. Node (opens in a new tab) ve Noir (opens in a new tab) kurulu olduğundan emin olun. Tercihen bunları macOS, Linux veya WSL (opens in a new tab) gibi bir UNIX sistemine kurun.

  2. Aşama 1 kodunu indirin ve istemci kodunu sunmak için web sunucusunu başlatın.

    git clone https://github.com/qbzzt/250911-zk-bank.git -b 01-manual-zk
    cd 250911-zk-bank
    cd client
    npm install
    npm run dev
    

    Burada bir web sunucusuna ihtiyaç duymanızın nedeni, belirli dolandırıcılık türlerini önlemek için birçok cüzdanın (MetaMask gibi) doğrudan diskten sunulan dosyaları kabul etmemesidir.

  3. Bir cüzdan ile tarayıcı açın.

  4. Cüzdanda yeni bir parola girin. Bunun mevcut parolanızı sileceğini unutmayın, bu nedenle bir yedeğiniz olduğundan emin olun.

    Parola, anvil için varsayılan test parolası olan test test test test test test test test test test test junk'dir.

  5. İstemci tarafı koduna (opens in a new tab) göz atın.

  6. Cüzdana bağlanın ve hedef hesabınızı ve tutarı seçin.

  7. İmzala'ya tıklayın ve işlemi imzalayın.

  8. Prover.toml başlığı altında bir metin bulacaksınız. server/noir/Prover.toml dosyasını bu metinle değiştirin.

  9. Sıfır bilgi ispatını çalıştırın.

    cd ../server/noir
    nargo execute
    

    Çıktı şuna benzer olmalıdır:

ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute

[zkBank] Circuit witness successfully solved [zkBank] Witness saved to target/zkBank.gz [zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)

Mesaj metin formatındadır, bu da kullanıcının anlamasını (imzalama sırasında gereklidir) ve Noir kodunun ayrıştırmasını kolaylaştırır. Tutar, bir yandan kesirli transferlere olanak tanımak, diğer yandan kolayca okunabilmesi için finney cinsinden belirtilmiştir. Son sayı nonce (opens in a new tab) değeridir.

Dize 100 karakter uzunluğundadır. Sıfır bilgi ispatları değişken boyutlu verileri iyi işlemez, bu nedenle verileri doldurmak (padding) genellikle gereklidir.

pubKeyX=["0x83",...,"0x75"]
pubKeyY=["0x35",...,"0xa5"]
signature=["0xb1",...,"0x0d"]

Bu üç parametre sabit boyutlu bayt dizileridir.

Bu, bir yapı dizisini belirtmenin yoludur. Her giriş için adresi, bakiyeyi (milliETH, diğer adıyla finney (opens in a new tab) cinsinden) ve bir sonraki nonce değerini belirtiriz.

client/src/Transfer.tsx

Bu dosya (opens in a new tab), istemci tarafı işlemeyi uygular ve server/noir/Prover.toml dosyasını (sıfır bilgi parametrelerini içeren dosya) oluşturur.

İşte daha ilginç kısımların açıklaması.

export default attrs =>  {

Bu fonksiyon, diğer dosyaların içe aktarabileceği Transfer React bileşenini oluşturur.

  const accounts = [
    "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
    "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
    "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
    "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
    "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
  ]

Bunlar hesap adresleridir, test ... test junk parolası tarafından oluşturulan adreslerdir. Kendi adreslerinizi kullanmak isterseniz, sadece bu tanımı değiştirin.

  const account = useAccount()
  const wallet = createWalletClient({
    transport: custom(window.ethereum!)
  })

Bu Wagmi hook'ları (opens in a new tab), Viem (opens in a new tab) kütüphanesine ve cüzdana erişmemizi sağlar.

  const message = `send ${toAccount} ${ethAmount*1000} finney (milliEth) ${nonce}`.padEnd(100, " ")

Bu, boşluklarla doldurulmuş mesajdır. useState (opens in a new tab) değişkenlerinden biri her değiştiğinde, bileşen yeniden çizilir ve message güncellenir.

  const sign = async () => {

Bu fonksiyon, kullanıcı İmzala düğmesine tıkladığında çağrılır. Mesaj otomatik olarak güncellenir, ancak imza cüzdanda kullanıcı onayı gerektirir ve gerekmedikçe bunu istemek istemeyiz.

    const signature = await wallet.signMessage({
        account: fromAccount,
        message,
    })

Cüzdandan mesajı imzalamasını (opens in a new tab) isteyin.

    const hash = hashMessage(message)

Mesaj hash'ini alın. Hata ayıklama (Noir kodu için) amacıyla bunu kullanıcıya sağlamak faydalıdır.

    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

Açık anahtarı alın (opens in a new tab). Bu, Noir ecrecover (opens in a new tab) fonksiyonu için gereklidir.

    setSignature(signature)
    setHash(hash)
    setPubKey(pubKey)

Durum değişkenlerini ayarlayın. Bunu yapmak bileşeni yeniden çizer (sign fonksiyonu çıktıktan sonra) ve kullanıcıya güncellenmiş değerleri gösterir.

    let proverToml = `

Prover.toml için metin.

message="${message}"

pubKeyX=${hexToArray(pubKey.slice(4,4+2*32))}
pubKeyY=${hexToArray(pubKey.slice(4+2*32))}

Viem bize açık anahtarı 65 baytlık onaltılık (hexadecimal) bir dize olarak sağlar. İlk bayt bir sürüm işareti olan 0x04'tür. Bunu açık anahtarın x değeri için 32 bayt ve ardından açık anahtarın y değeri için 32 bayt izler.

Ancak Noir, bu bilgiyi biri x ve diğeri y için olmak üzere iki baytlık diziler olarak almayı bekler. Bunu sıfır bilgi ispatının bir parçası olarak değil de burada istemcide ayrıştırmak daha kolaydır.

Bunun genel olarak sıfır bilgi alanında iyi bir uygulama olduğunu unutmayın. Sıfır bilgi ispatı içindeki kod pahalıdır, bu nedenle sıfır bilgi ispatı dışında yapılabilecek herhangi bir işlem sıfır bilgi ispatı dışında yapılmalıdır.

signature=${hexToArray(signature.slice(2,-2))}

İmza ayrıca 65 baytlık onaltılık bir dize olarak sağlanır. Ancak, son bayt yalnızca açık anahtarı kurtarmak için gereklidir. Açık anahtar zaten Noir koduna sağlanacağından, imzayı doğrulamak için buna ihtiyacımız yoktur ve Noir kodu bunu gerektirmez.

${accounts.map(accountInProverToml).reduce((a,b) => a+b, "")}
`

Hesapları sağlayın.

    setProverToml(proverToml)
  }

  return (
    <>
        <h2>Transfer</h2>

Bu, bileşenin HTML (daha doğrusu JSX (opens in a new tab)) formatıdır.

server/noir/src/main.nr

Bu dosya (opens in a new tab) asıl sıfır bilgi kodudur.

use std::hash::pedersen_hash;

Pedersen hash (opens in a new tab), Noir standart kütüphanesi (opens in a new tab) ile birlikte sağlanır. Sıfır bilgi ispatları yaygın olarak bu hash fonksiyonunu kullanır. Standart hash fonksiyonlarına kıyasla aritmetik devreler (opens in a new tab) içinde hesaplanması çok daha kolaydır.

use keccak256::keccak256;
use dep::ecrecover;

Bu iki fonksiyon, Nargo.toml (opens in a new tab) içinde tanımlanan harici kütüphanelerdir. Tam olarak adlandırıldıkları şeyi yaparlar; keccak256 hash'ini (opens in a new tab) hesaplayan bir fonksiyon ve Ethereum imzalarını doğrulayıp imzalayanın Ethereum adresini kurtaran bir fonksiyon.

global ACCOUNT_NUMBER : u32 = 5;

Noir, Rust (opens in a new tab)'tan esinlenmiştir. Değişkenler varsayılan olarak sabitlerdir. Küresel yapılandırma sabitlerini bu şekilde tanımlarız. Spesifik olarak, ACCOUNT_NUMBER sakladığımız hesap sayısıdır.

u<number> olarak adlandırılan veri türleri, o sayıda bit içeren işaretsiz (unsigned) türlerdir. Desteklenen tek türler u8, u16, u32, u64 ve u128'dır.

global FLAT_ACCOUNT_FIELDS : u32 = 2;

Bu değişken, aşağıda açıklandığı gibi hesapların Pedersen hash'i için kullanılır.

global MESSAGE_LENGTH : u32 = 100;

Yukarıda açıklandığı gibi, mesaj uzunluğu sabittir. Burada belirtilmiştir.

global ASCII_MESSAGE_LENGTH : [u8; 3] = [0x31, 0x30, 0x30];
global HASH_BUFFER_SIZE : u32 = 26+3+MESSAGE_LENGTH;

EIP-191 imzaları (opens in a new tab), 26 baytlık bir önek, ardından ASCII cinsinden mesaj uzunluğu ve son olarak mesajın kendisini içeren bir arabellek gerektirir.

struct Account {
    balance: u128,
    address: Field,
    nonce: u32,
}

Bir hesap hakkında sakladığımız bilgiler. Field (opens in a new tab), sıfır bilgi ispatını uygulayan aritmetik devrede (opens in a new tab) doğrudan kullanılabilen, tipik olarak 253 bite kadar olan bir sayıdır. Burada 160 bitlik bir Ethereum adresini saklamak için Field kullanıyoruz.

struct TransferTxn {
    from: Field,
    to: Field,
    amount: u128,
    nonce: u32
}

Bir transfer işlemi için sakladığımız bilgiler.

fn flatten_account(account: Account) -> [Field; FLAT_ACCOUNT_FIELDS] {

Bir fonksiyon tanımı. Parametre Account bilgisidir. Sonuç, uzunluğu FLAT_ACCOUNT_FIELDS olan bir Field değişkenleri dizisidir.

let flat = [
        account.address,
        ((account.balance << 32) + account.nonce.into()).into(),
    ];

Dizideki ilk değer hesap adresidir. İkincisi hem bakiyeyi hem de nonce değerini içerir. .into() çağrıları, bir sayıyı olması gereken veri türüne dönüştürür. account.nonce bir u32 değeridir, ancak onu bir u128 değeri olan account.balance << 32'e eklemek için bir u128 olması gerekir. Bu ilk .into()'dur. İkincisi, u128 sonucunu diziye sığması için bir Field'a dönüştürür.

flat
}

Noir'da fonksiyonlar yalnızca sonda bir değer döndürebilir (erken dönüş yoktur). Dönüş değerini belirtmek için, onu fonksiyonun kapanış parantezinden hemen önce değerlendirirsiniz.

fn flatten_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] {

Bu fonksiyon, hesaplar dizisini bir Petersen Hash'ine girdi olarak kullanılabilecek bir Field dizisine dönüştürür.

let mut flat: [Field; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER] = [0; FLAT_ACCOUNT_FIELDS*ACCOUNT_NUMBER];

Değiştirilebilir (mutable) bir değişkeni, yani bir sabit olmayan değişkeni bu şekilde belirtirsiniz. Noir'daki değişkenlerin her zaman bir değeri olmalıdır, bu nedenle bu değişkeni tamamen sıfırlarla başlatıyoruz.

for i in 0..ACCOUNT_NUMBER {

Bu bir for döngüsüdür. Sınırların sabit olduğuna dikkat edin. Noir döngülerinin sınırlarının derleme zamanında bilinmesi gerekir. Bunun nedeni, aritmetik devrelerin akış kontrolünü desteklememesidir. Bir for döngüsünü işlerken, derleyici içindeki kodu her yineleme için bir kez olmak üzere birden çok kez yerleştirir.

Son olarak, hesaplar dizisini hash'leyen fonksiyona geldik.

fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
    let mut account : u32 = ACCOUNT_NUMBER;

    for i in 0..ACCOUNT_NUMBER {
        if accounts[i].address == address {
            account = i;
        }
    }

Bu fonksiyon, belirli bir adrese sahip hesabı bulur. Bu fonksiyon standart kodda son derece verimsiz olurdu çünkü adresi bulduktan sonra bile tüm hesaplar üzerinde yinelenir.

Ancak sıfır bilgi ispatlarında akış kontrolü yoktur. Bir koşulu kontrol etmemiz gerekirse, bunu her seferinde kontrol etmeliyiz.

Benzer bir durum if ifadelerinde de yaşanır. Yukarıdaki döngüdeki if ifadesi şu matematiksel ifadelere çevrilir.

koşulsonucu = accounts[i].address == address // eşitlerse bir, aksi takdirde sıfır

hesapyeni = koşulsonucu*i + (1-koşulsonucu)*hesapeski

    assert (account < ACCOUNT_NUMBER, f"{address} does not have an account");

    account
}

assert (opens in a new tab) fonksiyonu, iddia (assertion) yanlışsa sıfır bilgi ispatının çökmesine neden olur. Bu durumda, ilgili adrese sahip bir hesap bulamazsak. Adresi bildirmek için bir format dizesi (opens in a new tab) kullanırız.

fn apply_transfer_txn(accounts: [Account; ACCOUNT_NUMBER], txn: TransferTxn) -> [Account; ACCOUNT_NUMBER] {

Bu fonksiyon bir transfer işlemi uygular ve yeni hesaplar dizisini döndürür.

    let from = find_account(accounts, txn.from);
    let to = find_account(accounts, txn.to);

    let (txnFrom, txnAmount, txnNonce, accountNonce) =
        (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

Noir'da bir format dizesi içindeki yapı elemanlarına erişemeyiz, bu nedenle kullanılabilir bir kopya oluştururuz.

    assert (accounts[from].balance >= txn.amount,
        f"{txnFrom} does not have {txnAmount} finney");

    assert (accounts[from].nonce == txn.nonce,
        f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

Bunlar, bir işlemi geçersiz kılabilecek iki koşuldur.

    let mut newAccounts = accounts;

    newAccounts[from].balance -= txn.amount;
    newAccounts[from].nonce += 1;
    newAccounts[to].balance += txn.amount;

    newAccounts
}

Yeni hesaplar dizisini oluşturun ve ardından onu döndürün.

fn readAddress(messageBytes: [u8; MESSAGE_LENGTH]) -> Field

Bu fonksiyon adresi mesajdan okur.

{
    let mut result : Field = 0;

    for i in 7..47 {

Adres her zaman 20 bayt (diğer adıyla 40 onaltılık basamak) uzunluğundadır ve 7. karakterden başlar.

Mesajdan tutarı ve nonce değerini okuyun.

{
    let mut amount : u128 = 0;
    let mut nonce: u32 = 0;
    let mut stillReadingAmount: bool = true;
    let mut lookingForNonce: bool = false;
    let mut stillReadingNonce: bool = false;

Mesajda, adresten sonraki ilk sayı transfer edilecek finney (diğer adıyla ETH'nin binde biri) miktarıdır. İkinci sayı nonce değeridir. Aralarındaki herhangi bir metin yok sayılır.

Bir demet (tuple) (opens in a new tab) döndürmek, Noir'da bir fonksiyondan birden fazla değer döndürmenin yoludur.

Bu fonksiyon mesajı baytlara dönüştürür, ardından tutarları bir TransferTxn değerine dönüştürür.

// Viem'in hashMessage'ının karşılığı
// https://viem.sh/docs/utilities/hashMessage#hashmessage
fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

Hesaplar için Pedersen Hash kullanabildik çünkü bunlar yalnızca sıfır bilgi ispatı içinde hash'lenir. Ancak, bu kodda tarayıcı tarafından oluşturulan mesajın imzasını kontrol etmemiz gerekiyor. Bunun için EIP-191 (opens in a new tab) içindeki Ethereum imzalama formatını izlememiz gerekir. Bu, standart bir önek, ASCII cinsinden mesaj uzunluğu ve mesajın kendisini içeren birleşik bir arabellek oluşturmamız ve bunu hash'lemek için Ethereum standardı keccak256'yı kullanmamız gerektiği anlamına gelir.

Bir uygulamanın kullanıcıdan bir işlem olarak veya başka bir amaçla kullanılabilecek bir mesajı imzalamasını istediği durumlardan kaçınmak için EIP-191, imzalanan tüm mesajların 0x19 karakteriyle (geçerli bir ASCII karakteri değil) başlamasını ve ardından Ethereum Signed Message: ve yeni bir satır gelmesini belirtir.

999'a kadar olan mesaj uzunluklarını işleyin ve daha büyükse başarısız olun. Mesaj uzunluğu bir sabit olmasına rağmen bu kodu ekledim, çünkü onu değiştirmeyi kolaylaştırıyor. Bir üretim sisteminde, daha iyi performans adına muhtemelen MESSAGE_LENGTH değerinin değişmediğini varsayarsınız.

    keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
}

Ethereum standardı keccak256 fonksiyonunu kullanın.

fn signatureToAddressAndHash(
        message: str<MESSAGE_LENGTH>, 
        pubKeyX: [u8; 32],
        pubKeyY: [u8; 32],
        signature: [u8; 64]
    ) -> (Field, Field, Field)   // Adres, hash'in ilk 16 baytı, hash'in son 16 baytı        
{

Bu fonksiyon, mesaj hash'ini gerektiren imzayı doğrular. Daha sonra bize onu imzalayan adresi ve mesaj hash'ini sağlar. Mesaj hash'i iki Field değeri olarak sağlanır çünkü bunların programın geri kalanında kullanımı bir bayt dizisinden daha kolaydır.

İki Field değeri kullanmamız gerekir çünkü alan hesaplamaları büyük bir sayının modülüne (modulo) (opens in a new tab) göre yapılır, ancak bu sayı tipik olarak 256 bitten küçüktür (aksi takdirde bu hesaplamaları EVM'de yapmak zor olurdu).

    let hash = hashMessage(message);

    let mut (hash1, hash2) = (0,0);

    for i in 0..16 {
        hash1 = hash1*256 + hash[31-i].into();
        hash2 = hash2*256 + hash[15-i].into();
    }

hash1 ve hash2 değerlerini değiştirilebilir değişkenler olarak belirtin ve hash'i bunlara bayt bayt yazın.

    (
        ecrecover::ecrecover(pubKeyX, pubKeyY, signature, hash), 

Bu, iki önemli farkla Solidity'nin ecrecover (opens in a new tab) fonksiyonuna benzer:

  • İmza geçerli değilse, çağrı bir assert hatası verir ve program iptal edilir.
  • Açık anahtar imza ve hash'ten kurtarılabilirken, bu harici olarak yapılabilecek bir işlemdir ve bu nedenle sıfır bilgi ispatı içinde yapmaya değmez. Birisi burada bizi kandırmaya çalışırsa, imza doğrulaması başarısız olacaktır.

Son olarak main fonksiyonuna ulaşıyoruz. Hesapların hash'ini eski değerden yeni değere geçerli bir şekilde değiştiren bir işlemimiz olduğunu kanıtlamamız gerekiyor. Ayrıca, gönderen kişinin işleminin işlendiğini bilmesi için bu belirli işlem hash'ine sahip olduğunu da kanıtlamamız gerekir.

{
    let mut txn = readTransferTxn(message);

txn değerinin değiştirilebilir olması gerekir çünkü gönderen adresini mesajdan okumuyoruz, imzadan okuyoruz.

Aşama 2 - Bir sunucu ekleme

İkinci aşamada, tarayıcıdan transfer işlemlerini alan ve uygulayan bir sunucu ekliyoruz.

Bunu çalışırken görmek için:

  1. Çalışıyorsa Vite'ı durdurun.

  2. Sunucuyu içeren dalı indirin ve gerekli tüm modüllere sahip olduğunuzdan emin olun.

    git checkout 02-add-server
    cd client
    npm install
    cd ../server
    npm install
    

    Noir kodunu derlemeye gerek yoktur, aşama 1 için kullandığınız kodla aynıdır.

  3. Sunucuyu başlatın.

    npm run start
    
  4. Ayrı bir komut satırı penceresinde, tarayıcı kodunu sunmak için Vite'ı çalıştırın.

    cd client
    npm run dev
    
  5. http://localhost:5173 (opens in a new tab) adresindeki istemci koduna göz atın.

  6. Bir işlem başlatmadan önce, gönderebileceğiniz tutarın yanı sıra nonce değerini de bilmeniz gerekir. Bu bilgiyi almak için Hesap verilerini güncelle'ye tıklayın ve mesajı imzalayın.

    Burada bir ikilemimiz var. Bir yandan, yeniden kullanılabilecek bir mesajı imzalamak istemiyoruz (bir tekrarlama saldırısı (replay attack) (opens in a new tab)), bu yüzden en başta bir nonce istiyoruz. Ancak henüz bir nonce değerimiz yok. Çözüm, yalnızca bir kez kullanılabilen ve her iki tarafta da zaten sahip olduğumuz, örneğin geçerli zaman gibi bir nonce seçmektir.

    Bu çözümle ilgili sorun, zamanın mükemmel bir şekilde senkronize olmamasıdır. Bu yüzden bunun yerine, her dakika değişen bir değeri imzalıyoruz. Bu, tekrarlama saldırılarına karşı güvenlik açığı penceremizin en fazla bir dakika olduğu anlamına gelir. Üretimde imzalanan isteğin TLS tarafından korunacağı ve tünelin diğer tarafının---sunucunun---zaten bakiyeyi ve nonce değerini ifşa edebileceği (çalışması için bunları bilmesi gerekir) göz önüne alındığında, bu kabul edilebilir bir risktir.

  7. Tarayıcı bakiye ve nonce değerini geri aldığında, transfer formunu gösterir. Hedef adresi ve tutarı seçin ve Transfer'e tıklayın. Bu isteği imzalayın.

  8. Transferi görmek için ya Hesap verilerini güncelleyin ya da sunucuyu çalıştırdığınız pencereye bakın. Sunucu, durum her değiştiğinde bunu günlüğe kaydeder.

ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start

server@1.0.0 start node --experimental-json-modules index.mjs

Listening on port 3000 Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0) Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed New state: 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)


#### `server/index.mjs` \{#server-index-mjs-1\}

[Bu dosya](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/index.mjs) sunucu sürecini içerir ve [`main.nr`](https://github.com/qbzzt/250911-zk-bank/blob/02-add-server/server/noir/src/main.nr) adresindeki Noir koduyla etkileşime girer. İşte ilginç kısımların bir açıklaması.

```js
import { Noir } from '@noir-lang/noir_js'

noir.js (opens in a new tab) kütüphanesi, JavaScript kodu ile Noir kodu arasında arayüz oluşturur.

const circuit = JSON.parse(await fs.readFile("./noir/target/zkBank.json"))
const noir = new Noir(circuit)

Aritmetik devreyi---önceki aşamada oluşturduğumuz derlenmiş Noir programını---yükleyin ve çalıştırmaya hazırlanın.

// Hesap bilgisini yalnızca imzalı bir isteğe yanıt olarak sağlarız
const accountInformation = async signature => {
    const fromAddress = await recoverAddress({
        hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
        signature
    })

Hesap bilgilerini sağlamak için yalnızca imzaya ihtiyacımız var. Nedeni, mesajın ne olacağını ve dolayısıyla mesaj hash'ini zaten bilmemizdir.

const processMessage = async (message, signature) => {

Bir mesajı işleyin ve kodladığı işlemi yürütün.

    // Açık anahtarı al
    const pubKey = await recoverPublicKey({
        hash,
        signature
    })

Artık sunucuda JavaScript çalıştırdığımıza göre, açık anahtarı istemci yerine oradan alabiliriz.

noir.execute Noir programını çalıştırır. Parametreler Prover.toml (opens in a new tab) içinde sağlananlara eşdeğerdir. Uzun değerlerin, Viem'in yaptığı gibi tek bir onaltılık değer (0x60A7) olarak değil, onaltılık dizelerden oluşan bir dizi (["0x60", "0xA7"]) olarak sağlandığına dikkat edin.

    } catch (err) {
        console.log(`Noir error: ${err}`)
        throw Error("Invalid transaction, not processed")
    }

Bir hata varsa, onu yakalayın ve ardından basitleştirilmiş bir sürümünü istemciye iletin.

    Accounts[fromAccountNumber].nonce++
    Accounts[fromAccountNumber].balance -= amount
    Accounts[toAccountNumber].balance += amount

İşlemi uygulayın. Bunu zaten Noir kodunda yaptık, ancak sonucu oradan çıkarmak yerine burada tekrar yapmak daha kolaydır.

let Accounts = [
    {
        address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
        balance: 5000,
        nonce: 0,
    },

Başlangıç Accounts yapısı.

Aşama 3 - Ethereum akıllı sözleşmeleri

  1. Sunucu ve istemci süreçlerini durdurun.

  2. Akıllı sözleşmeleri içeren dalı indirin ve gerekli tüm modüllere sahip olduğunuzdan emin olun.

    git checkout 03-smart-contracts
    cd client
    npm install
    cd ../server
    npm install
    
  3. Ayrı bir komut satırı penceresinde anvil çalıştırın.

  4. Doğrulama anahtarını ve Solidity doğrulayıcısını oluşturun, ardından doğrulayıcı kodunu Solidity projesine kopyalayın.

    cd noir
    bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    cp target/Verifier.sol ../../smart-contracts/src
    
  5. Akıllı sözleşmelere gidin ve anvil blokzincirini kullanmak için ortam değişkenlerini ayarlayın.

    cd ../../smart-contracts
    export ETH_RPC_URL=http://localhost:8545
    ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
    
  6. Verifier.sol sözleşmesini dağıtın ve adresi bir ortam değişkeninde saklayın.

    VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    echo $VERIFIER_ADDRESS
    
  7. ZkBank sözleşmesini dağıtın.

    ZKBANK_ADDRESS=`forge create ZkBank --private-key $ETH_PRIVATE_KEY --broadcast --constructor-args $VERIFIER_ADDRESS 0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b | awk '/Deployed to:/ {print $3}'`
    echo $ZKBANK_ADDRESS
    

    0x199..67b değeri, Accounts başlangıç durumunun Pederson hash'idir. Bu başlangıç durumunu server/index.mjs içinde değiştirirseniz, sıfır bilgi ispatı tarafından bildirilen başlangıç hash'ini görmek için bir işlem çalıştırabilirsiniz.

  8. Sunucuyu çalıştırın.

    cd ../server
    npm run start
    
  9. İstemciyi farklı bir komut satırı penceresinde çalıştırın.

    cd client
    npm run dev
    
  10. Bazı işlemleri çalıştırın.

  11. Durumun zincir içi değiştiğini doğrulamak için sunucu sürecini yeniden başlatın. İşlemlerdeki orijinal hash değeri zincir içi saklanan hash değerinden farklı olduğu için ZkBank sözleşmesinin artık işlemleri kabul etmediğini görün.

    Beklenen hata türü budur.

ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start

server@1.0.0 start node --experimental-json-modules index.mjs

Listening on port 3000 Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason: Wrong old state hash

Contract Call: address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512 function: processTransaction(bytes _proof, bytes32[] _publicInputs) args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000

Zincir içi gönderilecek asıl ispatı oluşturmak için Barretenberg paketini (opens in a new tab) kullanmamız gerekir. Bu paketi komut satırı arayüzünü (bb) çalıştırarak veya JavaScript kütüphanesi olan bb.js (opens in a new tab) kullanarak kullanabiliriz. JavaScript kütüphanesi kodu yerel olarak çalıştırmaktan çok daha yavaştır, bu nedenle komut satırını kullanmak için burada exec (opens in a new tab) kullanıyoruz.

bb.js kullanmaya karar verirseniz, kullandığınız Noir sürümüyle uyumlu bir sürüm kullanmanız gerektiğini unutmayın. Bu yazının yazıldığı sırada, mevcut Noir sürümü (1.0.0-beta.11) bb.js sürüm 0.87'yi kullanmaktadır.

const zkBankAddress = process.env.ZKBANK_ADDRESS || "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"

Buradaki adres, temiz bir anvil ile başlayıp yukarıdaki talimatları izlediğinizde aldığınız adrestir.

const walletClient = createWalletClient({ 
    chain: anvil, 
    transport: http(), 
    account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
})

Bu özel anahtar, anvil içindeki varsayılan önceden fonlanmış hesaplardan biridir.

const generateProof = async (witness, fileID) => {

bb yürütülebilir dosyasını kullanarak bir ispat oluşturun.

    const fname = `witness-${fileID}.gz`    
    await fs.writeFile(fname, witness)

Tanığı bir dosyaya yazın.

    await execPromise(`bb prove -b ./noir/target/zkBank.json -w ${fname} -o ${fileID} --oracle_hash keccak --output_format fields`)

Asıl ispatı oluşturun. Bu adım ayrıca açık değişkenleri içeren bir dosya da oluşturur, ancak buna ihtiyacımız yok. Bu değişkenleri zaten noir.execute'den aldık.

    const proof = "0x" + JSON.parse(await fs.readFile(`./${fileID}/proof_fields.json`)).reduce((a,b) => a+b, "").replace(/0x/g, "")

İspat, her biri onaltılık bir değer olarak temsil edilen Field değerlerinden oluşan bir JSON dizisidir. Ancak, bunu işlemde Viem'in büyük bir onaltılık dizeyle temsil ettiği tek bir bytes değeri olarak göndermemiz gerekir. Burada tüm değerleri birleştirerek, tüm 0x'leri kaldırarak ve ardından sona bir tane ekleyerek formatı değiştiriyoruz.

    await execPromise(`rm -r ${fname} ${fileID}`)

    return proof
}

Temizleyin ve ispatı döndürün.

const processMessage = async (message, signature) => {
    .
    .
    .

    const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

Açık alanların 32 baytlık değerlerden oluşan bir dizi olması gerekir. Ancak, işlem hash'ini iki Field değeri arasında bölmemiz gerektiğinden, 16 baytlık bir değer olarak görünür. Burada Viem'in aslında 32 bayt olduğunu anlaması için sıfırlar ekliyoruz.

    const proof = await generateProof(noirResult.witness, `${fromAddress}-${nonce}`)

Her adres her nonce değerini yalnızca bir kez kullanır, böylece tanık dosyası ve çıktı dizini için benzersiz bir tanımlayıcı olarak fromAddress ve nonce kombinasyonunu kullanabiliriz.

İşlemi zincire gönderin.

smart-contracts/src/ZkBank.sol

Bu, işlemi alan zincir içi koddur.

Zincir içi kodun iki değişkeni takip etmesi gerekir: doğrulayıcı (nargo tarafından oluşturulan ayrı bir sözleşme) ve mevcut durum hash'i.

    event TransactionProcessed(
        bytes32 indexed transactionHash,
        bytes32 oldStateHash,
        bytes32 newStateHash
    );

Durum her değiştiğinde, bir TransactionProcessed olayı (event) yayınlarız.

    function processTransaction(
        bytes calldata _proof,
        bytes32[] calldata _publicFields
    ) public {

Bu fonksiyon işlemleri işler. İspatı (bytes olarak) ve açık girdileri (bir bytes32 dizisi olarak), doğrulayıcının gerektirdiği formatta (zincir içi işlemeyi ve dolayısıyla gaz maliyetlerini en aza indirmek için) alır.

        require(_publicInputs[0] == currentStateHash,
            "Wrong old state hash");

Sıfır bilgi ispatının, işlemin mevcut hash'imizden yeni bir hash'e değiştiğini göstermesi gerekir.

        myVerifier.verify(_proof, _publicFields);

Sıfır bilgi ispatını doğrulamak için doğrulayıcı sözleşmesini çağırın. Bu adım, sıfır bilgi ispatı yanlışsa işlemi geri alır (revert).

Her şey yolundaysa, durum hash'ini yeni değere güncelleyin ve bir TransactionProcessed olayı yayınlayın.

Merkezi bileşen tarafından yapılan suistimaller

Bilgi güvenliği üç özellikten oluşur:

  • Gizlilik, kullanıcılar okumaya yetkili olmadıkları bilgileri okuyamazlar.
  • Bütünlük, bilgiler yetkili kullanıcılar dışında ve yetkili bir yöntem haricinde değiştirilemez.
  • Erişilebilirlik, yetkili kullanıcılar sistemi kullanabilir.

Bu sistemde bütünlük, sıfır bilgi ispatları aracılığıyla sağlanır. Erişilebilirliği garanti etmek çok daha zordur ve gizlilik imkansızdır, çünkü banka her hesabın bakiyesini ve tüm işlemleri bilmek zorundadır. Bilgiye sahip olan bir varlığın bu bilgiyi paylaşmasını engellemenin bir yolu yoktur.

Gizli adresler (opens in a new tab) kullanarak gerçekten gizli bir banka oluşturmak mümkün olabilir, ancak bu, bu makalenin kapsamı dışındadır.

Yanlış bilgi

Sunucunun bütünlüğü ihlal etmesinin bir yolu, veri talep edildiğinde (opens in a new tab) yanlış bilgi sağlamasıdır.

Bunu çözmek için, hesapları özel bir girdi olarak ve bilgi talep edilen adresi genel bir girdi olarak alan ikinci bir Noir programı yazabiliriz. Çıktı, o adresin bakiyesi ve nonce'u ile hesapların hash'idir.

Elbette, bu ispat zincir içi doğrulanamaz, çünkü nonce'ları ve bakiyeleri zincir içi yayınlamak istemiyoruz. Ancak, tarayıcıda çalışan istemci kodu tarafından doğrulanabilir.

Zorunlu işlemler

L2'lerde erişilebilirliği sağlamak ve sansürü önlemek için olağan mekanizma zorunlu işlemlerdir (opens in a new tab). Ancak zorunlu işlemler sıfır bilgi ispatlarıyla birleşmez. Sunucu, işlemleri doğrulayabilen tek varlıktır.

smart-contracts/src/ZkBank.sol kodunu zorunlu işlemleri kabul edecek ve bunlar işlenene kadar sunucunun durumu değiştirmesini engelleyecek şekilde değiştirebiliriz. Ancak bu, bizi basit bir hizmet reddi (denial-of-service) saldırısına açık hale getirir. Ya zorunlu bir işlem geçersizse ve bu nedenle işlenmesi imkansızsa?

Çözüm, zorunlu bir işlemin geçersiz olduğuna dair bir sıfır bilgi ispatına sahip olmaktır. Bu, sunucuya üç seçenek sunar:

  • Zorunlu işlemi işlemek, işlendiğine dair bir sıfır bilgi ispatı ve yeni durum hash'ini sağlamak.
  • Zorunlu işlemi reddetmek ve sözleşmeye işlemin geçersiz olduğuna dair (bilinmeyen adres, hatalı nonce veya yetersiz bakiye) bir sıfır bilgi ispatı sağlamak.
  • Zorunlu işlemi görmezden gelmek. Sunucuyu işlemi gerçekten işlemeye zorlamanın bir yolu yoktur, ancak bu tüm sistemin erişilemez olduğu anlamına gelir.

Erişilebilirlik teminatları

Gerçek hayattaki bir uygulamada, sunucuyu çalışır durumda tutmak için muhtemelen bir tür kâr amacı olacaktır. Sunucunun, zorunlu bir işlem belirli bir süre içinde işlenmezse herkesin yakabileceği bir erişilebilirlik teminatı yatırmasını sağlayarak bu teşviki güçlendirebiliriz.

Kötü Noir kodu

Normalde, insanların bir akıllı sözleşmeye güvenmesini sağlamak için kaynak kodunu bir blok gezginine (opens in a new tab) yükleriz. Ancak, sıfır bilgi ispatları söz konusu olduğunda bu yetersizdir.

Verifier.sol, Noir programının bir fonksiyonu olan doğrulama anahtarını içerir. Ancak bu anahtar bize Noir programının ne olduğunu söylemez. Gerçekten güvenilir bir çözüme sahip olmak için Noir programını (ve onu oluşturan sürümü) yüklemeniz gerekir. Aksi takdirde, sıfır bilgi ispatları arka kapısı olan farklı bir programı yansıtıyor olabilir.

Blok gezginleri Noir programlarını yüklememize ve doğrulamamıza izin vermeye başlayana kadar, bunu kendiniz yapmalısınız (tercihen IPFS'e). Böylece deneyimli kullanıcılar kaynak kodunu indirebilecek, kendileri derleyebilecek, Verifier.sol oluşturabilecek ve bunun zincir içi olanla aynı olduğunu doğrulayabilecekler.

Sonuç

Plasma tipi uygulamalar, bilgi depolama alanı olarak merkezi bir bileşen gerektirir. Bu durum potansiyel güvenlik açıklarına yol açar ancak buna karşılık, Blokzincirin kendisinde bulunmayan yollarla gizliliği korumamıza olanak tanır. Sıfır bilgi ispatları ile bütünlüğü sağlayabilir ve merkezi bileşeni çalıştıran kişi için kullanılabilirliği sürdürmeyi ekonomik olarak avantajlı hale getirebiliriz.

Çalışmalarımın devamı için buraya göz atın (opens in a new tab).

Teşekkürler

  • Josh Crites bu makalenin taslağını okudu ve zorlu bir Noir sorununda bana yardımcı oldu.

Kalan tüm hatalar benim sorumluluğumdadır.