تخطٍ إلى المحتوى الرئيسي

كتابة Plasma خاصة بالتطبيق تحافظ على الخصوصية

المعرفة الصفرية
خادم
خارج السلسلة
الخصوصية
إعدادات متقدمة
أوري بوميرانتز
15 أكتوبر 2025
29 دقيقة قراءة

مقدمة

على عكس الرول أبس، تستخدم البلازما شبكة إيثريوم الرئيسية للنزاهة، ولكن ليس للتوافر. في هذا المقال، نكتب تطبيقًا يتصرف مثل Plasma، حيث تضمن إيثريوم النزاهة (عدم وجود تغييرات غير مصرح بها) ولكن ليس التوافر (يمكن أن يتعطل مكون مركزي ويعطل النظام بأكمله).

التطبيق الذي نكتبه هنا هو بنك يحافظ على الخصوصية. تمتلك العناوين المختلفة حسابات ذات أرصدة، ويمكنها إرسال الأموال (ETH) إلى حسابات أخرى. ينشر البنك تجزئات (هاشات) الحالة (الحسابات وأرصدتها) والمعاملات، لكنه يحتفظ بالأرصدة الفعلية خارج السلسلة حيث يمكن أن تظل خاصة.

التصميم

هذا ليس نظامًا جاهزًا للإنتاج، بل أداة تعليمية. على هذا النحو، تمت كتابته مع عدة افتراضات تبسيطية.

  • مجمع حسابات ثابت. هناك عدد محدد من الحسابات، وكل حساب ينتمي إلى عنوان محدد مسبقًا. هذا يجعل النظام أبسط بكثير لأنه من الصعب التعامل مع هياكل البيانات متغيرة الحجم في إثباتات المعرفة الصفرية. بالنسبة لنظام جاهز للإنتاج، يمكننا استخدام جذر Merkle كتجزئة (هاش) للحالة وتقديم إثباتات Merkle للأرصدة المطلوبة.

  • تخزين الذاكرة. في نظام الإنتاج، نحتاج إلى كتابة جميع أرصدة الحسابات على القرص للحفاظ عليها في حالة إعادة التشغيل. هنا، لا بأس إذا فقدت المعلومات ببساطة.

  • التحويلات فقط. سيتطلب نظام الإنتاج طريقة لإيداع الأصول في البنك وسحبها. لكن الغرض هنا هو فقط توضيح المفهوم، لذا فإن هذا البنك يقتصر على التحويلات.

إثباتات المعرفة الصفرية

على المستوى الأساسي، يوضح إثبات المعرفة الصفرية أن المُثبِت يعرف بعض البيانات، البياناتالخاصة بحيث توجد علاقة العلاقة بين بعض البيانات العامة، البياناتالعامة، و_البياناتالخاصة. يعرف المُحقِّق العلاقة و_البياناتالعامة.

للحفاظ على الخصوصية، نحتاج إلى أن تكون الحالات والمعاملات خاصة. ولكن لضمان النزاهة، نحتاج إلى أن تكون التجزئة (الهاش) المشفرة (opens in a new tab) للحالات عامة. لإثبات للأشخاص الذين يقدمون المعاملات أن تلك المعاملات قد حدثت بالفعل، نحتاج أيضًا إلى نشر تجزئات (هاشات) المعاملات.

في معظم الحالات، تكون البياناتالخاصة هي المُدخَل لبرنامج إثبات المعرفة الصفرية، وتكون البياناتالعامة هي المُخرَج.

هذه الحقول في البياناتالخاصة:

  • الحالةn، الحالة القديمة
  • الحالةn+1، الحالة الجديدة
  • المعاملة، معاملة تتغير من الحالة القديمة إلى الحالة الجديدة. تحتاج هذه المعاملة إلى تضمين هذه الحقول:
    • عنوان الوجهة الذي يستقبل التحويل
    • المبلغ الذي يتم تحويله
    • Nonce لضمان إمكانية معالجة كل معاملة مرة واحدة فقط. لا يلزم أن يكون عنوان المصدر في المعاملة، لأنه يمكن استرداده من التوقيع.
  • التوقيع، وهو توقيع مصرح به لإجراء المعاملة. في حالتنا، العنوان الوحيد المصرح له بإجراء معاملة هو عنوان المصدر. نظرًا لأن نظام المعرفة الصفرية لدينا يعمل بالطريقة التي يعمل بها، فإننا نحتاج أيضًا إلى المفتاح العام للحساب، بالإضافة إلى توقيع إيثريوم.

هذه هي الحقول في البياناتالعامة:

  • تجزئة(الحالةn) تجزئة (هاش) الحالة القديمة
  • تجزئة(الحالةn+1) تجزئة (هاش) الحالة الجديدة
  • تجزئة(المعاملة) تجزئة (هاش) المعاملة التي تغير الحالة من الحالةn إلى الحالةn+1.

تتحقق العلاقة من عدة شروط:

  • التجزئات (الهاشات) العامة هي بالفعل التجزئات (الهاشات) الصحيحة للحقول الخاصة.
  • المعاملة، عند تطبيقها على الحالة القديمة، تؤدي إلى الحالة الجديدة.
  • يأتي التوقيع من عنوان مصدر المعاملة.

بسبب خصائص دوال التجزئة (الهاش) المشفرة، فإن إثبات هذه الشروط يكفي لضمان النزاهة.

هياكل البيانات

هيكل البيانات الأساسي هو الحالة التي يحتفظ بها الخادم. لكل حساب، يتتبع الخادم رصيد الحساب وnonce (opens in a new tab)، المستخدم لمنع هجمات إعادة الإرسال (opens in a new tab).

المكونات

يتطلب هذا النظام مكونين:

  • الخادم الذي يستقبل المعاملات، ويعالجها، وينشر التجزئات (الهاشات) على السلسلة إلى جانب إثباتات المعرفة الصفرية.
  • عقد ذكي يخزن التجزئات (الهاشات) ويتحقق من إثباتات المعرفة الصفرية لضمان شرعية انتقالات الحالة.

تدفق البيانات والتحكم

هذه هي الطرق التي تتواصل بها المكونات المختلفة للتحويل من حساب إلى آخر.

  1. يُقدِّم متصفح الويب معاملة موقعة تطلب تحويلاً من حساب الموقِّع إلى حساب مختلف.

  2. يتحقق الخادم من أن المعاملة صالحة:

    • يمتلك الموقِّع حسابًا في البنك برصيد كافٍ.
    • يمتلك المستلم حسابًا في البنك.
  3. يحسب الخادم الحالة الجديدة عن طريق طرح المبلغ المحوَّل من رصيد الموقِّع وإضافته إلى رصيد المستلم.

  4. يحسب الخادم إثبات المعرفة الصفرية بأن تغيير الحالة صالح.

  5. يقدم الخادم إلى إيثريوم معاملة تتضمن:

    • تجزئة (هاش) الحالة الجديدة
    • تجزئة (هاش) المعاملة (حتى يتمكن مرسل المعاملة من معرفة أنه قد تمت معالجتها)
    • إثبات المعرفة الصفرية الذي يثبت أن الانتقال إلى الحالة الجديدة صالح
  6. يتحقق العقد الذكي من إثبات المعرفة الصفرية.

  7. إذا تم التحقق من إثبات المعرفة الصفرية، يقوم العقد الذكي بتنفيذ هذه الإجراءات:

    • تحديث تجزئة (هاش) الحالة الحالية إلى تجزئة (هاش) الحالة الجديدة
    • إصدار إدخال سجل بتجزئة (هاش) الحالة الجديدة وتجزئة (هاش) المعاملة

