پویان
پویان
خواندن ۱۶ دقیقه·۵ سال پیش

بلاک‌چین شخصی خودتان با جاوااسکریپت (۳): تراکنش‌ها

در قسمت دوّم شرح داده شد که چگونه Proof-of-Work (به اختصار: PoW) را به بلاک‌چین شخصی خودتان اضافه کنید تا دشواری‌ای عامدانه جهت ساخت بلوک‌های جدید و افزودن آنان به زنجیره را ایجاد کرده و بخش مهمّی از امنیت بلاک‌چین خود را تأمین نمایید. امّا مشکل بعدی، عدم وجود سازوکاری برای تراکنش (transaction) است. در بلاک‌چینی که تا کنون ساخته‌ایم هیچ آدرس فرستنده و گیرنده‌ای وجود نداشته که پولی میان آنان تبادل گردد. همچنین لازم است برای ایجادکنندگان بلوک‌های جدید (درواقع minerها) نیز پاداشی در نظر گرفته شود تا تشویق به صرف توان‌های پردازشی برای استمرار حیات بلاک‌چین شما (از طریق ایجاد بلوک‌های جدید) شوند.

https://virgool.io/@pouyan_01001010/blockchain-js-gtrzmnqpvslt
https://virgool.io/@pouyan_01001010/blockchain-js-2-lwfnbtoyycbp

ابتدا با ایجاد تغییراتی در متد سازنده (constructor) کلاس Block شروع می‌کنیم. توضیح داده شد که هر بلوک محتوی داده است. از آن‌جا که بلاک‌چین ما در این‌جا قرار است نقش یک رمزارز (cryptocurrency) را داشته باشد، داده‌هایی که قرار است در خود داشته باشد نیز اطّلاعات تراکنش‌هاست. لذا مشخّصه‌ی data را به transactions تغییر نام می‌دهیم تا در ادامه، یک کلاس نیز برایش تعریف کنیم.
تغییر دیگر نیز حذف مشخّصه‌ی index است که به آن نیازی نداریم؛ چرا که ترتیب بلوک‌ها، به موقعیتشان در آرایه تعیین می‌گردد. لذا متد را به این شیوه اصلاح می‌کنیم:

constructor(timeStamp, transactions, previousHash = '') { this.timeStamp = timeStamp; this.transactions = transactions; this.previousHash = previousHash; this.hash = this.calculateHash(); this.nonce = 0; }

با توجّه به این تغییرات، فراموش نکنید که تنها بلوک از پیش تعریف‌شده‌ی ما، یعنی بلوک جنسیس (Genesis Block) در کلاس Blockchain، نیز باید اصلاح گردد و ورودی index از آن حذف شود:

createGenesisBlock() { return new Block(&quot01/01/2019&quot, &quotGenesis Block&quot, &quot0&quot); }

حال نوبت تعریف تراکنش است. این کار را با افزودن کلاس جدیدی به کد انجام می‌دهیم. یک تراکنش از سه مشخّصه تشکیل شده است:

  • آدرس فرستنده
  • آدرس گیرنده
  • مبلغ

پس تعریف به این شکل انجام می‌شود:

class Transaction { constructor(fromAddress, toAddress, amount) { this.fromAddress = fromAddress; this.toAddress = toAddress; this.amount = amount; } }

و امّا نوبت به معرّفی یک مفهوم جدید است: تراکنش‌های درحال انتظار (Pending Transactions). به طور خلاصه، جهت ذخیره‌ی اطّلاعات یک تراکنش جدید، لازم است بلوک جدیدی ایجاد شود. لذا تا زمان رخ دادن چنین چیزی، تمامی تراکنش‌های جدید باید به یک صف انتظار منتقل شود؛ یک آرایه که آن‌ها را موقّتاً نگهداری کند. نکته‌ی جانبی لازم به ذکر این است که در رمزارزهای واقعی، ساخت بلوک‌های جدید محدودیت زمانی نیز دارد. برای مثال، بلاک‌چینِ رمزارزِ بیت‌کوین اجازه نمی‌دهد که بیش از ۱۴۴ بلوک در هر روز (و یا به طور دقیق‌تر، بیش از یک بلوک در هر ۱۰ دقیقه) ساخته شود. لذا به طور معمول، کاربران لازم است ۱۰ دقیقه جهت تأیید تراکنش منتظر بمانند. همچنین شیوه‌ی گزینش تراکنش‌های موجود در صف نیز ممکن است لزوماً به ترتیب زمان ورود نباشد و لذا این زمان را کمی بیشتر (در موارد معدودی تا چند روز) کند. برای مثال می‌توانید در صفحه‌ی زیر، لیست انتظار تراکنش‌های بیت‌کوین را به صورت زنده مشاهده نمایید:

