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

إثباتات ميركل لسلامة البيانات خارج السلسلة

التخزين
إعدادات متقدمة
أوري بوميرانتس
30 ديسمبر 2021
11 دقيقة قراءة

المقدمة

من الناحية المثالية، نود تخزين كل شيء في تخزين إيثريوم، والذي يتم تخزينه عبر آلاف أجهزة الكمبيوتر ويتمتع بتوافر عالٍ للغاية (لا يمكن فرض رقابة على البيانات) وسلامة (لا يمكن تعديل البيانات بطريقة غير مصرح بها)، ولكن تخزين كلمة بحجم 32 بايت يكلف عادةً 20,000 من الغاز. أثناء كتابتي لهذا، تعادل هذه التكلفة 6.60 دولار. بسعر 21 سنتًا لكل بايت، يعد هذا مكلفًا للغاية للعديد من الاستخدامات.

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

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

كيف تعمل؟

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

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

شجرة ميركل

تجزئة الجذر هي الجزء الوحيد الذي يجب تخزينه على السلسلة. لإثبات قيمة معينة، فإنك توفر جميع التجزئات التي يجب دمجها معها للحصول على الجذر. على سبيل المثال، لإثبات C، فإنك توفر D و H(A-B) و H(E-H).

إثبات قيمة C

التنفيذ

يتم توفير نموذج التعليمات البرمجية هنا (opens in a new tab).

التعليمات البرمجية خارج السلسلة

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

إنشاء جذر ميركل

أولاً، نحتاج إلى توفير جذر ميركل للسلسلة.

1const ethers = require("ethers")

نستخدم دالة التجزئة (هاش) من حزمة ethers (opens in a new tab).

1// The raw data whose integrity we have to verify. The first two bytes a // البيانات الخام التي يجب علينا التحقق من سلامتها. أول بايتين
2// are a user identifier, and the last two bytes the amount of tokens the // هما معرّف مستخدم، وآخر بايتين هما كمية الرموز التي
3// user owns at present. // يملكها المستخدم في الوقت الحالي.
4const dataArray = [
5 0x0bad0010, 0x60a70020, 0xbeef0030, 0xdead0040, 0xca110050, 0x0e660060,
6 0xface0070, 0xbad00080, 0x060d0091,
7]

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

1// The array of hash values, as BigInts // مصفوفة قيم التجزئة (هاش)، كأعداد صحيحة كبيرة (BigInts)
2const hashArray = dataArray

في هذه الحالة، بياناتنا عبارة عن قيم بحجم 256 بت في البداية، لذلك لا حاجة إلى معالجة. إذا استخدمنا بنية بيانات أكثر تعقيدًا، مثل السلاسل النصية، فنحن بحاجة إلى التأكد من إجراء التجزئة (هاش) للبيانات أولاً للحصول على مصفوفة من التجزئات. لاحظ أن هذا يرجع أيضًا إلى أننا لا نهتم إذا كان المستخدمون يعرفون معلومات المستخدمين الآخرين. وإلا لكان علينا إجراء التجزئة (هاش) حتى لا يعرف المستخدم 1 قيمة المستخدم 0، ولن يعرف المستخدم 2 قيمة المستخدم 3، وما إلى ذلك.

1// Convert between the string the hash function expects and the // التحويل بين السلسلة النصية التي تتوقعها دالة التجزئة (هاش) و
2// BigInt we use everywhere else. // العدد الصحيح الكبير (BigInt) الذي نستخدمه في كل مكان آخر.
3const hash = (x) =>
4 BigInt(ethers.utils.keccak256("0x" + x.toString(16).padStart(64, 0)))

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

1// Symmetrical hash of a pair so we won't care if the order is reversed. // التجزئة (هاش) المتماثلة لزوج حتى لا نهتم إذا تم عكس الترتيب.
2const pairHash = (a, b) => hash(hash(a) ^ hash(b))

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

تحذير: علم التشفير أصعب مما يبدو. الإصدار الأولي من هذه المقالة كان يحتوي على دالة التجزئة hash(a^b). كانت تلك فكرة سيئة لأنها تعني أنه إذا كنت تعرف القيم المشروعة لـ a و b، فيمكنك استخدام b' = a^b^a' لإثبات أي قيمة a' مطلوبة. باستخدام هذه الدالة، سيتعين عليك حساب b' بحيث تكون hash(a') ^ hash(b') مساوية لقيمة معروفة (الفرع التالي في الطريق إلى الجذر)، وهو أمر أصعب بكثير.

