إثباتات ميركل لسلامة البيانات خارج السلسلة
المقدمة
من الناحية المثالية، نود تخزين كل شيء في مساحة تخزين إيثيريوم، والتي يتم تخزينها عبر آلاف أجهزة الكمبيوتر وتتمتع بتوافر عالٍ للغاية (لا يمكن فرض رقابة على البيانات) وسلامة (لا يمكن تعديل البيانات بطريقة غير مصرح بها)، ولكن تخزين كلمة بحجم 32-byte يكلف عادةً 20,000 غاز. في وقت كتابة هذا المقال، تعادل هذه التكلفة $6.60. بتكلفة 21 سنتًا لكل بايت، يعد هذا مكلفًا للغاية للعديد من الاستخدامات.
لحل هذه المشكلة، طور نظام إيثيريوم البيئي العديد من الطرق البديلة لتخزين البيانات بطريقة لامركزية. عادة ما تنطوي على مقايضة بين التوافر والسعر. ومع ذلك، عادة ما تكون السلامة مضمونة.
في هذه المقالة ستتعلم كيفية ضمان سلامة البيانات دون تخزين البيانات على سلسلة الكتل، باستخدام إثباتات ميركل (opens in a new tab).
كيف تعمل؟
نظريًا، يمكننا فقط تخزين تجزئة البيانات على السلسلة، وإرسال جميع البيانات في المعاملات التي تتطلبها. ومع ذلك، لا يزال هذا مكلفًا للغاية. يكلف بايت من البيانات لمعاملة حوالي 16 غاز، أي حوالي نصف سنت حاليًا، أو حوالي $5 لكل كيلوبايت. بتكلفة $5000 لكل ميغابايت، لا يزال هذا مكلفًا للغاية للعديد من الاستخدامات، حتى بدون التكلفة الإضافية لعملية التجزئة للبيانات.
الحل هو إجراء عملية التجزئة بشكل متكرر لمجموعات فرعية مختلفة من البيانات، لذلك بالنسبة للبيانات التي لا تحتاج إلى إرسالها، يمكنك فقط إرسال تجزئة. يمكنك القيام بذلك باستخدام شجرة ميركل، وهي بنية بيانات شجرية حيث تكون كل عقدة عبارة عن تجزئة للعقد الموجودة أسفلها:
تجزئة الجذر هي الجزء الوحيد الذي يحتاج إلى التخزين على السلسلة. لإثبات قيمة معينة، فإنك توفر جميع التجزئات التي يجب دمجها معها للحصول على الجذر. على سبيل المثال، لإثبات C فإنك توفر D و H(A-B) و H(E-H).
التنفيذ
يتم توفير نموذج التعليمات البرمجية هنا (opens in a new tab).
التعليمات البرمجية خارج السلسلة
في هذه المقالة نستخدم JavaScript للحسابات خارج السلسلة. تحتوي معظم التطبيقات اللامركزية على مكونها خارج السلسلة مكتوبًا بلغة JavaScript.
إنشاء جذر ميركل
أولاً نحتاج إلى توفير جذر ميركل للسلسلة.
const ethers = require("ethers")
نستخدم دالة تجزئة من حزمة ethers (opens in a new tab).
// البيانات الأولية التي يجب علينا التحقق من سلامتها. أول بايتين
// هما معرف مستخدم، وآخر بايتين هما كمية الرموز التي
// يملكها المستخدم في الوقت الحالي.
const dataArray = [
0x0bad0010, 0x60a70020, 0xbeef0030, 0xdead0040, 0xca110050, 0x0e660060,
0xface0070, 0xbad00080, 0x060d0091,
]
يؤدي تشفير كل إدخال إلى عدد صحيح واحد بحجم 256-bit إلى تعليمات برمجية أقل قابلية للقراءة مقارنة باستخدام JSON، على سبيل المثال. ومع ذلك، هذا يعني معالجة أقل بكثير لاسترداد البيانات في العقد، وبالتالي تكاليف غاز أقل بكثير. يمكنك قراءة JSON على السلسلة (opens in a new tab)، إنها مجرد فكرة سيئة إذا كان من الممكن تجنبها.
// مصفوفة قيم التجزئة، كـ BigInts
const hashArray = dataArray
في هذه الحالة، بياناتنا عبارة عن قيم بحجم 256-bit في البداية، لذلك لا حاجة إلى معالجة. إذا استخدمنا بنية بيانات أكثر تعقيدًا، مثل السلاسل النصية، فنحن بحاجة إلى التأكد من إجراء عملية التجزئة للبيانات أولاً للحصول على مصفوفة من التجزئات. لاحظ أن هذا يرجع أيضًا إلى أننا لا نهتم إذا كان المستخدمون يعرفون معلومات المستخدمين الآخرين. بخلاف ذلك، كان علينا إجراء عملية التجزئة حتى لا يعرف المستخدم 1 قيمة المستخدم 0، ولن يعرف المستخدم 2 قيمة المستخدم 3، وما إلى ذلك.
// التحويل بين السلسلة النصية التي تتوقعها دالة التجزئة و
// الـ BigInt الذي نستخدمه في كل مكان آخر.
const hash = (x) =>
BigInt(ethers.utils.keccak256("0x" + x.toString(16).padStart(64, 0)))
تتوقع دالة تجزئة ethers الحصول على سلسلة نصية JavaScript برقم سداسي عشري، مثل 0x60A7، وتستجيب بسلسلة نصية أخرى بنفس البنية. ومع ذلك، بالنسبة لبقية التعليمات البرمجية، من الأسهل استخدام BigInt، لذلك نقوم بالتحويل إلى سلسلة نصية سداسية عشرية والعودة مرة أخرى.
// تجزئة متماثلة لزوج حتى لا نهتم إذا تم عكس الترتيب.
const 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') مساوية لقيمة معروفة (الفرع التالي في الطريق إلى الجذر)، وهو أمر أصعب بكثير.
// القيمة التي تشير إلى أن فرعًا معينًا فارغ، ولا
// يحتوي على قيمة
const empty = 0n
عندما لا يكون عدد القيم قوة صحيحة للعدد اثنين، نحتاج إلى التعامل مع الفروع الفارغة. الطريقة التي يقوم بها هذا البرنامج هي وضع الصفر كعنصر نائب.
// حساب مستوى واحد لأعلى في الشجرة لمصفوفة تجزئة عن طريق أخذ تجزئة
// كل زوج بالتسلسل
const oneLevelUp = (inputArray) => {
var result = []
var inp = [...inputArray] // لتجنب الكتابة فوق المدخلات // إضافة قيمة فارغة إذا لزم الأمر (نحتاج إلى أن تكون جميع الأوراق // مزدوجة)
if (inp.length % 2 === 1) inp.push(empty)
for (var i = 0; i < inp.length; i += 2)
result.push(pairHash(inp[i], inp[i + 1]))
return result
} // oneLevelUp
هذه الدالة "تتسلق" مستوى واحدًا في شجرة ميركل عن طريق إجراء عملية التجزئة لأزواج القيم في الطبقة الحالية. لاحظ أن هذا ليس التنفيذ الأكثر كفاءة، كان بإمكاننا تجنب نسخ الإدخال وإضافة hashEmpty فقط عند الاقتضاء في الحلقة، ولكن تم تحسين هذه التعليمات البرمجية لسهولة القراءة.
const getMerkleRoot = (inputArray) => {
var result
result = [...inputArray] // الصعود في الشجرة حتى تتبقى قيمة واحدة فقط، وهي // الجذر. // // إذا كانت الطبقة تحتوي على عدد فردي من الإدخالات، فإن // الكود في oneLevelUp يضيف قيمة فارغة، لذلك إذا كان لدينا، على سبيل المثال، // 10 أوراق، فسيكون لدينا 5 فروع في الطبقة الثانية، و3 // فروع في الثالثة، و2 في الرابعة، والجذر هو الخامس
while (result.length > 1) result = oneLevelUp(result)
return result[0]
}
للحصول على الجذر، استمر في التسلق حتى تتبقى قيمة واحدة فقط.
إنشاء إثبات ميركل
إثبات ميركل هو القيم التي يتم إجراء عملية التجزئة لها مع القيمة التي يتم إثباتها لاستعادة جذر ميركل. غالبًا ما تكون القيمة المراد إثباتها متاحة من بيانات أخرى، لذلك أفضل توفيرها بشكل منفصل بدلاً من كونها جزءًا من التعليمات البرمجية.
// يتكون إثبات ميركل من قيمة قائمة الإدخالات التي سيتم
// إجراء التجزئة معها. نظرًا لأننا نستخدم دالة تجزئة متماثلة، فإننا لا
// نحتاج إلى موقع العنصر للتحقق من الإثبات، بل لإنشائه فقط
const getMerkleProof = (inputArray, n) => {
var result = [], currentLayer = [...inputArray], currentN = n
// حتى نصل إلى القمة
while (currentLayer.length > 1) {
// لا توجد طبقات ذات طول فردي
if (currentLayer.length % 2)
currentLayer.push(empty)
result.push(currentN % 2
// إذا كان currentN فرديًا، أضفه مع القيمة التي قبله إلى الإثبات
? currentLayer[currentN-1]
// إذا كان زوجيًا، أضف القيمة التي بعده
: currentLayer[currentN+1])
نقوم بإجراء عملية التجزئة لـ (v[0],v[1]) و (v[2],v[3]) وما إلى ذلك. لذلك بالنسبة للقيم الزوجية نحتاج إلى القيمة التالية، وبالنسبة للقيم الفردية نحتاج إلى القيمة السابقة.
// الانتقال إلى الطبقة التالية لأعلى
currentN = Math.floor(currentN/2)
currentLayer = oneLevelUp(currentLayer)
} // while currentLayer.length > 1
return result
} // getMerkleProof
التعليمات البرمجية على السلسلة
أخيرًا لدينا التعليمات البرمجية التي تتحقق من الإثبات. تتم كتابة التعليمات البرمجية على السلسلة بلغة Solidity (opens in a new tab). التحسين أكثر أهمية هنا لأن الغاز مكلف نسبيًا.
//SPDX-License-Identifier: Public Domain
pragma solidity ^0.8.0;
import "hardhat/console.sol";
لقد كتبت هذا باستخدام بيئة تطوير Hardhat (opens in a new tab)، والتي تتيح لنا الحصول على مخرجات وحدة التحكم من Solidity (opens in a new tab) أثناء التطوير.
contract MerkleProof {
uint merkleRoot;
function getRoot() public view returns (uint) {
return merkleRoot;
}
// غير آمن للغاية، في كود الإنتاج يجب أن يكون الوصول إلى
// هذه الدالة مقيدًا بشدة، وربما يقتصر على
// المالك
function setRoot(uint _merkleRoot) external {
merkleRoot = _merkleRoot;
} // setRoot
دوال التعيين والحصول (Set and get) لجذر ميركل. السماح للجميع بتحديث جذر ميركل هو فكرة سيئة للغاية في نظام الإنتاج. أفعل ذلك هنا من أجل البساطة لنموذج التعليمات البرمجية. لا تفعل ذلك في نظام تهم فيه سلامة البيانات حقًا.
function hash(uint _a) internal pure returns(uint) {
return uint(keccak256(abi.encode(_a)));
}
function pairHash(uint _a, uint _b) internal pure returns(uint) {
return hash(hash(_a) ^ hash(_b));
}
تُنشئ هذه الدالة تجزئة زوجية. إنها مجرد ترجمة Solidity لتعليمات JavaScript البرمجية لـ hash و pairHash.
ملاحظة: هذه حالة أخرى من حالات التحسين لسهولة القراءة. بناءً على تعريف الدالة (opens in a new tab)، قد يكون من الممكن تخزين البيانات كقيمة bytes32 (opens in a new tab) وتجنب التحويلات.
// التحقق من إثبات ميركل
function verifyProof(uint _value, uint[] calldata _proof)
public view returns (bool) {
uint temp = _value;
uint i;
for(i=0; i<_proof.length; i++) {
temp = pairHash(temp, _proof[i]);
}
return temp == merkleRoot;
}
} // MarkleProof
في التدوين الرياضي، يبدو التحقق من إثبات ميركل هكذا: H(proof_n, H(proof_n-1, H(proof_n-2, ... H(proof_1, H(proof_0, value))...))). تنفذ هذه التعليمات البرمجية ذلك.
إثباتات ميركل والتجميعات لا يختلطان
لا تعمل إثباتات ميركل بشكل جيد مع التجميعات. السبب هو أن التجميعات تكتب جميع بيانات المعاملة على طبقة 1 (L1)، ولكنها تعالجها على طبقة 2 (L2). يبلغ متوسط تكلفة إرسال إثبات ميركل مع معاملة 638 غاز لكل طبقة (حاليًا يكلف البايت في بيانات الاستدعاء 16 غاز إذا لم يكن صفرًا، و 4 إذا كان صفرًا). إذا كان لدينا 1024 كلمة من البيانات، فإن إثبات ميركل يتطلب عشر طبقات، أو إجمالي 6380 غاز.
بالنظر على سبيل المثال إلى أوبتيميزم (opens in a new tab)، فإن كتابة غاز طبقة 1 (L1) تكلف حوالي 100 Gwei وغاز طبقة 2 (L2) يكلف 0.001 Gwei (هذا هو السعر العادي، ويمكن أن يرتفع مع الازدحام). لذلك مقابل تكلفة غاز واحد من طبقة 1 (L1)، يمكننا إنفاق مائة ألف غاز على معالجة طبقة 2 (L2). بافتراض أننا لا نستبدل التخزين، فهذا يعني أنه يمكننا كتابة حوالي خمس كلمات في التخزين على طبقة 2 (L2) بسعر غاز واحد من طبقة 1 (L1). بالنسبة لإثبات ميركل واحد، يمكننا كتابة جميع الكلمات البالغ عددها 1024 في التخزين (بافتراض أنه يمكن حسابها على السلسلة في البداية، بدلاً من توفيرها في معاملة) ولا يزال يتبقى لدينا معظم الغاز.
الخاتمة
في الحياة الواقعية، قد لا تقوم أبدًا بتنفيذ أشجار ميركل بنفسك. هناك مكتبات معروفة ومدققة يمكنك استخدامها، وبشكل عام من الأفضل عدم تنفيذ أساسيات علم التشفير بنفسك. ولكن آمل أن تفهم الآن إثباتات ميركل بشكل أفضل ويمكنك أن تقرر متى تستحق الاستخدام.
لاحظ أنه بينما تحافظ إثباتات ميركل على السلامة، فإنها لا تحافظ على التوافر. إن معرفة أنه لا يمكن لأي شخص آخر أخذ أصولك هو عزاء بسيط إذا قرر تخزين البيانات عدم السماح بالوصول ولا يمكنك بناء شجرة ميركل للوصول إليها أيضًا. لذلك من الأفضل استخدام أشجار ميركل مع نوع من التخزين اللامركزي، مثل IPFS.


