در قسمت نخست توضیح داده شد که چگونه با جاوااسکریپت، بلاکچین شخصی خودتان را راهاندازی کنید. یکی از بزرگترین اشکالات آن بلاکچین ساده، نبودِ Proof-of-Work، یا به اختصار: PoW، بود که این موضوع عملاً امنیت بلاکچین شما را تماماً زیر سؤال میبرد. از این رو در دوّمین قسمت به همین موضوع پرداخته خواهد شد.
کدی که تاکنون نوشته شد شامل دو کلاس میشود که یکی بلوک را تعریف کرده و دیگری با برگرداندن آرایهای از بلوکها، یک بلاکچین را تعریف مینماید و در کل از این قرار است:
class Block { constructor(index, timeStamp, data, previousHash = '') { this.index = index; this.timeStamp = timeStamp; this.data = data; this.previousHash = previousHash; this.hash = this.calculateHash(); } calculateHash() { return SHA256(this.index + this.previousHash + this.timeStamp + JSON.stringify(this.data)).toString(); } } class Blockchain { constructor() { this.chain = [this.createGenesisBlock()]; } createGenesisBlock() { return new Block(0, "01/01/2019", "Genesis Block", "0"); } getLastestBlock() { return this.chain[this.chain.length - 1]; } addBlock(newBlock) { newBlock.previousHash = this.getLastestBlock().hash; newBlock.hash = newBlock.calculateHash(); this.chain.push(newBlock); } isChainValid() { for (let i = 1; i < this.chain.length; i++) { const currentBlock = this.chain[i]; const previousBlock = this.chain[i - 1]; if (currentBlock.hash !== currentBlock.calculateHash()) { return false; } if (currentBlock.previousHash !== previousBlock.hash) { return false; } } return true; } }
و اشکال عدم وجود PoW از متد addBlock ناشی میشود: این متد هیچ پیچیدگیای جهت ایجاد یک بلوک جدید ایجاد نمیکند. به این ترتیب با سهولت تمام میتوان نه تنها اقدام به دستکاری دادههای یک بلوک و تغییر hash آن کرد، بلکه کلّ بلوکهای بعدی زنجیره را نیز با بلوکهای جدیدی جایگزین کرد – هزاران بلوک جدید در ثانیه – تا دادههای دستکاری شده، معتبر به نظر آمده و تابع isChainValid مقدار true را برگرداند.
راه حلّ پیچیده کردن فرایند ساخت بلوک جدید در پیچیدهسازی محاسبهی hash آن با تابع calculateHash نهفته است. ساخت یک عبارت hash با استفاده از این متد چندان سخت نیست. امّا اگر ورودی پویایی در کار بوده و شرطی نیز قرار داده شود، مبنی برآنکه فقط hashهایی قابل قبول هستند که تعداد معیّنی صفر در ابتدا داشته باشند، آنگاه کامپیوتر ناچار است آنقدر مقدار ورودی را تغییر داده و hashهای گوناگون را محاسبه کند تا بالاخره به آنچه مورد نظر است برسد. بدینسان از آنجایی که hash تابعی یکطرفه بوده و حدس آنکه ورودیهایش چه بودهاند غیر ممکن است (قابل توجّه مجنونهای ریاضیات: اگر احیاناً توانستید روشی برای crack کردن خروجی توابع hash رایج امروزی پیدا کنید، قدرت و ثروت بسیاری در انتظارتان خواهد بود!)، کامپیوتر درگیر پردازش آزمون و خطای بسیار طولانیای خواهد شد. نکتهی دیگر این است که هرچه تعداد صفرهای مورد نظر بیشتر باشد، کار کامپیوتر نیز سختتر میگردد. لذا میتوان یک مفهوم درجهی سختی نیز تعریف کرد. به این ترتیب دیگر نیازی نیست که نگران افزایش توان پردازشی کامپیوترها در طول زمان بود. هر چقدر کامپیوترها سریعتر شوند، کافیست درجهی سختی بلاکچینمان را نیز به همان اندازه بالاتر ببریم تا نگذاریم کار برای کامپیوترهای جدید راحت شود.
آنچه گفته شد دقیقاً همان مفهوم Mining بوده که بسیار بعید است این روزها نام آن را نشنیده باشید – مخصوصاً پیرامون اخبار مربوط به رمزارزها (این واژه به معنای معدنکاوی است. ظاهراً میتوان جملهی «کار در معدن، سختترین کار دنیاست.» را به دنیای کامپیوترها نیز تعمیم داد!). حیات هر بلاکچین به عملیات mining وابسته است؛ چرا که برای ثبت دادههای جدید به بلوکهای جدید نیازمندیم. از آنجا که mining بسیار سخت و هزینهبر است و با بالا رفتن درجهی سختی، توان پردازشی بسیار زیادی میطلبد، و همچنین از آنجا که قرار است بلاکچین یک سیستم غیرمتمرکز باشد، میتوان وظیفهی ایجاد بلوکهای جدید را به کاربران خود سیستم واگذار کرده و در ازای ایجاد هر بلوک جدید، پاداشی داده شود تا کاربران را ترغیب به تولید بلوکهای بیشتر کند.
به عنوان نمونه، رمزارز بیتکوین برای ادامهی فعّالیتش و ثبت تراکنشهای جدید به بلوکهای جدید نیاز دارد؛ لذا به هرکس که بتواند بلوک جدیدی mine کند، مبلغی بیتکوین را پاداش میدهد. از همین روست که عدّهای «miner» بودن را به عنوان راهی برای کسب درآمد بر میگزینند. آنها با تأمین پردازندههای قدرتمند و مصرف بالای برق جهت فعّالیت آنها اقدام به mining کرده و در ازای این کار پاداشی میگیرند که ارزش آن اصولاً باید بیشتر از هزینههای صرفشده باشد – یک بازی برد-برد: شما حیات بلاکچین را تأمین کرده و بلاکچین نیز به شما پاداش میدهد.
(البته شاید بازی برد-برد-باخت، با درنظر گرفتن طبیعت به عنوان طرف سوّم! در حقیقت یکی از انتقادات اساسی به بیشتر رمزارزهای فعلی همین است که حیات آنها به شکل جاری، مستلزم مصرف انرژی زیادی بوده که به طبیعت آسیب میزند. برای مثال ادّعا میشود که در حال حاضر استخراج بیتکوین انرژی بیشتری مصرف میکند تا استخراج طلا با ارزش معادل. به همین دلیل هماکنون روشهای دیگری بجز PoW نیز درحال مطالعه و توسعه بوده که علاوه بر تضمین امنیت بلاکچین، انرژی چندانی نیز مصرف نکند. معروفترین آنها Proof-of-Stake یا (به اختصار PoS) بوده که تعدادی از رمزارزهای عموماً نسل ۲ و ۳ (بیتکوین از نسل ۱ است) به صورت آزمایشی از آن استفاده میکنند.)
برای پیادهسازی سازوکار PoW، ابتدا لازم است یک متغیّر جدید به متد سازندهی کلاس Block و همچنین به متد calculateHash اضافه نماییم تا با تغییر مقدار آن، حاصل hash نیز تغییر کرد. این ورودی میتواند هر مقداری داشته باشد و هیچ اهمّیتی ندارد – درواقع اصلاً مقدارش تصادفی است. تنها فاکتور این است که در کنار سایر مقادیر کلاس، hashـی را تشکیل داده که شرایط مورد نظر ما را داشته باشد (تعداد معیّنی صفر در ابتدا داشته باشد). پس نام آن را nonce (مخفف number only used once) نهاده و با مقدار اوّلیهی صفر تعریف میکنیم:
constructor(index, timeStamp, data, previousHash = '') { this.index = index; this.timeStamp = timeStamp; this.data = data; this.previousHash = previousHash; this.hash = this.calculateHash(); this.nonce = 0; }
سپس متدی جدید را به کلاس Block اضافه مینماییم، تحت نام mineBlock:
mineBlock(difficulty) { while(this.hash.substring(0, difficulty) !== Array(difficulty + 1).join("0")) { this.nonce++; this.hash = this.calculateHash(); } }
ضمناً مقدار nonce باید در متد calculateHash نیز اعمال شود. لذا:
calculateHash() { return SHA256(this.index + this.previousHash + this.timeStamp + JSON.stringify(this.data) + this.nonce).toString(); }
کاری که در اینجا انجام میشود از این قرار است: با دادن مقداری عددی به متغیّر difficulty و اجرای متد mineBlock، شرط موجود در حلقهی while این متد به گونهای تنظیم میشود که محدودیتی روی hashهای قابل قبول ایجاد کند. مثلاً اگر difficulty برابر ۲ تنظیم شود، فقط hashهایی قابل قبول بوده و میتوانند حلقه را بشکنند که با «00» آغاز گردند. با هر سیکل از حلقهی while، مقدار nonce یکی اضافه شده و سپس hash جدیدی برای آن بلوک محاسبه میگردد. زمانی که nonce به عدد خاصّی برسد که hash حاصل از ترکیب آن با سایر محتویات بلوک (index ،previousHash ،timeStamp و داده) برابر عبارتی شده که با دو صفر شروع شود (حدس زدن این که آن عدد چه باشد ممکن نیست و باید به روش brute force پیدا شود – یعنی دقیقاً همین کاری که حلقهی while در این متد در حال انجامش است.
به عنوان مثالی برای درک بهتر از آنچه رخ میدهد، فرض کنید بلوکی به شرح زیر ساخته شود:
let sampleBlock = new Block(1, "10/07/2019", {amount: 4});
اگر difficulty برابر ۰ باشد، hash این بلوک برابر عبارت زیر خواهد بود:
6c21239cbaa5939fb2d1124a71268b71eb741762222d2841febd79b25c6a9e0e
امّا difficulty را برابر ۲ تنظیم میکنیم. در این صورت، nonce باید آنقدر افزایش یافته متد calculateHash آنقدر بر اساس مقدار افزایشیافتهی nonce مجدّداً محاسبه شود تا hashـی به دست آید که از قضا با «00» شروع میشود. در خصوص این مثال ما، اگر nonce برابر ۳۹۰ شود این اتّفاق میافتد و hash حاصل شده برابر میشود با:
00f369563e1cb0bf26e052b558e6094680532695f768cca715e2590507030737
لازم به ذکر است که عدد ۳۹۰ تنها عددی نیست که چنین hashـی (با دو صفر در ابتدا) حاصل میکند؛ امّا کوچکترین عدد مثبت است. اگر به حلقه، یک شرط با دستور continue اضافه کنیم که استثنائاً از ۳۹۰ گذر کند، عدد بعدی برای مثال خاصّ ما ۷۱۵ خواهد بود و hash بهدستآمده میشود:
006b06f9a9d0f57c2ca25ce1807b4a7ba8ab424505016d6e6984a9221366e974
یک نکتهی مفهومی دیگر این است که هیچ لزومی نداشت nonce را به عنوان متغیّری عددی در نظر گرفته و تغییر مقدارش نیز از طریق افزایش یک واحدی (++nonce) باشد. میتوانست عددی باشد که از طریق متد random تولید شده است. میتوانست اصلاً نه عدد بلکه یک string تصادفی باشد و هر دفعه بر اساس الگویی تغییر کند (مثلاً از "a" شروع شود تا "z"، سپس "aa" تا "zz" و...). تنها مسئلهی مهم این است که مقدارش ثابت نباشد تا بتوان آنقدر hashهای گوناگون را با آزمون و خطا امتحان کرد تا hashـی با شرایط خاصّ دادهشده (مثلاً تعداد معیّنی صفر در ابتدایش) پیدا شود. این که چگونه این آزمون و خطا انجام شود، نه به عهدهی بلاکچین بلکه به عهدهی خود miner هاست. امّا عملیات جمع، کمترین حافظه و توان پردازشی را در کامپیوترها مصرف میکند (در مقایسه با توان، تابع random و...)، منطقیترین کار همین است که nonce را عددی در نظر گرفته و از طریق افزایش یک واحدی دست به آزمون و خطا بزنیم. (مگر آن که روش ریاضی بهتری کشف شود (یا به بیان دیگر، hashing مورد نظر crack شود)... و یا این که یک پیشگوی دارای علم غیب باشید و بتوانید مقداری را برای nonce به طور شانسی حدس بزنید! دفعهی بعد که کسی ادّعای غیبگویی، فالگیری یا برخورداری «چشم بصیرت» کرد، فقط دادههای چشمبهراهِ یک بلوک جدید و مرتبهی difficulty رمزارز مورد نظر خود را به او داده و بخواهید از بین بینهایت عدد/عبارت ممکن، فقط یک nonce قابل قبول به شما بگوید! اگر توانست چنین کاری کند، شما ثروتمند میشوید. خودش نیز جایزهی رَندی را کسب کرده و جهانیان نیز به قدرتش ایمان خواهند آورد. البته حالت دیگر نیز این است که مدال فیلدز را به کسب کند و سپس به جرم شیّادی روانهی دادگاه شود!!)
حال نوبت استفاده از این متد در کلاس Blockchain است. میدانید که مشکل ما با متد addBlock بود که اجرای آن هیچ دشواریای ندارد؛ بلکه به راحتی بلوکهای جدید می سازد و به زنجیره میافزاید. پس آن را این گونه تغییر میدهیم:
addBlock(newBlock) { newBlock.previousHash = this.getLastestBlock().hash; //newBlock.hash = newBlock.calculateHash(); newBlock.mineBlock(this.difficulty); this.chain.push(newBlock); }
ورودی متد mineBlock برابر شد با «this.difficulty»؛ چرا که difficulty عملاً خصیصهای (property) مربوط به کلاس Blockchain است و در این کلاس باید تنظیم شود (رجوع کنید به مفاهیم برنامهنویسی شیگرا). از همین رو آن را به عنوان یک خصیصه در تابع سازندهی کلاس تعریف میکنیم:
constructor() { this.chain = [this.createGenesisBlock()]; this.difficulty = 2; // فعلاً برای نمونه در همینجا مقداردهی میشود // امّا در اصل باید ورودی متدِ سازنده باشد }
حال نوبت آزمایش است. با همان نمونههای قسمت نخست شروع میکنیم:
let pouyanCoin = new Blockchain(); console.log("Mining block 1..."); pouyanCoin.addBlock(new Block(1, "10/07/2019", {amount: 4})); console.log("Mining block 2..."); pouyanCoin.addBlock(new Block(2, "12/07/2019", {amount: 10}));
برای آزمایش میتوان در انتهای متد mineBlock، خطّ زیر را افزود تا بفهمیم چه زمانی mining کامل شده است:
console.log("Block mined: " + this.hash);
با مرتبهی دشواری ۲، خروجی از این قرار خواهد بود:
Mining block 1... Block mined: 008b08e380cca7df35808a413c4ba70df45aa68785fc68e75659dbf013841ff2 Mining block 2... Block mined: 0037368500fec367286fe6c3ce5888956f219c2f861df40535d0de19dc9ff087
در صورت اجرای این کد خواهید دید که عملیات mining بسیار سریع انجام میشود (شاید مگر این که کد را روی یک نسخهی کمقدرت از Raspberry Pi اجرا کنید که این درجه از سختی برایش سنگین باشد و کمی بیشتر طول بکشد). امّا اگر مرتبهی دشواری را به ۴ تغییر دهیم؟ خروجی زیر به دست میآید امّا زمان به دست آمدن آن چند برابر بیشتر میشود.
Mining block 1... Block mined: 00009743e3f1484342f2420e6acc8e871a185e45a1cfad35c1d369c64a05e81a Mining block 2... Block mined: 00008fcdc40aad79ebee5506fa983363fc477584fbd9f94cbfc5366fe9739dde
بگذارید کمی به شیوهی آکادمیک پیش برویم. کتابخانهی performance را به این شیوه به فایل خود بیفزایید:
const { performance, PerformanceObserver } = require('perf_hooks');
و سپس خطوطی را به متد mineBlock اضافه کنید:
mineBlock(difficulty) { var t0 = performance.now(); while(this.hash.substring(0, difficulty) !== Array(difficulty + 1).join("0")) { this.nonce++; this.hash = this.calculateHash(); } var t1 = performance.now(); console.log("Block mined: " + this.hash); console.log("Elapsed time: " + (t1 - t0) + " milliseconds.") console.log("Nonce:" + this.nonce); }
خروجی اجرای برنامه با مرتبهی دشواری ۲:
Mining block 1... Block mined: 008b08e380cca7df35808a413c4ba70df45aa68785fc68e75659dbf013841ff2 Elapsed time: 15.772261000238359 milliseconds. Nonce: 118 Mining block 2... Block mined: 0037368500fec367286fe6c3ce5888956f219c2f861df40535d0de19dc9ff087 Elapsed time: 27.986017999239266 milliseconds. Nonce: 931
خروجی اجرای برنامه با مرتبهی دشواری ۴:
Mining block 1... Block mined: 00009743e3f1484342f2420e6acc8e871a185e45a1cfad35c1d369c64a05e81a Elapsed time: 1380.534944000654 milliseconds. Nonce: 113453 Mining block 2... Block mined: 00008fcdc40aad79ebee5506fa983363fc477584fbd9f94cbfc5366fe9739dde Elapsed time: 239.76053899992257 milliseconds. Nonce: 19634
اگر ورودیهای بلوکهای ساختهشدهی شما نیز درست برابر با مثالِ زدهشده باشد و ضمناً hashing را نیز با همان الگوریتم SHA256 انجام داده باشید، hashها و nonce به دست آمده نیز باید مثل خروجیهای بالا باشد. امّا زمان سپری شده (elapsed time) برابر مقادیر بالا نبوده (به احتمال مایل به ۱۰۰٪) و تماماً به سیستم خودتان وابسته است. به هر حال چند نکته در خروجیهای بالا جالب توجّه است:
در همینجا فضای گستردهای جهت پژوهشهای آکادمیک نیز باز است. میتوانید الگوریتمهای مختلف را با درجهی سختیهای گوناگون و روی سختافزارهای متعدد آزمایش کرده و نتایج را مورد تحلیل آماری قرار دهید. مثلاً میتوانید با امکاناتی چون CUDA الگوریتمی بنویسید که بازههای مختلف nonce را بین هستههای یک GPU توزیع کرده تا عملیات mining از راه پردازش موازیْ تسریع شود.
هماکنون بلاکچین سادهی ما از ویژگی PoW نیز برخوردار است. امّا همچنان حتّی نزدیک به یک بلاکچین عملیاتی نیز نیست. یک ویژگی کلیدی دیگر که از نبود آن رنج میبرد، عدم وجود مبدأ و مقصد تراکنشهاست (به این ترتیب بابت mining نیز نمیتوان به کسی پاداشی داد). برای مثال در دو بلوکی که از رمزارز شخصی بنده، پویانکوین ?، ساختهایم، تنها دادهی موجود این است که مبالغ ۴?? و سپس ۱۰?? منتقل شده است؛ امّا از کجا و به کجا؟ چنین چیزیهایی تعریف نشدهاند! در بخش سوّم، این ویژگی نیز افزوده خواهد شد.