1// The value to denote that a certain branch is empty, doesn't // القيمة التي تشير إلى أن فرعًا معينًا فارغ، ولا
2// have a value // يحتوي على قيمة
3const empty = 0n

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

شجرة ميركل مع فروع مفقودة

1// Calculate one level up the tree of a hash array by taking the hash of // حساب مستوى واحد لأعلى في شجرة مصفوفة التجزئة (هاش) عن طريق أخذ التجزئة (هاش) لـ
2// each pair in sequence // كل زوج بالتسلسل
3const oneLevelUp = (inputArray) => {
4 var result = []
5 var inp = [...inputArray] // To avoid over writing the input // Add an empty value if necessary (we need all the leaves to be // paired) // لتجنب الكتابة فوق المدخلات // أضف قيمة فارغة إذا لزم الأمر (نحتاج إلى أن تكون جميع الأوراق // مزدوجة)
6
7 if (inp.length % 2 === 1) inp.push(empty)
8
9 for (var i = 0; i < inp.length; i += 2)
10 result.push(pairHash(inp[i], inp[i + 1]))
11
12 return result
13} // oneLevelUp // oneLevelUp

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

1const getMerkleRoot = (inputArray) => {
2 var result
3
4 result = [...inputArray] // Climb up the tree until there is only one value, that is the // root. // // If a layer has an odd number of entries the // code in oneLevelUp adds an empty value, so if we have, for example, // 10 leaves we'll have 5 branches in the second layer, 3 // branches in the third, 2 in the fourth and the root is the fifth // تسلق الشجرة حتى تتبقى قيمة واحدة فقط، وهي // الجذر. // // إذا كانت الطبقة تحتوي على عدد فردي من المدخلات، فإن // الكود في oneLevelUp يضيف قيمة فارغة، لذلك إذا كان لدينا، على سبيل المثال، // 10 أوراق، فسيكون لدينا 5 فروع في الطبقة الثانية، و 3 // فروع في الثالثة، و 2 في الرابعة، والجذر هو الخامس
5
6 while (result.length > 1) result = oneLevelUp(result)
7
8 return result[0]
9}

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

إنشاء إثبات ميركل

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

1// A merkle proof consists of the value of the list of entries to // يتكون إثبات ميركل من قيمة قائمة المدخلات التي سيتم
2// hash with. Because we use a symmetrical hash function, we don't // إجراء التجزئة (هاش) معها. لأننا نستخدم دالة تجزئة (هاش) متماثلة، فإننا لا
3// need the item's location to verify the proof, only to create it // نحتاج إلى موقع العنصر للتحقق من الإثبات، بل لإنشائه فقط
4const getMerkleProof = (inputArray, n) => {
5    var result = [], currentLayer = [...inputArray], currentN = n
6
7    // Until we reach the top // حتى نصل إلى القمة
8    while (currentLayer.length > 1) {
9        // No odd length layers // لا توجد طبقات بطول فردي
10        if (currentLayer.length % 2)
11            currentLayer.push(empty)
12
13        result.push(currentN % 2
14               // If currentN is odd, add with the value before it to the proof // إذا كان currentN فرديًا، أضفه مع القيمة التي قبله إلى الإثبات
15            ? currentLayer[currentN-1]
16               // If it is even, add the value after it // إذا كان زوجيًا، أضف القيمة التي بعده
17            : currentLayer[currentN+1])
18

نقوم بإجراء التجزئة (هاش) لـ (v[0],v[1])، (v[2],v[3])، إلخ. لذلك بالنسبة للقيم الزوجية نحتاج إلى القيمة التالية، وبالنسبة للقيم الفردية نحتاج إلى القيمة السابقة.

1        // Move to the next layer up // الانتقال إلى الطبقة التالية لأعلى
2        currentN = Math.floor(currentN/2)
3        currentLayer = oneLevelUp(currentLayer)
4    }   // while currentLayer.length > 1 // طالما currentLayer.length > 1
5
6    return result
7}   // getMerkleProof // getMerkleProof

التعليمات البرمجية على السلسلة

أخيرًا لدينا التعليمات البرمجية التي تتحقق من الإثبات. تمت كتابة التعليمات البرمجية على السلسلة بلغة Solidity (opens in a new tab). التحسين هنا أكثر أهمية بكثير لأن الغاز مكلف نسبيًا.

1//SPDX-License-Identifier: Public Domain // SPDX-License-Identifier: Public Domain
2pragma solidity ^0.8.0;
3
4import "hardhat/console.sol";