أدوات

بالنسبة لرمز العميل، سوف نستخدم فيت (opens in a new tab) و رياكت (opens in a new tab) و فيم (opens in a new tab) و واغمي (opens in a new tab). هذه أدوات قياسية في الصناعة؛ إذا لم تكن على دراية بها، يمكنك استخدام هذا البرنامج التعليمي.

غالبية الخادم مكتوبة بلغة جافا سكريبت باستخدام Node (opens in a new tab). جزء المعرفة الصفرية مكتوب بلغة نوار (opens in a new tab). نحن بحاجة إلى الإصدار 1.0.0-beta.10، لذا بعد تثبيت نوار حسب التعليمات (opens in a new tab)، قم بتشغيل:

1noirup -v 1.0.0-beta.10

البلوكتشين الذي نستخدمه هو anvil، وهو بلوكتشين اختبار محلي وهو جزء من فاوندري (opens in a new tab).

التنفيذ

لأن هذا نظام معقد، سننفذه على مراحل.

المرحلة 1 - المعرفة الصفرية اليدوية

في المرحلة الأولى، سنقوم بتوقيع معاملة في المتصفح ثم نقدم المعلومات يدويًا إلى إثبات المعرفة الصفرية. يتوقع رمز المعرفة الصفرية الحصول على هذه المعلومات في server/noir/Prover.toml (موثقة هنا (opens in a new tab)).

لرؤيته أثناء العمل:

  1. تأكد من تثبيت Node (opens in a new tab) و نوار (opens in a new tab). يفضل تثبيتها على نظام UNIX مثل ماك أو إس أو لينكس أو WSL (opens in a new tab).

  2. قم بتنزيل رمز المرحلة 1 وابدأ خادم الويب لخدمة رمز العميل.

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

    السبب الذي يجعلك تحتاج إلى خادم ويب هنا هو أنه لمنع أنواع معينة من الاحتيال، لا تقبل العديد من المحافظ (مثل ميتاماسك) الملفات التي يتم تقديمها مباشرة من القرص

  3. افتح متصفحًا به محفظة.

  4. في المحفظة، أدخل عبارة مرور جديدة. لاحظ أن هذا سيؤدي إلى حذف عبارة المرور الحالية الخاصة بك، لذا تأكد من أن لديك نسخة احتياطية.

    عبارة المرور هي test test test test test test test test test test test junk، وهي عبارة مرور الاختبار الافتراضية لـ anvil.

  5. تصفح رمز العميل (opens in a new tab).

  6. اتصل بالمحفظة وحدد حساب الوجهة والمبلغ.

  7. انقر على توقيع وقم بتوقيع المعاملة.

  8. تحت عنوان Prover.toml، ستجد نصًا. استبدل server/noir/Prover.toml بهذا النص.

  9. نفذ إثبات المعرفة الصفرية.

    1cd ../server/noir
    2nargo execute

    يجب أن يكون الإخراج مشابهًا لـ

    1ori@CryptoDocGuy:~/noir/250911-zk-bank/server/noir$ nargo execute
    2
    3[zkBank] Circuit witness successfully solved
    4[zkBank] Witness saved to target/zkBank.gz
    5[zkBank] Circuit output: (0x199aa62af8c1d562a6ec96e66347bf3240ab2afb5d022c895e6bf6a5e617167b, 0x0cfc0a67cb7308e4e9b254026b54204e34f6c8b041be207e64c5db77d95dd82d, 0x450cf9da6e180d6159290554ae3d8787, 0x6d8bc5a15b9037e52fb59b6b98722a85)
  10. قارن بين آخر قيمتين والتجزئة (الهاش) التي تراها على متصفح الويب لترى ما إذا كانت الرسالة قد تم تجزئتها بشكل صحيح.

server/noir/Prover.toml

يعرض هذا الملف (opens in a new tab) تنسيق المعلومات الذي تتوقعه نوار.

1message="إرسال 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 500 finney (milliEth) 0 "

الرسالة بصيغة نصية، مما يسهل على المستخدم فهمها (وهو أمر ضروري عند التوقيع) وعلى رمز نوار تحليلها. يُذكر المبلغ بوحدة Finney لتمكين التحويلات الكسرية من ناحية، وليكون سهل القراءة من ناحية أخرى. الرقم الأخير هو nonce (opens in a new tab).

يبلغ طول السلسلة 100 حرف. لا تتعامل إثباتات المعرفة الصفرية بشكل جيد مع البيانات متغيرة الحجم، لذلك غالبًا ما يكون من الضروري حشو البيانات.

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

هذه المعلمات الثلاث هي مصفوفات بايت ثابتة الحجم.

1[[accounts]]
2address="0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"
3balance=100_000
4nonce=0
5
6[[accounts]]
7address="0x70997970C51812dc3A010C7d01b50e0d17dc79C8"
8balance=100_000
9nonce=0
إظهار الكل

هذه هي طريقة تحديد مصفوفة من الهياكل. لكل إدخال، نحدد العنوان، الرصيد (بالميلي إيثر المعروف أيضًا باسم finney (opens in a new tab))، وقيمة nonce التالية.

client/src/Transfer.tsx

ينفذ هذا الملف (opens in a new tab) المعالجة من جانب العميل وينشئ ملف server/noir/Prover.toml (الملف الذي يتضمن معلمات المعرفة الصفرية).

فيما يلي شرح للأجزاء الأكثر إثارة للاهتمام.