https://www.blockchain.com/btc/unconfirmed-transactions

به هر حال ما در بلاک‌چین ساده‌ی خود قصد پیاده‌سازی چنین پیچیدگی‌هایی را نداشته و هیچ سقف زمانی‌ای برای ایجاد بلوک‌های جدید تعیین نمی‌کنیم. صرفاً صفی را در قالب یک آرایه را در متد سازنده‌ی Blockchian تعریف می‌نماییم. همچین لازم است در همین‌جا، پاداشی برای minerها نیز در نظر بگیریم. لذا متد را بدین‌سان کامل می‌کنیم:

constructor() { this.chain = [this.createGenesisBlock()]; this.difficulty = 2; this.pendingTransactions = []; //صف انتظار this.miningReward = 100; //مبلغ پاداش جهت ایجاد بلوک جدید }

نکته‌ی مهمّ دیگر این است که در رمزارزهای واقعی، مبلغی که به minerها پاداش داده می‌شود ثابت نبوده و بسته به ترافیک شبکه، کم یا زیاد می‌گردد. هرچه minerهای کمتری مشغول فعّالیت باشند پاداش کلان‌تری جهت تشویق جذب بیشتر minerها داده شده و بلعکس. به عنوان مثال، همین اکنون که مشغول نگارش همین خط از این مطلب می‌باشم، بلوک زیر به زنجیره‌ی اصلی بیت‌کوین اضافه شده است:

https://www.blockchain.com/btc/block/00000000000000000014c26ff2a88ec88bf20eaf2c8aec75888e604b3b919c43
https://www.blockchain.com/btc/block/00000000000000000014c26ff2a88ec88bf20eaf2c8aec75888e604b3b919c43

می‌توانید مشاهده کنید که مبلغ ۱۲/۵฿ به miner این بلوک پاداش داده شده است. امّا ما مبلغ پاداش را ایستا و در این‌جا معادل ۱۰۰?? در نظر می‌گیریم که به صورت hard-code در متد سازنده‌ی کلاس یادشده وارد شد.

حال با توجّه به تغییرات بالا باید اقدام به تعریف متد جدیدی کنیم که بتواند به minerها پاداش دهد. لذا ابتدا اقدام به حذف متد addBlock از کلاس Blockchain کرده؛ سپس بجای آن، این متد را تعریف می‌کنیم:

minePendingTransactions(miningRewardAddress) { let block = new Block(Date.now(), this.pendingTransactions); //ایجاد بلوک جدید block.mineBlock(this.difficulty); //محاسبه‌ی هش (ماین) بلوک جدید console.log('Block successfully mined!'); this.chain.push(block); //افزودن بلوک جدید به بلاک‌چین this.pendingTransactions = [ new Transaction(null, miningRewardAddress, this.miningReward) ]; //بازنشانی صف انتظار و اعطای پاداش }

ورودی این متد، یعنی «miningRewardAddress»، آدرس کسی خواهد بود که بلوک جدید را mine می‌کند و پاداش به همین آدرس داده خواهد شد. سپس در بدنه‌ی این متد، یک شیء (object) جدید از کلاس block ایجاد شده که ورودی‌های کلاس سازنده‌ی آن، تاریخ فعلی (( )Date.now) و آرایه‌ای از تراکنش‌های فعلی درحال انتظار است. یک‌بار دیگر بخاطر بیاورید که متد سازنده‌ی کلاس Block، سه ورودی دریافت می‌کند:

  • مقدار timeStamp: زمان حال که با ( )Date.now داده می‌شود.
  • آرایه‌ی transactions: آرایه‌ی pendingTransactions از کلاس Blockchain که شامل تراکنش‌های منتظر است را دریافت می‌کند.
  • مقدار previousHash: به صورت پیش‌فرض برابر null در نظر گرفته شده و بعداً هنگام افزوده شدن به زنجیره، مقداردهی خواهد شد.

در خطّ بعدی، تابع mineBlock فراخوانی شده و درجه‌ی سختی بلاک‌چین نیز از همان کلاس Blockchian به آن داده می‌شود. با صرف زمان توان پردازشی لازم جهت اجرای این تابع، در نهایت hash بلوک جدید محاسبه گشته و پیغامی در این خصوص در کنسول چاپ می‌گردد. پس از آن نیز بلوک جدید با متد push به زنجیره اضافه می‌شود. به این ترتیب، تمامی تراکنش‌های منتظر در صف، در بلوک جدید و در زنجیره ذخیره می‌گردند. کاری که در نهایت انجام می‌شود نیز خالی کردن صف انتظار از تراکنش‌های قبلی و البته افزودن یک تراکنش جدید است: تراکنشی مربوط به دادنِ پاداش به ایجادکننده‌ی بلوک. باری دیگر به این خط بنگرید:

new Transaction(null, miningRewardAddress, this.miningReward) //تراکنش پاداش

همزمان با خالی گشتن صف از تراکنش‌های قبلی، این تراکنش به آن افزوده شده است که آدرس فرستنده ندارد (چرا که خودِ الگوریتم است که دارد پولی را خارج از سیستم و بدون پشتوانه تولید و اعطا می‌کند، نه یک شخصِ صاحب آدرسِ داخل سیستم)، آدرس گیرنده‌ی آن برابر آدرس ایجادکننده‌ی بلوک (که به عنوان ورودی به متد داده شد) است، و مبلغ آن برابر همان مبلغ پاداش که در کلاس سازنده در نظر گرفته شد.

یک تفاوتِ دیگر این بلاک‌چین ساده با بلاک‌چین‌های کاربردی این است که در این‌جا ما تمامی محتوای صف را یک‌جا به بلوک جدید اضافه کردیم، بدون توجّه به این که این صف ممکن هست حاوی هزاران یا میلیون‌ها تراکنش باشد! در بلاک‌چین‌های کاربردی، معمولاً سقفی در نظر گرفته شده و حجم داده‌های ذخیره شده در هر بلوک، نمی‌تواند از آن سقف بیشتر شود. این که مقدار این سقف چه باشد نیز ثابت نبوده و یکی از موارد اصلی اختلاف در میان بلاک‌چین‌های مختلف و یا حتّی forkهای مختلف یک بلاک‌چین است ‒ معمولاً مقداری میان چند کیلوبایت تا چندده مگابایت و به طور رایج هم دو یا چهار مگابایت. همین نیز می‌تواند موضوع یک تحقیق دانشگاهی و آکادمیک بسیار گسترده و کاربردی باشد که به راستی بهترین و بهینه‌ترین رویکرد برای تعیین حداکثر حجم هر بلوک در یک بلاک‌چین چیست. همچنین پاسخ به پرسش‌های دیگری از قبیل این که آیا اصلاً سقف تعیین‌شده باید مقداری ایستا باشد و در طول حیات بلاک‌چین و بسته به فاکتورهای مختلف (تعداد تراکنش‌ها، ترافیک شبکه و...) تغییر کند. در هر صورت فضای بزرگ و جدیدی برای تحقیق و پژوهش، پیش روی دانشجویان، اساتید و پژوهشگران علوم کامپیوتر، مهندسی نرم‌افزار، مهندسی شبکه، تحلیل داده و حتّی آمار و ریاضیات باز شده است و جا برای پیشرفت و نوآوری بسیار است.

از بحث اصلی منحرف نشویم! یک متد دیگر نیز نیاز داریم که تراکنش‌های جدید را گرفته و به صف انتظار بلاک‌چین اضافه کند. لذا آن را به این شکل به کلاس Blockchain می‌افزاییم:

createTransaction(trans) { this.pendingTransactions.push(trans); }

حال نوبت به متد دیگری جهت محاسبه‌ی موجودی هر آدرس است. نکته‌ی قابل توجّه این است که داده‌های موجود در یک بلاک‌چینِ پشتیبان یک رمزارز، در عمل و از دید رایانه، چیزی نیستند جز مجموعه‌هایی سه‌تایی تشکیل‌شده از دو آدرس (فرستنده و گیرنده) و یک عدد (مبلغ). لذا عملاً هیچ پولی از جایی به جای دیگری منتقل نشده و چیزی در حساب شما ذخیره نمی‌شود - درواقع شما حسابی ندارید؛ آدرس شما فقط نشانی از هویّت مجازی شما است. از همین رو، برای محاسبه‌ی موجودی یک حساب راهی نیست جز شمارش تک‌تک بلوک‌های موجود در کلّ بلاک‌چین نیست! تمامی بلوک‌ها باید پیمایش شوند و در هر کجا که آدرس شما به عنوان گیرنده یافت شد، مبلغ تراکنش به موجودی شما افزوده و هر جا به عنوان فرستنده یافت شد، مبلغ از موجودی شما کاسته شود. عدد حاصل شده پس از پیمایش تمام بلوک‌ها، همان موجودی شماست. پس:

getBalanceOfAddress(address) { let balance = 0; for (const block of this.chain) { //پیمایش تک‌تک بلوک‌های زنجیره for (const trans of block.transactions) { //بررسی تک‌تک تراکنش‌های هر بلوک if (trans.fromAddress === address) { //فرستنده balance -= trans.amount; //کاهش موجودی } if (trans.toAddress === address) { //گیرنده balance += trans.amount; //افزایش موجودی } } } return balance; //بازگردانی موجودی نهایی }

پس به طور کلّی، تمام کدی که تا کنون نوشته‌ایم از این قرار است:

const SHA256 = require('crypto-js/sha256') const { performance, PerformanceObserver } = require('perf_hooks'); class Transaction { constructor(fromAddress, toAddress, amount) { this.fromAddress = fromAddress; this.toAddress = toAddress; this.amount = amount; } } class Block { constructor(timeStamp, transactions, previousHash = '') { this.timeStamp = timeStamp; this.transactions = transactions; this.previousHash = previousHash; this.hash = this.calculateHash(); this.nonce = 0; } calculateHash() { return SHA256(this.index + this.previousHash + this.timeStamp + JSON.stringify(this.data) + this.nonce).toString(); } mineBlock(difficulty) { var t0 = performance.now(); while(this.hash.substring(0, difficulty) !== Array(difficulty + 1).join(&quot0&quot)) { this.nonce++; this.hash = this.calculateHash(); } var t1 = performance.now(); console.log(&quotBlock mined: &quot + this.hash); console.log(&quotElapsed time: &quot + (t1 - t0) + &quot milliseconds.&quot) console.log(&quotNonce: &quot + this.nonce); } } class Blockchain { constructor() { this.chain = [this.createGenesisBlock()]; this.difficulty = 2; this.pendingTransactions = []; this.miningReward = 100; } createGenesisBlock() { return new Block(&quot01/01/2019&quot, &quotGenesis Block&quot, &quot0&quot); } getLastestBlock() { return this.chain[this.chain.length - 1]; } minePendingTransactions(miningRewardAddress) { let block = new Block(Date.now(), this.pendingTransactions); block.mineBlock(this.difficulty); console.log('Block successfully mined!'); this.chain.push(block); this.pendingTransactions = [ new Transaction(null, miningRewardAddress, this.miningReward) ]; } createTransaction(trans) { this.pendingTransactions.push(trans); } getBalanceOfAddress(address) { let balance = 0; for (const block of this.chain) { for (const trans of block.transactions) { if (trans.fromAddress === address) { balance -= trans.amount; } if (trans.toAddress === address) { balance += trans.amount; } } } return balance; } 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; } }

و حال زمان تست فرا رسیده است. اگر در انتهای فایل شما، خطوطی مربوط به تست از قسمت پیشین باقی مانده است، تمامی آن‌ها را پاک کنید.

ابتدا رمزارز خود را با استفاده از ایجاد یک شیء جدید از Blockchain آغاز می‌نماییم و سپس دو تراکنش فرضی نیز اضافه می‌کنیم:

let pouyanCoin = new Blockchain(); pouyanCoin.createTransaction(new Transaction('address1', 'address2', 100)); pouyanCoin.createTransaction(new Transaction('address2', 'address3', 50));

واضح است که در تراکنش نخست، مبلغ ۱۰۰?? از حسابی با آدرس «address1» به حسابی با آدرس «address2» واریز گشته و سپس ۵۰?? نیز از آن به «address3» واریز می‌گردد. امّا از آن‌جایی که هنوز هیچ بلوکی جهت ذخیره‌سازی این تراکنش‌ها وجود ندارد، دو تراکنش بالا در صف انتظار قرار گرفته و منتظر می‌مانند تا یک miner دست به کار شود:

console.log(&quot\nStarting the Miner...&quot); pouyanCoin.minePendingTransactions('pouyan-address'); console.log(&quotPouyan's balance is&quot, pouyanCoin.getBalanceOfAddress('pouyan-address')); console.log(&quotA1's balance is&quot, pouyanCoin.getBalanceOfAddress('address1')); console.log(&quotA2's balance is&quot, pouyanCoin.getBalanceOfAddress('address2')); console.log(&quotA3's balance is&quot, pouyanCoin.getBalanceOfAddress('address3'));

با افزودن دو خطّ بالا، درواقع یک miner با آدرس «pouyan-address» فرایند ایجاد یک بلوک جدید و محاسبه‌ی hash آن را کلید می‌زند تا ضمن ذخیره‌ی دو تراکنش منتظر، ۱۰۰?? نیز پاداش دریافت کند. آن‌چه در چهار خطّ آخر انجام می‌شود نیز محاسبه و چاپّ موجودی تمام چهار کاربر است. با اجرای برنامه، خروجی آن مشابه چنین چیزی خواهد بود:

Starting the Miner... Block mined: 0063a8bb5872aac095bebb5fc62fae3bae24b70cf70febf1f3ccbe8d575b9b53 Elapsed time: 46.15125400014222 milliseconds. Nonce: 1292 Block successfully mined! Pouyan's balance is 0 A1's balance is -100 A2's balance is 50 A3's balance is 50

نکته: در نظر داشته باشید که حتّی اگر ورودی‌ها را درست عین من وارد کرده باشید، مقادیر hash، زمان سپری‌شده و nonce مثل من نخواهد شد. همچنین اگر کد را دوباره اجرا کنید نیز این سه مقدار مثل اجرای دفعه‌ی پیشین خودتان نخواهد شد. چرا که اگر بخاطر داشته باشید، یکی از ورودی‌های هر بلوک، timeStamp بوده که توسّط ( )Date.now، مطابق زمان فعلی سیستم شما تعیین خواهد شد. برای مثال، هنگام تهیه‌ی خروجی بالا، درست در لحظه‌ای که مفسّر جاوااسکریپت روی سیستم من به سراغ تابع ( )Date.now آمده، Time Stamp سیستم من (که از طریق اینترنتْ مطابق ساعت جهانی تنظیم شده) برابر 1571739771245 بوده و همین عدد در محاسبه‌ی hash بلوک بالا نیز تأثیر گذاشته است.

به موجودی‌ها دقّت کنید. هم‌اکنون و پس از دو تراکنش بالا، فرد A1 مبلغ (۱۰۰-)??، فرد A2 مبلغ ۵۰?? و A3 نیز مبلغ ۵۰?? به عنوان موجودی دارد. امّا نکته‌ی جالب توجّه، موجودی miner، یعنی Pouyan است: با وجود این که بلوکی توسّط این کاربر mine شده و مبلغ ۱۰۰?? نیز بابت این فرآیند پاداش داده شده است، موجودی این حساب همچنان صفر نمایش داده می‌شود! پس این ۱۰۰?? گم‌گشته کجاست؟! پیش از خواندن ادامه‌ی مطلب، سعی کنید لحظاتی به آن فکر کنید تا سنجشی باشد از این که تا کنون چقدر از مطلب را درک کرده‌اید...

پاسخ: آن‌گونه که توضیح داده شد، هر تراکنش جدید به صف انتظار می‌رود و در آن‌جا منتظر مانده تا یک بلوک جدید mine شده و بتواند در آن ذخیره شود. در ابتدا دو تراکنشی که میان آدرس‌های address1 و address2 و address3 صورت گرفته بود، منتظر ماندند تا بلوکِ «...0063a8bb5872a» توسّط Pouyan ایجاد شود تا ذخیره شوند. امّا خودِ فرآیند اعطای پاداش به Pouyan یک تراکنش جدید بوده که به صف انتظار منتقل می‌شود و باید در آن‌جا منتظر ایجاد یک بلوک جدید بماند. پس سه خطّ دیگر نیز مربوط به ساخت یک بلوک جدید، به انتهای تست اضافه می‌کنیم:

console.log(&quot\nStarting the Miner...&quot); pouyanCoin.minePendingTransactions('pouyan-address'); console.log(&quotPouyan's balance is&quot, pouyanCoin.getBalanceOfAddress('pouyan-address')); console.log(&quotaddress1's balance is&quot, pouyanCoin.getBalanceOfAddress('address1')); console.log(&quotaddress2's balance is&quot, pouyanCoin.getBalanceOfAddress('address2')); console.log(&quotaddress3's balance is&quot, pouyanCoin.getBalanceOfAddress('address3')); console.log(&quot\nStarting the Miner...&quot); pouyanCoin.minePendingTransactions('pouyan-address'); console.log(&quotPouyan's balance is&quot, pouyanCoin.getBalanceOfAddress('pouyan-address'));

حال اگر اجرا بگیریم، خروجی چنین می‌شود:

Starting the Miner... Block mined: 0009a7a4f0e9d825203be6b09a670a017c0ad4aaa557eb1ee0f14f741c224552 Elapsed time: 20.137283999472857 milliseconds. Nonce: 229 Block successfully mined! Pouyan's balance is 0 address1's balance is -100 address2's balance is 50 address3's balance is 50 Starting the Miner... Block mined: 000a684ab3574e8dc8ae57ac4491e4703633de04eddeace889c75db6b46a3cd0 Elapsed time: 5.021122001111507 milliseconds. Nonce: 341 Block successfully mined! Pouyan's balance is 100

بدین ترتیب با ایجاد شدن بلوک دوّم، آن تراکنشی که قرار بود مبلغ ۱۰۰?? به عنوان پاداش به موجودی pouyan-address اضافه کند، می‌تواند ذخیره شود. از همین‌جا می‌توان نتیجه گرفت که هر miner خود باید جهت دریافت پاداشش صبر کند تا بلوک دیگری نیز پس از او (توسّط خودش یا شخص دیگری) ایجاد شود.

تا این‌جا بلاک‌چین ما بدون خطا کار می‌کند، ولی همچنان اشکالات مفهومی بسیاری نیز متوجّهش بوده که دوتا از اساسی‌ترینِ آنان از این قرار است:

  • آدرس‌ها هنوز هیچ موجودی ندارند و چیزی جز stringهایی بی‌معنی و صوری نیستند؛ از همین رو، هرکس می‌تواند در بلاک‌چین ما با هر آدرسی دست به انجام هر تراکنشی بزند! شما می‌توانید تمام موجودی حساب من را به حساب خود انتقال دهید و البته من هم می‌توانم عکس چنین کاری را با شما کنم!
  • طبیعتاً جهت انتقال وجهی به یک حساب دیگر، باید ابتدا آن مبلغ را در حساب خودتان داشته باشید؛ درحالی که در بلاک‌چین ما هنوز هیچ سازوکاری جهت بررسی این که آیا اصلاً واریزکننده‌ی وجه، آن مقدار پول را در موجودی خود دارد یا نه، نداریم! همان‌طور که در تست‌ها مشاهده کردید، فرد صاحبِ آدرسی با شناسه‌ی address1 در ابتدا هیچ موجودی‌ای ندارد؛ ولی با این حال مبلغ ۱۰۰?? را به address2 واریز کرده و موجودی خود را برابر (۱۰۰-)?? می‌کند! بدیهیست که موجودی منفی در یک سیستم مالی معنا نداشته و باید به نحوی جلوی چنین تراکنشی گرفته شود. تا زمانی که حداقل ۱۰۰?? در حساب address1 موجود نباشد، این حساب نباید مجاز به ارسال چنین مبلغی به هیچ حساب دیگری باشد.

این موارد در قسمت چهارم پوشش داده خواهند شد.

blockchainbitcoinبلاک چینبیت کوینبرنامه نویسی
برنامه‌نویس، نِردی-گیک و شیفته‌ی دانش، فن‌آوری، اخترشناسی و فیزیک | https://P74.ir
شاید از این پست‌ها خوشتان بیاید