لقد كتبت هذا باستخدام بيئة تطوير Hardhat (opens in a new tab)، والتي تتيح لنا الحصول على مخرجات وحدة التحكم من Solidity (opens in a new tab) أثناء التطوير.

1
2contract MerkleProof {
3    uint merkleRoot;
4
5    function getRoot() public view returns (uint) {
6      return merkleRoot;
7    }
8
9    // Extremely insecure, in production code access to // غير آمن للغاية، في كود الإنتاج يجب أن يكون الوصول إلى
10    // this function MUST BE strictly limited, probably to an // هذه الدالة مقيدًا بشدة، وربما يقتصر على
11    // owner // المالك
12    function setRoot(uint _merkleRoot) external {
13      merkleRoot = _merkleRoot;
14    }   // setRoot // setRoot

دوال التعيين (Set) والاسترجاع (Get) لجذر ميركل. السماح للجميع بتحديث جذر ميركل هو فكرة سيئة للغاية في نظام الإنتاج. أقوم بذلك هنا من أجل التبسيط لنموذج التعليمات البرمجية. لا تفعل ذلك في نظام تهم فيه سلامة البيانات بالفعل.

1    function hash(uint _a) internal pure returns(uint) {
2      return uint(keccak256(abi.encode(_a)));
3    }
4
5    function pairHash(uint _a, uint _b) internal pure returns(uint) {
6      return hash(hash(_a) ^ hash(_b));
7    }

تُنشئ هذه الدالة تجزئة زوجية. إنها مجرد ترجمة Solidity لتعليمات JavaScript البرمجية لـ hash و pairHash.

ملاحظة: هذه حالة أخرى من حالات التحسين لسهولة القراءة. بناءً على تعريف الدالة (opens in a new tab)، قد يكون من الممكن تخزين البيانات كقيمة bytes32 (opens in a new tab) وتجنب التحويلات.

1    // Verify a Merkle proof // التحقق من إثبات ميركل
2    function verifyProof(uint _value, uint[] calldata _proof)
3        public view returns (bool) {
4      uint temp = _value;
5      uint i;
6
7      for(i=0; i<_proof.length; i++) {
8        temp = pairHash(temp, _proof[i]);
9      }
10
11      return temp == merkleRoot;
12    }
13
14}  // MarkleProof // MarkleProof

في التدوين الرياضي، يبدو التحقق من إثبات ميركل هكذا: H(proof_n, H(proof_n-1, H(proof_n-2, ... H(proof_1, H(proof_0, value))...))). هذه التعليمات البرمجية تنفذ ذلك.

إثباتات ميركل والرول أب لا يختلطان

لا تعمل إثباتات ميركل بشكل جيد مع الرول أب. السبب هو أن الرول أب يكتب جميع بيانات المعاملات على الطبقة الأولى (L1)، ولكنه يعالجها على الطبقة الثانية (L2). يبلغ متوسط تكلفة إرسال إثبات ميركل مع معاملة 638 من الغاز لكل طبقة (حاليًا يكلف البايت في بيانات الاستدعاء 16 من الغاز إذا لم يكن صفرًا، و 4 إذا كان صفرًا). إذا كان لدينا 1024 كلمة من البيانات، فإن إثبات ميركل يتطلب عشر طبقات، أو إجمالي 6380 من الغاز.

بالنظر على سبيل المثال إلى Optimism (opens in a new tab)، تكلف كتابة غاز الطبقة الأولى (L1) حوالي 100 gwei وتكلف غاز الطبقة الثانية (L2) 0.001 gwei (هذا هو السعر العادي، ويمكن أن يرتفع مع الازدحام). لذلك مقابل تكلفة غاز واحد من الطبقة الأولى (L1)، يمكننا إنفاق مائة ألف غاز على معالجة الطبقة الثانية (L2). بافتراض أننا لا نستبدل التخزين، فهذا يعني أنه يمكننا كتابة حوالي خمس كلمات في التخزين على الطبقة الثانية (L2) بسعر غاز واحد من الطبقة الأولى (L1). بالنسبة لإثبات ميركل واحد، يمكننا كتابة 1024 كلمة بالكامل في التخزين (بافتراض أنه يمكن حسابها على السلسلة في البداية، بدلاً من توفيرها في معاملة) ولا يزال يتبقى لدينا معظم الغاز.

الخاتمة

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

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

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

آخر تحديث للصفحة: 3 مارس 2026

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