1export default attrs => {

تنشئ هذه الدالة مكون Transfer رياكت، الذي يمكن للملفات الأخرى استيراده.

1 const accounts = [
2 "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
3 "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
4 "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC",
5 "0x90F79bf6EB2c4f870365E785982E1f101E93b906",
6 "0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65",
7 ]

هذه هي عناوين الحسابات، العناوين التي أنشأتها عبارة المرور test ... test junk. إذا كنت تريد استخدام العناوين الخاصة بك، فما عليك سوى تعديل هذا التعريف.

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

تتيح لنا خطافات واغمي (opens in a new tab) هذه الوصول إلى مكتبة viem (opens in a new tab) والمحفظة.

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

هذه هي الرسالة، محشوة بالمسافات. في كل مرة يتغير فيها أحد متغيرات useState (opens in a new tab)، تتم إعادة رسم المكون وتحديث message.

1 const sign = async () => {

يتم استدعاء هذه الدالة عندما ينقر المستخدم على زر توقيع. يتم تحديث الرسالة تلقائيًا، ولكن التوقيع يتطلب موافقة المستخدم في المحفظة، ولا نريد أن نطلبها إلا عند الحاجة.

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

اطلب من المحفظة توقيع الرسالة (opens in a new tab).

1 const hash = hashMessage(message)

احصل على تجزئة (هاش) الرسالة. من المفيد توفيره للمستخدم لتصحيح الأخطاء (في رمز نوار).

1 const pubKey = await recoverPublicKey({
2 hash,
3 signature
4 })

احصل على المفتاح العام (opens in a new tab). هذا مطلوب لدالة نوار ecrecover (opens in a new tab).

1 setSignature(signature)
2 setHash(hash)
3 setPubKey(pubKey)

عيّن متغيرات الحالة. يؤدي القيام بذلك إلى إعادة رسم المكون (بعد خروج دالة sign) ويعرض للمستخدم القيم المحدثة.

1 let proverToml = `

نص Prover.toml.

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

يزودنا فيم بالمفتاح العام كسلسلة سداسية عشرية مكونة من 65 بايت. البايت الأول هو 0x04، وهو علامة إصدار. يتبع ذلك 32 بايت لـ x من المفتاح العام ثم 32 بايت لـ y من المفتاح العام.

ومع ذلك، تتوقع نوار الحصول على هذه المعلومات كمصفوفتي بايت، واحدة لـ x والأخرى لـ y. من الأسهل تحليلها هنا على العميل بدلاً من أن تكون جزءًا من إثبات المعرفة الصفرية.

لاحظ أن هذه ممارسة جيدة في المعرفة الصفرية بشكل عام. الرمز داخل إثبات المعرفة الصفرية مكلف، لذا فإن أي معالجة يمكن إجراؤها خارج إثبات المعرفة الصفرية يجب أن تتم خارج إثبات المعرفة الصفرية.

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

يتم توفير التوقيع أيضًا كسلسلة سداسية عشرية مكونة من 65 بايت. ومع ذلك، فإن البايت الأخير ضروري فقط لاستعادة المفتاح العام. نظرًا لأنه سيتم بالفعل توفير المفتاح العام لرمز نوار، فلن نحتاج إليه للتحقق من التوقيع، ولا يتطلبه رمز نوار.

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

قم بتوفير الحسابات.

1 setProverToml(proverToml)
2 }
3
4 return (
5 <>
6 <h2>Transfer</h2>

هذا هو تنسيق HTML (بتعبير أدق، JSX (opens in a new tab)) للمكون.

server/noir/src/main.nr

هذا الملف (opens in a new tab) هو رمز المعرفة الصفرية الفعلي.

1use std::hash::pedersen_hash;

يتم توفير تجزئة (هاش) Pedersen (opens in a new tab) مع مكتبة نوار القياسية (opens in a new tab). تستخدم إثباتات المعرفة الصفرية دالة التجزئة (الهاش) هذه بشكل شائع. من الأسهل بكثير حسابها داخل الدوائر الحسابية (opens in a new tab) مقارنة بدوال التجزئة (الهاش) القياسية.

1use keccak256::keccak256;
2use dep::ecrecover;

هاتان الدالتان هما مكتبتان خارجيتان، معرفتان في Nargo.toml (opens in a new tab). هما بالضبط ما سُميتا به، دالة تحسب تجزئة keccak256 (opens in a new tab) ودالة تتحقق من توقيعات إيثريوم وتستعيد عنوان إيثريوم الخاص بالموقِّع.

1global ACCOUNT_NUMBER : u32 = 5;

لغة نوار مستوحاة من راست (opens in a new tab). المتغيرات، بشكل افتراضي، هي ثوابت. هذه هي الطريقة التي نعرّف بها ثوابت التكوين العامة. على وجه التحديد، ACCOUNT_NUMBER هو عدد الحسابات التي نخزنها.

أنواع البيانات المسماة u<number> هي ذلك العدد من البتات، غير الموقعة. الأنواع المدعومة الوحيدة هي u8 وu16 وu32 وu64 وu128.

1global FLAT_ACCOUNT_FIELDS : u32 = 2;

يستخدم هذا المتغير لتجزئة Pedersen للحسابات، كما هو موضح أدناه.

1global MESSAGE_LENGTH : u32 = 100;

كما هو موضح أعلاه، طول الرسالة ثابت. يتم تحديده هنا.

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

تتطلب توقيعات EIP-191 (opens in a new tab) مخزنًا مؤقتًا ببادئة 26 بايت، متبوعًا بطول الرسالة في ASCII، وأخيرًا الرسالة نفسها.

1struct Account {
2 balance: u128,
3 address: Field,
4 nonce: u32,
5}

المعلومات التي نخزنها عن الحساب. Field (opens in a new tab) هو رقم، عادة ما يصل إلى 253 بت، يمكن استخدامه مباشرة في الدائرة الحسابية (opens in a new tab) التي تنفذ إثبات المعرفة الصفرية. هنا نستخدم Field لتخزين عنوان إيثريوم مكون من 160 بت.

1struct TransferTxn {
2 from: Field,
3 to: Field,
4 amount: u128,
5 nonce: u32
6}

المعلومات التي نخزنها لمعاملة تحويل.

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

تعريف دالة. المعلمة هي معلومات Account. النتيجة هي مصفوفة من متغيرات Field، طولها FLAT_ACCOUNT_FIELDS

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

القيمة الأولى في المصفوفة هي عنوان الحساب. تتضمن القيمة الثانية كلاً من الرصيد والـ nonce. تغير استدعاءات .into() رقمًا إلى نوع البيانات الذي يجب أن يكون عليه. account.nonce هي قيمة u32، ولكن لإضافتها إلى account.balance << 32، وهي قيمة u128، يجب أن تكون u128. هذا هو أول استدعاء .into(). الاستدعاء الثاني يحول نتيجة u128 إلى Field حتى تتناسب مع المصفوفة.

1 flat
2}

في لغة نوار، لا يمكن للدوال إرجاع قيمة إلا في النهاية (لا يوجد إرجاع مبكر). لتحديد القيمة المرجعة، تقوم بتقييمها مباشرة قبل القوس الختامي للدالة.

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

تحول هذه الدالة مصفوفة الحسابات إلى مصفوفة Field، والتي يمكن استخدامها كمدخل لتجزئة (هاش) Petersen.

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

هذه هي طريقة تحديد متغير قابل للتغيير، أي ليس ثابتًا. يجب أن تحتوي المتغيرات في نوار دائمًا على قيمة، لذلك نقوم بتهيئة هذا المتغير إلى كل الأصفار.

1 for i in 0..ACCOUNT_NUMBER {

هذه حلقة for. لاحظ أن الحدود هي ثوابت. يجب أن تكون حدود حلقات نوار معروفة في وقت الترجمة. والسبب هو أن الدوائر الحسابية لا تدعم التحكم في التدفق. عند معالجة حلقة for، يضع المترجم ببساطة الرمز الموجود بداخلها عدة مرات، مرة لكل تكرار.

1 let fields = flatten_account(accounts[i]);
2 for j in 0..FLAT_ACCOUNT_FIELDS {
3 flat[i*FLAT_ACCOUNT_FIELDS + j] = fields[j];
4 }
5 }
6
7 flat
8}
9
10fn hash_accounts(accounts: [Account; ACCOUNT_NUMBER]) -> Field {
11 pedersen_hash(flatten_accounts(accounts))
12}
إظهار الكل

أخيرًا، وصلنا إلى الدالة التي تجزئ مصفوفة الحسابات.

1fn find_account(accounts: [Account; ACCOUNT_NUMBER], address: Field) -> u32 {
2 let mut account : u32 = ACCOUNT_NUMBER;
3
4 for i in 0..ACCOUNT_NUMBER {
5 if accounts[i].address == address {
6 account = i;
7 }
8 }

تبحث هذه الدالة عن الحساب الذي له عنوان محدد. ستكون هذه الدالة غير فعالة بشكل فظيع في الرمز القياسي لأنها تتكرر على جميع الحسابات، حتى بعد أن تجد العنوان.

ومع ذلك، في إثباتات المعرفة الصفرية، لا يوجد تحكم في التدفق. إذا احتجنا في أي وقت إلى التحقق من شرط، فعلينا التحقق منه في كل مرة.

يحدث شيء مماثل مع عبارات if. تُترجم عبارة if في الحلقة أعلاه إلى هذه العبارات الرياضية.

نتيجةالشرط = accounts[i].address == address // واحد إذا كانا متساويين، صفر بخلاف ذلك

الحسابالجديد = نتيجةالشرط*i + (1-نتيجةالشرط)*الحسابالقديم

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

تتسبب دالة assert (opens in a new tab) في تعطل إثبات المعرفة الصفرية إذا كان التأكيد خاطئًا. في هذه الحالة، إذا لم نتمكن من العثور على حساب بالعنوان ذي الصلة. للإبلاغ عن العنوان، نستخدم سلسلة تنسيق (opens in a new tab).

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

تطبق هذه الدالة معاملة تحويل وترجع مصفوفة الحسابات الجديدة.

1 let from = find_account(accounts, txn.from);
2 let to = find_account(accounts, txn.to);
3
4 let (txnFrom, txnAmount, txnNonce, accountNonce) =
5 (txn.from, txn.amount, txn.nonce, accounts[from].nonce);

لا يمكننا الوصول إلى عناصر الهيكل داخل سلسلة تنسيق في نوار، لذلك نقوم بإنشاء نسخة قابلة للاستخدام.

1 assert (accounts[from].balance >= txn.amount,
2 f"{txnFrom} does not have {txnAmount} finney");
3
4 assert (accounts[from].nonce == txn.nonce,
5 f"Transaction has nonce {txnNonce}, but the account is expected to use {accountNonce}");

هذان شرطان يمكن أن يجعلا المعاملة غير صالحة.

1 let mut newAccounts = accounts;
2
3 newAccounts[from].balance -= txn.amount;
4 newAccounts[from].nonce += 1;
5 newAccounts[to].balance += txn.amount;
6
7 newAccounts
8}

أنشئ مصفوفة الحسابات الجديدة ثم أرجعها.

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

تقرأ هذه الدالة العنوان من الرسالة.

1{
2 let mut result : Field = 0;
3
4 for i in 7..47 {

العنوان دائمًا ما يكون 20 بايت (المعروف أيضًا باسم 40 رقمًا سداسيًا عشريًا) ويبدأ عند الحرف رقم 7.

1 result *= 0x10;
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 result += (messageBytes[i]-48).into();
4 }
5 if messageBytes[i] >= 65 & messageBytes[i] <= 70 { // A-F
6 result += (messageBytes[i]-65+10).into()
7 }
8 if messageBytes[i] >= 97 & messageBytes[i] <= 102 { // a-f
9 result += (messageBytes[i]-97+10).into()
10 }
11 }
12
13 result
14}
15
16fn readAmountAndNonce(messageBytes: [u8; MESSAGE_LENGTH]) -> (u128, u32)
إظهار الكل

اقرأ المبلغ والـ nonce من الرسالة.

1{
2 let mut amount : u128 = 0;
3 let mut nonce: u32 = 0;
4 let mut stillReadingAmount: bool = true;
5 let mut lookingForNonce: bool = false;
6 let mut stillReadingNonce: bool = false;

في الرسالة، الرقم الأول بعد العنوان هو مبلغ Finney (المعروف أيضًا باسم جزء من ألف من ETH) للتحويل. الرقم الثاني هو الـ nonce. يتم تجاهل أي نص بينهما.

1 for i in 48..MESSAGE_LENGTH {
2 if messageBytes[i] >= 48 & messageBytes[i] <= 57 { // 0-9
3 let digit = (messageBytes[i]-48);
4
5 if stillReadingAmount {
6 amount = amount*10 + digit.into();
7 }
8
9 if lookingForNonce { // We just found it
10 stillReadingNonce = true;
11 lookingForNonce = false;
12 }
13
14 if stillReadingNonce {
15 nonce = nonce*10 + digit.into();
16 }
17 } else {
18 if stillReadingAmount {
19 stillReadingAmount = false;
20 lookingForNonce = true;
21 }
22 if stillReadingNonce {
23 stillReadingNonce = false;
24 }
25 }
26 }
27
28 (amount, nonce)
29}
إظهار الكل

إرجاع tuple (opens in a new tab) هو طريقة نوار لإرجاع قيم متعددة من دالة.

1fn readTransferTxn(message: str<MESSAGE_LENGTH>) -> TransferTxn
2{
3 let mut txn: TransferTxn = TransferTxn { from: 0, to: 0, amount:0, nonce:0 };
4 let messageBytes = message.as_bytes();
5
6 txn.to = readAddress(messageBytes);
7 let (amount, nonce) = readAmountAndNonce(messageBytes);
8 txn.amount = amount;
9 txn.nonce = nonce;
10
11 txn
12}
إظهار الكل

تحول هذه الدالة الرسالة إلى بايتات، ثم تحول المبالغ إلى TransferTxn.

1// The equivalent to Viem's hashMessage
2// https://viem.sh/docs/utilities/hashMessage#hashmessage
3fn hashMessage(message: str<MESSAGE_LENGTH>) -> [u8;32] {

تمكنا من استخدام تجزئة (هاش) Pedersen للحسابات لأنها لا يتم تجزئتها إلا داخل إثبات المعرفة الصفرية. ولكن، في هذا الرمز، نحتاج إلى التحقق من توقيع الرسالة، والذي يتم إنشاؤه بواسطة المتصفح. لذلك، نحتاج إلى اتباع تنسيق توقيع إيثريوم في EIP 191 (opens in a new tab). هذا يعني أننا بحاجة إلى إنشاء مخزن مؤقت مدمج ببادئة قياسية، وطول الرسالة في ASCII، والرسالة نفسها، واستخدام keccak256 القياسي لإيثريوم لتجزئتها.

1 // ASCII prefix
2 let prefix_bytes = [
3 0x19, // \x19
4 0x45, // 'E'
5 0x74, // 't'
6 0x68, // 'h'
7 0x65, // 'e'
8 0x72, // 'r'
9 0x65, // 'e'
10 0x75, // 'u'
11 0x6D, // 'm'
12 0x20, // ' '
13 0x53, // 'S'
14 0x69, // 'i'
15 0x67, // 'g'
16 0x6E, // 'n'
17 0x65, // 'e'
18 0x64, // 'd'
19 0x20, // ' '
20 0x4D, // 'M'
21 0x65, // 'e'
22 0x73, // 's'
23 0x73, // 's'
24 0x61, // 'a'
25 0x67, // 'g'
26 0x65, // 'e'
27 0x3A, // ':'
28 0x0A // '\n'
29 ];
إظهار الكل

لتجنب الحالات التي يطلب فيها تطبيق من المستخدم توقيع رسالة يمكن استخدامها كمعاملة أو لغرض آخر، يحدد EIP 191 أن جميع الرسائل الموقعة تبدأ بالحرف 0x19 (ليس حرف ASCII صالحًا) متبوعًا بـ Ethereum Signed Message: وسطر جديد.

1 let mut buffer: [u8; HASH_BUFFER_SIZE] = [0u8; HASH_BUFFER_SIZE];
2 for i in 0..26 {
3 buffer[i] = prefix_bytes[i];
4 }
5
6 let messageBytes : [u8; MESSAGE_LENGTH] = message.as_bytes();
7
8 if MESSAGE_LENGTH <= 9 {
9 for i in 0..1 {
10 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
11 }
12
13 for i in 0..MESSAGE_LENGTH {
14 buffer[i+26+1] = messageBytes[i];
15 }
16 }
17
18 if MESSAGE_LENGTH >= 10 & MESSAGE_LENGTH <= 99 {
19 for i in 0..2 {
20 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
21 }
22
23 for i in 0..MESSAGE_LENGTH {
24 buffer[i+26+2] = messageBytes[i];
25 }
26 }
27
28 if MESSAGE_LENGTH >= 100 {
29 for i in 0..3 {
30 buffer[i+26] = ASCII_MESSAGE_LENGTH[i];
31 }
32
33 for i in 0..MESSAGE_LENGTH {
34 buffer[i+26+3] = messageBytes[i];
35 }
36 }
37
38 assert(MESSAGE_LENGTH < 1000, "Messages whose length is over three digits are not supported");
إظهار الكل

تعامل مع أطوال الرسائل التي تصل إلى 999 وفشل إذا كانت أكبر. لقد أضفت هذا الرمز، على الرغم من أن طول الرسالة ثابت، لأنه يسهل تغييره. في نظام الإنتاج، من المحتمل أن تفترض فقط أن MESSAGE_LENGTH لا يتغير من أجل أداء أفضل.

1 keccak256::keccak256(buffer, HASH_BUFFER_SIZE)
2}

استخدم دالة keccak256 القياسية لإيثريوم.

1fn signatureToAddressAndHash(
2 message: str<MESSAGE_LENGTH>,
3 pubKeyX: [u8; 32],
4 pubKeyY: [u8; 32],
5 signature: [u8; 64]
6 ) -> (Field, Field, Field) // address, first 16 bytes of hash, last 16 bytes of hash
7{

تتحقق هذه الدالة من التوقيع، والذي يتطلب تجزئة (هاش) الرسالة. ثم تزودنا بالعنوان الذي وقّعها وتجزئة (هاش) الرسالة. يتم توفير تجزئة (هاش) الرسالة في قيمتي Field لأنه من الأسهل استخدامها في بقية البرنامج من مصفوفة بايت.

نحتاج إلى استخدام قيمتي Field لأن حسابات الحقل تتم باقي قسمة (opens in a new tab) عدد كبير، ولكن هذا الرقم عادة ما يكون أقل من 256 بت (وإلا فسيكون من الصعب إجراء تلك الحسابات في آلة إيثريوم الافتراضية).

1 let hash = hashMessage(message);
2
3 let mut (hash1, hash2) = (0,0);
4
5 for i in 0..16 {
6 hash1 = hash1*256 + hash[31-i].into();
7 hash2 = hash2*256 + hash[15-i].into();
8 }

حدد hash1 وhash2 كمتغيرات قابلة للتغيير، واكتب التجزئة (الهاش) فيها بايتًا ببايت.

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

هذا مشابه لـ ecrecover في سوليديتي (opens in a new tab)، مع اختلافين مهمين:

  • إذا لم يكن التوقيع صالحًا، يفشل استدعاء assert ويتم إحباط البرنامج.
  • في حين يمكن استعادة المفتاح العام من التوقيع والتجزئة (الهاش)، فهذه معالجة يمكن إجراؤها خارجيًا وبالتالي لا تستحق القيام بها داخل إثبات المعرفة الصفرية. إذا حاول شخص ما خداعنا هنا، فسيفشل التحقق من التوقيع.
1 hash1,
2 hash2
3 )
4}
5
6fn main(
7 accounts: [Account; ACCOUNT_NUMBER],
8 message: str<MESSAGE_LENGTH>,
9 pubKeyX: [u8; 32],
10 pubKeyY: [u8; 32],
11 signature: [u8; 64],
12 ) -> pub (
13 Field, // Hash of old accounts array
14 Field, // Hash of new accounts array
15 Field, // First 16 bytes of message hash
16 Field, // Last 16 bytes of message hash
17 )
إظهار الكل

أخيرًا، نصل إلى الدالة main. نحتاج إلى إثبات أن لدينا معاملة تغير بشكل صحيح تجزئة (هاش) الحسابات من القيمة القديمة إلى القيمة الجديدة. نحتاج أيضًا إلى إثبات أن لديها تجزئة (هاش) المعاملة المحددة هذه حتى يعرف الشخص الذي أرسلها أن معاملته قد تمت معالجتها.

1{
2 let mut txn = readTransferTxn(message);

نحتاج إلى أن يكون txn قابلاً للتغيير لأننا لا نقرأ عنوان "من" من الرسالة، بل نقرأه من التوقيع.

1 let (fromAddress, txnHash1, txnHash2) = signatureToAddressAndHash(
2 message,
3 pubKeyX,
4 pubKeyY,
5 signature);
6
7 txn.from = fromAddress;
8
9 let newAccounts = apply_transfer_txn(accounts, txn);
10
11 (
12 hash_accounts(accounts),
13 hash_accounts(newAccounts),
14 txnHash1,
15 txnHash2
16 )
17}
إظهار الكل

المرحلة 2 - إضافة خادم

في المرحلة الثانية، نضيف خادمًا يستقبل وينفذ معاملات التحويل من المتصفح.

لرؤيته أثناء العمل:

  1. أوقف فيت إذا كان قيد التشغيل.

  2. قم بتنزيل الفرع الذي يتضمن الخادم وتأكد من أن لديك جميع الوحدات النمطية اللازمة.

    1git checkout 02-add-server
    2cd client
    3npm install
    4cd ../server
    5npm install

    ليست هناك حاجة لترجمة رمز نوار، فهو نفس الرمز الذي استخدمته للمرحلة 1.

  3. ابدأ الخادم.

    1npm run start
  4. في نافذة سطر أوامر منفصلة، قم بتشغيل فيت لخدمة رمز المتصفح.

    1cd client
    2npm run dev
  5. تصفح رمز العميل على http://localhost:5173 (opens in a new tab)

  6. قبل أن تتمكن من إصدار معاملة، تحتاج إلى معرفة الـ nonce، بالإضافة إلى المبلغ الذي يمكنك إرساله. للحصول على هذه المعلومات، انقر فوق تحديث بيانات الحساب وقم بتوقيع الرسالة.

    لدينا معضلة هنا. من ناحية، لا نريد توقيع رسالة يمكن إعادة استخدامها (هجوم إعادة الإرسال (opens in a new tab))، ولهذا السبب نريد nonce في المقام الأول. ولكن، ليس لدينا nonce بعد. الحل هو اختيار nonce يمكن استخدامه مرة واحدة فقط ولدينا بالفعل على كلا الجانبين، مثل الوقت الحالي.

    المشكلة في هذا الحل هي أن الوقت قد لا يكون متزامنًا تمامًا. لذلك بدلاً من ذلك، نوقع قيمة تتغير كل دقيقة. هذا يعني أن نافذة تعرضنا لهجمات إعادة الإرسال هي دقيقة واحدة على الأكثر. بالنظر إلى أنه في الإنتاج سيتم حماية الطلب الموقع بواسطة TLS، وأن الجانب الآخر من النفق - الخادم - يمكنه بالفعل الكشف عن الرصيد والـ nonce (يجب أن يعرفهما للعمل)، فهذا خطر مقبول.

  7. بمجرد أن يستعيد المتصفح الرصيد والـ nonce، فإنه يعرض نموذج التحويل. حدد عنوان الوجهة والمبلغ وانقر فوق تحويل. قم بتوقيع هذا الطلب.

  8. لرؤية التحويل، إما تحديث بيانات الحساب أو انظر في النافذة التي تقوم فيها بتشغيل الخادم. يسجل الخادم الحالة في كل مرة تتغير فيها.

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 36000 finney (milliEth) 0 processed
    8New state:
    90xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 64000 (1)
    100x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 100000 (0)
    110x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    120x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    130x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    14Txn send 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 7200 finney (milliEth) 1 processed
    15New state:
    160xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 56800 (2)
    170x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    180x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    190x90F79bf6EB2c4f870365E785982E1f101E93b906 has 136000 (0)
    200x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    21Txn send 0x90F79bf6EB2c4f870365E785982E1f101E93b906 3000 finney (milliEth) 2 processed
    22New state:
    230xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 has 53800 (3)
    240x70997970C51812dc3A010C7d01b50e0d17dc79C8 has 107200 (0)
    250x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC has 100000 (0)
    260x90F79bf6EB2c4f870365E785982E1f101E93b906 has 139000 (0)
    270x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 has 100000 (0)
    إظهار الكل

server/index.mjs

يحتوي هذا الملف (opens in a new tab) على عملية الخادم، ويتفاعل مع رمز نوار في main.nr (opens in a new tab). فيما يلي شرح للأجزاء المثيرة للاهتمام.

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

تتفاعل مكتبة noir.js (opens in a new tab) بين رمز جافا سكريبت ورمز نوار.

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

قم بتحميل الدائرة الحسابية - برنامج نوار المترجم الذي أنشأناه في المرحلة السابقة - واستعد لتنفيذه.

1// We only provide account information in return to a signed request
2const accountInformation = async signature => {
3 const fromAddress = await recoverAddress({
4 hash: hashMessage("Get account data " + Math.floor((new Date().getTime())/60000)),
5 signature
6 })

لتوفير معلومات الحساب، نحتاج فقط إلى التوقيع. والسبب هو أننا نعرف بالفعل ما ستكون عليه الرسالة، وبالتالي تجزئة (هاش) الرسالة.

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

معالجة رسالة وتنفيذ المعاملة التي تقوم بتشفيرها.

1 // Get the public key
2 const pubKey = await recoverPublicKey({
3 hash,
4 signature
5 })

الآن بعد أن قمنا بتشغيل جافا سكريبت على الخادم، يمكننا استرداد المفتاح العام هناك بدلاً من العميل.

1 let noirResult
2 try {
3 noirResult = await noir.execute({
4 message,
5 signature: signature.slice(2,-2).match(/.{2}/g).map(x => `0x${x}`),
6 pubKeyX,
7 pubKeyY,
8 accounts: Accounts
9 })
إظهار الكل

noir.execute يقوم بتشغيل برنامج نوار. المعلمات تعادل تلك المتوفرة في Prover.toml (opens in a new tab). لاحظ أنه يتم توفير القيم الطويلة كمصفوفة من السلاسل السداسية العشرية (["0x60", "0xA7"])، وليس كقيمة سداسية عشرية واحدة (0x60A7)، بالطريقة التي يفعلها فيم.

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

إذا كان هناك خطأ، قم بالتقاطه ثم قم بنقل نسخة مبسطة إلى العميل.

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

قم بتطبيق المعاملة. لقد فعلنا ذلك بالفعل في رمز نوار، ولكن من الأسهل القيام به مرة أخرى هنا بدلاً من استخراج النتيجة من هناك.

1let Accounts = [
2 {
3 address: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266",
4 balance: 5000,
5 nonce: 0,
6 },

هيكل Accounts الأولي.

المرحلة 3 - عقود إيثريوم الذكية

  1. أوقف عمليات الخادم والعميل.

  2. قم بتنزيل الفرع الذي يحتوي على العقود الذكية وتأكد من أن لديك جميع الوحدات النمطية اللازمة.

    1git checkout 03-smart-contracts
    2cd client
    3npm install
    4cd ../server
    5npm install
  3. قم بتشغيل anvil في نافذة سطر أوامر منفصلة.

  4. قم بإنشاء مفتاح التحقق ومحقق سوليديتي، ثم انسخ رمز المحقق إلى مشروع سوليديتي.

    1cd noir
    2bb write_vk -b ./target/zkBank.json -o ./target --oracle_hash keccak
    3bb write_solidity_verifier -k ./target/vk -o ./target/Verifier.sol
    4cp target/Verifier.sol ../../smart-contracts/src
  5. اذهب إلى العقود الذكية وقم بتعيين متغيرات البيئة لاستخدام بلوكتشين anvil.

    1cd ../../smart-contracts
    2export ETH_RPC_URL=http://localhost:8545
    3ETH_PRIVATE_KEY=ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
  6. قم بنشر Verifier.sol وقم بتخزين العنوان في متغير بيئة.

    1VERIFIER_ADDRESS=`forge create src/Verifier.sol:HonkVerifier --private-key $ETH_PRIVATE_KEY --optimize --broadcast | awk '/Deployed to:/ {print $3}'`
    2echo $VERIFIER_ADDRESS
  7. قم بنشر عقد ZkBank.

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

    القيمة 0x199..67b هي تجزئة (هاش) Pederson للحالة الأولية لـ Accounts. إذا قمت بتعديل هذه الحالة الأولية في server/index.mjs، يمكنك تشغيل معاملة لرؤية التجزئة (الهاش) الأولية التي أبلغ عنها إثبات المعرفة الصفرية.

  8. قم بتشغيل الخادم.

    1cd ../server
    2npm run start
  9. قم بتشغيل العميل في نافذة سطر أوامر مختلفة.

    1cd client
    2npm run dev
  10. قم بتشغيل بعض المعاملات.

  11. للتحقق من أن الحالة قد تغيرت على السلسلة، أعد تشغيل عملية الخادم. لاحظ أن ZkBank لم يعد يقبل المعاملات، لأن قيمة التجزئة (الهاش) الأصلية في المعاملات تختلف عن قيمة التجزئة (الهاش) المخزنة على السلسلة.

    هذا هو نوع الخطأ المتوقع.

    1ori@CryptoDocGuy:~/x/250911-zk-bank/server$ npm run start
    2
    3> server@1.0.0 start
    4> node --experimental-json-modules index.mjs
    5
    6Listening on port 3000
    7Verification error: ContractFunctionExecutionError: The contract function "processTransaction" reverted with the following reason:
    8Wrong old state hash
    9
    10Contract Call:
    11 address: 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
    12 function: processTransaction(bytes _proof, bytes32[] _publicInputs)
    13 args: (0x0000000000000000000000000000000000000000000000042ab5d6d1986846cf00000000000000000000000000000000000000000000000b75c020998797da7800000000000000000000000000000000000000000000000
    إظهار الكل

server/index.mjs

تتعلق التغييرات في هذا الملف في الغالب بإنشاء الإثبات الفعلي وتقديمه على السلسلة.

1import { exec } from 'child_process'
2import util from 'util'
3
4const execPromise = util.promisify(exec)

نحتاج إلى استخدام حزمة Barretenberg (opens in a new tab) لإنشاء الإثبات الفعلي لإرساله على السلسلة. يمكننا استخدام هذه الحزمة إما عن طريق تشغيل واجهة سطر الأوامر (bb) أو عن طريق استخدام مكتبة جافا سكريبت، bb.js (opens in a new tab). مكتبة جافا سكريبت أبطأ بكثير من تشغيل الرمز محليًا، لذلك نستخدم exec (opens in a new tab) هنا لاستخدام سطر الأوامر.

لاحظ أنه إذا قررت استخدام bb.js، فأنت بحاجة إلى استخدام إصدار متوافق مع إصدار نوار الذي تستخدمه. في وقت كتابة هذا التقرير، يستخدم الإصدار الحالي من نوار (1.0.0-beta.11) إصدار bb.js رقم 0.87.

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

العنوان هنا هو الذي تحصل عليه عندما تبدأ بـ anvil نظيف وتتبع التوجيهات أعلاه.

1const walletClient = createWalletClient({
2 chain: anvil,
3 transport: http(),
4 account: privateKeyToAccount("0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6")
5})

هذا المفتاح الخاص هو أحد الحسابات الممولة مسبقًا الافتراضية في anvil.

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

أنشئ إثباتًا باستخدام الملف التنفيذي bb.

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

اكتب الشاهد في ملف.

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

أنشئ الإثبات فعليًا. تنشئ هذه الخطوة أيضًا ملفًا به المتغيرات العامة، لكننا لا نحتاج إليه. لقد حصلنا بالفعل على تلك المتغيرات من noir.execute.

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

الإثبات هو مصفوفة JSON من قيم Field، يتم تمثيل كل منها بقيمة سداسية عشرية. ومع ذلك، نحتاج إلى إرسالها في المعاملة كقيمة bytes واحدة، والتي يمثلها فيم بسلسلة سداسية عشرية كبيرة. هنا نغير التنسيق عن طريق ربط جميع القيم، وإزالة كل 0x، ثم إضافة واحدة في النهاية.

1 await execPromise(`rm -r ${fname} ${fileID}`)
2
3 return proof
4}

تنظيف وإرجاع الإثبات.

1const processMessage = async (message, signature) => {
2 .
3 .
4 .
5
6 const publicFields = noirResult.returnValue.map(x=>'0x' + x.slice(2).padStart(64, "0"))

يجب أن تكون الحقول العامة مصفوفة من قيم 32 بايت. ولكن، بما أننا احتجنا إلى تقسيم تجزئة (هاش) المعاملة بين قيمتي Field، فإنها تظهر كقيمة 16 بايت. هنا نضيف أصفارًا حتى يفهم فيم أنها في الواقع 32 بايت.

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

يستخدم كل عنوان كل nonce مرة واحدة فقط حتى نتمكن من استخدام مزيج من fromAddress وnonce كمعرّف فريد لملف الشاهد ودليل الإخراج.

1 try {
2 await zkBank.write.processTransaction([
3 proof, publicFields])
4 } catch (err) {
5 console.log(`Verification error: ${err}`)
6 throw Error("Can't verify the transaction onchain")
7 }
8 .
9 .
10 .
11}
إظهار الكل

أرسل المعاملة إلى السلسلة.

smart-contracts/src/ZkBank.sol

هذا هو الرمز الموجود على السلسلة الذي يستقبل المعاملة.

1// SPDX-License-Identifier: MIT
2
3pragma solidity >=0.8.21;
4
5import {HonkVerifier} from "./Verifier.sol";
6
7contract ZkBank {
8 HonkVerifier immutable myVerifier;
9 bytes32 currentStateHash;
10
11 constructor(address _verifierAddress, bytes32 _initialStateHash) {
12 currentStateHash = _initialStateHash;
13 myVerifier = HonkVerifier(_verifierAddress);
14 }
إظهار الكل

يحتاج الرمز الموجود على السلسلة إلى تتبع متغيرين: المحقق (عقد منفصل يتم إنشاؤه بواسطة nargo) وتجزئة (هاش) الحالة الحالية.

1 event TransactionProcessed(
2 bytes32 indexed transactionHash,
3 bytes32 oldStateHash,
4 bytes32 newStateHash
5 );

في كل مرة تتغير فيها الحالة، نصدر حدث TransactionProcessed.

1 function processTransaction(
2 bytes calldata _proof,
3 bytes32[] calldata _publicFields
4 ) public {

تعالج هذه الدالة المعاملات. تحصل على الإثبات (كـ bytes) والمدخلات العامة (كمصفوفة bytes32)، بالتنسيق الذي يتطلبه المحقق (لتقليل المعالجة على السلسلة وبالتالي تكاليف الغاز).

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

يجب أن يكون إثبات المعرفة الصفرية هو أن المعاملة تتغير من التجزئة (الهاش) الحالية إلى تجزئة (هاش) جديدة.

1 myVerifier.verify(_proof, _publicFields);

استدعِ عقد المحقق للتحقق من إثبات المعرفة الصفرية. تعيد هذه الخطوة المعاملة إذا كان إثبات المعرفة الصفرية خاطئًا.

1 currentStateHash = _publicFields[1];
2
3 emit TransactionProcessed(
4 _publicFields[2]<<128 | _publicFields[3],
5 _publicFields[0],
6 _publicFields[1]
7 );
8 }
9}
إظهار الكل

إذا تم التحقق من كل شيء، قم بتحديث تجزئة (هاش) الحالة إلى القيمة الجديدة وأصدر حدث TransactionProcessed.

إساءة استخدام المكون المركزي

يتكون أمن المعلومات من ثلاث سمات:

  • السرية، لا يمكن للمستخدمين قراءة المعلومات التي لم يتم التصريح لهم بقراءتها.
  • النزاهة، لا يمكن تغيير المعلومات إلا من قبل المستخدمين المصرح لهم بطريقة مصرح بها.
  • التوافر، يمكن للمستخدمين المصرح لهم استخدام النظام.

في هذا النظام، يتم توفير النزاهة من خلال إثباتات المعرفة الصفرية. من الصعب جدًا ضمان التوافر، والسرية مستحيلة، لأن البنك يجب أن يعرف رصيد كل حساب وجميع المعاملات. لا توجد طريقة لمنع كيان لديه معلومات من مشاركة تلك المعلومات.

قد يكون من الممكن إنشاء بنك سري حقًا باستخدام العناوين الخفية (opens in a new tab)، ولكن هذا خارج نطاق هذا المقال.

معلومات خاطئة

إحدى الطرق التي يمكن بها للخادم انتهاك النزاهة هي توفير معلومات خاطئة عند طلب البيانات (opens in a new tab).

لحل هذه المشكلة، يمكننا كتابة برنامج نوار ثانٍ يتلقى الحسابات كمدخل خاص والعنوان الذي تُطلب معلومات عنه كمدخل عام. الناتج هو الرصيد والـ nonce لهذا العنوان، وتجزئة (هاش) الحسابات.

بالطبع، لا يمكن التحقق من هذا الإثبات على السلسلة، لأننا لا نريد نشر nonces والأرصدة على السلسلة. ومع ذلك، يمكن التحقق منه بواسطة رمز العميل الذي يعمل في المتصفح.

المعاملات القسرية

الآلية المعتادة لضمان التوافر ومنع الرقابة على L2s هي المعاملات القسرية (opens in a new tab). لكن المعاملات القسرية لا تتحد مع إثباتات المعرفة الصفرية. الخادم هو الكيان الوحيد الذي يمكنه التحقق من المعاملات.

يمكننا تعديل smart-contracts/src/ZkBank.sol لقبول المعاملات القسرية ومنع الخادم من تغيير الحالة حتى تتم معالجتها. ومع ذلك، هذا يفتحنا على هجوم بسيط لرفض الخدمة. ماذا لو كانت المعاملة القسرية غير صالحة وبالتالي من المستحيل معالجتها؟

الحل هو الحصول على إثبات المعرفة الصفرية بأن المعاملة القسرية غير صالحة. وهذا يمنح الخادم ثلاثة خيارات:

  • معالجة المعاملة القسرية، وتوفير إثبات المعرفة الصفرية بأنه قد تمت معالجتها وتجزئة (هاش) الحالة الجديدة.
  • رفض المعاملة القسرية، وتوفير إثبات المعرفة الصفرية للعقد بأن المعاملة غير صالحة (عنوان غير معروف، nonce سيئ، أو رصيد غير كافٍ).
  • تجاهل المعاملة القسرية. لا توجد طريقة لإجبار الخادم على معالجة المعاملة فعليًا، ولكن هذا يعني أن النظام بأكمله غير متاح.

سندات التوافر

في التنفيذ الواقعي، من المحتمل أن يكون هناك نوع من دافع الربح للحفاظ على تشغيل الخادم. يمكننا تعزيز هذا الحافز من خلال جعل الخادم ينشر سند توافر يمكن لأي شخص حرقه إذا لم تتم معالجة معاملة قسرية خلال فترة معينة.

رمز نوار سيئ

عادة، لجعل الناس يثقون في عقد ذكي، نقوم بتحميل الرمز المصدري إلى مستكشف كتل (opens in a new tab). ولكن، في حالة إثباتات المعرفة الصفرية، هذا غير كافٍ.

يحتوي Verifier.sol على مفتاح التحقق، وهو دالة لبرنامج نوار. ولكن، هذا المفتاح لا يخبرنا بما كان عليه برنامج نوار. للحصول على حل موثوق به فعليًا، تحتاج إلى تحميل برنامج نوار (والإصدار الذي أنشأه). خلاف ذلك، قد تعكس إثباتات المعرفة الصفرية برنامجًا مختلفًا، برنامجًا به باب خلفي.

حتى يبدأ مستكشفو الكتل في السماح لنا بتحميل برامج نوار والتحقق منها، يجب عليك القيام بذلك بنفسك (ويفضل أن يكون ذلك على آي بي إف إس). بعد ذلك، سيتمكن المستخدمون المتطورون من تنزيل الرمز المصدري، وتجميعه بأنفسهم، وإنشاء Verifier.sol، والتحقق من أنه مطابق للرمز الموجود على السلسلة.

الخلاصة

تتطلب التطبيقات من نوع Plasma مكونًا مركزيًا لتخزين المعلومات. يفتح هذا ثغرات أمنية محتملة ولكن، في المقابل، يسمح لنا بالحفاظ على الخصوصية بطرق غير متوفرة على البلوكتشين نفسه. باستخدام إثباتات المعرفة الصفرية، يمكننا ضمان النزاهة وربما جعلها مفيدة اقتصاديًا لمن يدير المكون المركزي للحفاظ على التوافر.

انظر هنا لمزيد من أعمالي (opens in a new tab).

شكر وتقدير

  • قرأ Josh Crites مسودة هذا المقال وساعدني في حل مشكلة شائكة في نوار.

أي أخطاء متبقية هي مسؤوليتي.

آخر تحديث للصفحة: 28 أكتوبر 2025

هل كانت تعليمات الاستخدام هذه مفيدة؟