البلوكتشين والعملات الرقمية الأدلة التقنية

كيف يعمل Ethereum؟ الحسابات والغاز وآلة EVM

ماذا يحدث داخل Ethereum عندما ترسل معاملة، ولماذا تدفع الرسوم حتى لو فشلت، ولماذا تختلف تكلفة نفس الدالة من مرة لأخرى.

كيف يعمل Ethereum؟ الحسابات والغاز وآلة EVM

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

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

وسوف نناقش في هذا المقال ما يلي:

  • لماذا جاء Ethereum، وما معنى أنه آلة حالة State Machine وليس سجلاً للعملات فقط.
  • الحسابات Accounts: نوعاها وحقولها الأربعة، وكيف يخرج العنوان من المفتاح الخاص، وكيف يحسب عنوان العقد قبل نشره.
  • المعاملة Transaction: ماذا توقع بالضبط، والعداد nonce الذي يرتب المعاملات ويمنع إعادتها، وطريقها من المحفظة إلى الكتلة.
  • آلة EVM من الداخل: المكدس والذاكرة والتخزين، مع تتبع حقيقي لكل تعليمة في معاملة واحدة.
  • الغاز Gas: لماذا يدفع كل سطر ثمنه، ولماذا تختلف تكلفة نفس الدالة، مع أرقام حقيقية من التجربة.
  • الرسوم بعد EIP-1559: الرسوم الأساسية Base Fee والإكرامية Priority Fee، وتجربة ترفع فيها الرسوم وتنخفض أمامك.
  • ماذا يحدث عندما تفشل المعاملة، والحالة State ورأس الكتلة، والأخطاء الشائعة وقائمة تحقق للفريق.

لماذا جاء Ethereum؟ من سجل عملات إلى آلة حالة

في Bitcoin يتحقق كل عقد من شيء واحد تقريباً: هل صاحب هذه الوحدات وقع على صرفها، وهل لم يصرفها من قبل؟ ولغة Script في Bitcoin محدودة عن قصد، فلا توجد فيها حلقات تكرار Loops ولا حالة تبقى بين المعاملات، والسبب أن كل تعقيد إضافي يفتح باباً للأخطاء ولإبطاء الشبكة. وفي نهاية سنة 2013 نشر Vitalik Buterin ورقة Ethereum، وكانت فكرتها أن الشبكة نفسها تستطيع أن تحمل برامج كاملة بلغة عامة، يكتبها أي شخص وتنفذها كل العقد بنفس الطريقة، فبدل أن يبني كل مشروع بلوكتشين خاصاً به لكل تطبيق، تصبح هناك شبكة واحدة تعمل كحاسوب مشترك، وأطلقت الشبكة الأولى Frontier يوم 30 يوليو 2015 كما في السجل التقني لترقيات Ethereum.

والطريقة الأدق لوصف Ethereum أنه آلة حالة State Machine، وهذا ما يكتبه توثيق آلة EVM بمعادلة قصيرة: Y(S, T) = S'، أي أن دالة الانتقال Y تأخذ الحالة الحالية S ومجموعة معاملات صالحة T، وتخرج حالة جديدة S'. والحالة هنا هي كل الحسابات وأرصدتها وكود العقود ومتغيراتها في لحظة معينة، والكتلة هي مجموعة معاملات تنقل الشبكة من حالة إلى التي بعدها. والمهم في هذه المعادلة أنها حتمية Deterministic: كل عقدة في العالم تبدأ من نفس الحالة وتنفذ نفس المعاملات يجب أن تصل إلى نفس الحالة الجديدة بالبايت، وإلا اختلفت العقد على الكتلة. ولذلك المواصفة الرسمية مكتوبة بدقة شديدة في الورقة الصفراء Yellow Paper، وهناك اليوم مواصفة تنفيذية مكتوبة بلغة Python في مستودع execution-specs تستطيع أن تقرأها كما تقرأ أي كود.

وقد يتساءل البعض: ما الفرق العملي بين نموذج Bitcoin ونموذج Ethereum؟ والإجابة أن Bitcoin لا يعرف «رصيد الحساب» أصلاً، وإنما يعرف مخرجات غير منفقة Unspent Transaction Outputs واختصاراً UTXO، تشبه الأوراق النقدية في محفظتك، وكل معاملة تستهلك أوراقاً وتنشئ أوراقاً جديدة، وهذا موضوع مقال مستقل في مرحلة Bitcoin. أما Ethereum فيستخدم نموذج الحساب Account Model، وهو أقرب لما تعرفه من البنوك وقواعد البيانات: لكل عنوان سطر فيه رصيد وعداد، والمعاملة تعدل الأسطر مباشرة. وإذا كنت قد بنيت دفتر أستاذ Ledger لمحفظة رقمية كما شرحنا في مقال بناء المحفظة الرقمية فسوف تجد الفكرة مألوفة، والفرق أن كل عقدة تحمل نسخة كاملة من الجدول وتتحقق من كل تعديل بنفسها.

تجهيز التجربة: شبكة محلية مع Anvil

كل ما في هذا المقال سوف نراه بأعيننا على شبكة Ethereum محلية تعمل على جهازك، والأداة هي Anvil من مجموعة Foundry، وهي عقدة تطوير تنشئ عشرة حسابات اختبار وتنشئ كتلة فور وصول أي معاملة، كما يشرح توثيق Anvil، ومعها أداة cast التي تتكلم مع العقدة من سطر الأوامر كما في توثيق cast. وقد جربنا Anvil لأول مرة في مقال البلوكتشين للمبتدئين، والجديد هنا الخيار --steps-tracing الذي يجعل العقدة تحفظ كل خطوة نفذتها آلة EVM حتى نستطيع أن نطلب تتبعها لاحقاً. أنشئ مجلداً باسم lab، وضع فيه السكربتات التي سوف تأتي، ثم شغل العقدة وال Container يرى المجلد على المسار /lab:

docker run -d --name lab-eth -p 127.0.0.1:8545:8545 -v "$PWD:/lab" -w /lab \
  ghcr.io/foundry-rs/foundry:v1.8.6 "anvil --host 0.0.0.0 --steps-tracing"
docker exec lab-eth anvil --version | head -1

والمخرج سوف يكون كما يلي:

anvil Version: 1.8.6

في الإعداد أعلاه لاحظ التالي:

  • المنفذ منشور على 127.0.0.1 فقط، والسبب أن واجهة JSON-RPC في Anvil تقبل أي طلب دون مصادقة، ومفاتيح حساباتها معروفة للجميع.
  • كل السكربتات التالية نشغلها داخل نفس ال Container بالأمر docker exec lab-eth bash /lab/اسم-الملف.sh، فلا تحتاج تثبيت أي شيء على جهازك.
  • إذا كنت على Windows مع Git Bash فاكتب export MSYS_NO_PATHCONV=1 قبل الأوامر، وإلا حول Git Bash المسار /lab/… إلى مسار Windows ولن يجده ال Container.

الحسابات Accounts: نوعان وأربعة حقول

أول سؤال سأله فريق المنصة: عندما ينشئ العميل محفظة، أين يعيش حسابه؟ والإجابة أن الحساب ليس ملفاً عند العميل ولا سطراً عند المنصة، وإنما سطر في حالة الشبكة نفسها، وكما يقول توثيق الحسابات فإن الحساب ليس هو المحفظة، فالمحفظة Wallet هي البرنامج الذي يحمل المفتاح ويوقع به، أما الحساب فهو ما تحفظه الشبكة. وهناك نوعان من الحسابات:

  • الحساب الخارجي Externally Owned Account واختصاراً EOA: يتحكم فيه من يملك المفتاح الخاص Private Key، وهو وحده الذي يستطيع أن يبدأ معاملة، فكل نشاط على الشبكة يبدأ من EOA مهما مر بعد ذلك بعقود.
  • حساب العقد Contract Account: عقد ذكي منشور على الشبكة، ليس له مفتاح خاص، ولا يتحرك إلا عندما تصله معاملة أو رسالة من عقد آخر، فيشغل كوده ويعدل متغيراته.

ولكل حساب من النوعين أربعة حقول بالضبط: العداد nonce، والرصيد balance بأصغر وحدة وهي Wei، وهاش الكود codeHash، وجذر شجرة التخزين storageRoot. والسكربت التالي يأخذ الحساب الأول من حسابات Anvil ويشتق عنوانه بنفسه خطوة خطوة، ثم يطلب حقوله الأربعة من العقدة، واحفظه باسم 01-accounts.sh:

set -e
PK0=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
A0=0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
echo "== address from private key"
cast wallet address --private-key $PK0
echo "== public key (64 bytes, x and y)"
PUB=$(cast wallet public-key --private-key $PK0 2>/dev/null || cast wallet pubkey --private-key $PK0)
echo $PUB
echo "== keccak256(public key), last 20 bytes"
H=$(cast keccak $PUB); echo $H
echo 0x${H: -40}
cast to-check-sum-address 0x${H: -40}
echo "== account fields via eth_getProof"
cast proof $A0 | grep -E '"(balance|codeHash|nonce|storageHash)"'
echo "== nonce, code"
cast nonce $A0; cast code $A0
echo "== next contract address for nonce 0 and 1"
cast compute-address $A0 --nonce 0
cast compute-address $A0 --nonce 1

ثم شغله:

docker exec lab-eth bash /lab/01-accounts.sh

والمخرج سوف يكون كما يلي:

== address from private key
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
== public key (64 bytes, x and y)
0x8318535b54105d4a7aae60c08fc45f9687181b4fdfc625bd1a753fa7397fed753547f11ca8696646f2f3acb08e31016afac23e630c5d11f59f61fef57b0d2aa5
== keccak256(public key), last 20 bytes
0xc1ffd3cfee2d9e5cd67643f8f39fd6e51aad88f6f4ce6ab8827279cfffb92266
0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266
0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
== account fields via eth_getProof
  "balance": "0x21e19e0c9bab2400000",
  "codeHash": "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470",
  "nonce": "0x0",
  "storageHash": "0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421",
== nonce, code
0
0x
== next contract address for nonce 0 and 1
0x5FbDB2315678afecb367f032d93F642f64180aa3
0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512

في المخرج أعلاه لاحظ التالي:

  • المفتاح العام 64 بايت، وهما الإحداثيان x وy لنقطة على المنحنى secp256k1، والعنوان هو آخر 20 بايت من هاش Keccak-256 لهذا المفتاح، فقارن آخر 40 حرفاً في سطر الهاش بالعنوان تجدهما متطابقين.
  • السطر الأخير في القسم الثالث هو نفس العنوان ولكن ببعض الحروف الكبيرة، وهذا ليس زينة، وإنما مجموع تحقق Checksum موصوف في EIP-55: حالة كل حرف تحددها بتات من هاش العنوان نفسه، فإذا أخطأت في حرف واحد عند النسخ تكتشف المحفظة الخطأ. وهي نفس فكرة مجموع التحقق في ترميز Base58Check الذي شرحناه في مقال الترميز، ولكن بطريقة أخف.
  • الرصيد 0x21e19e0c9bab2400000 هو 10000 ETH مكتوبة بوحدة Wei، أي 10000 × 10 أس 18.
  • codeHash هو هاش الكود الفارغ، لأن هذا الحساب EOA ليس له كود، وcast code يعيد 0x فقط. وstorageHash هو جذر شجرة فارغة، وسوف ترى نفس القيمة 0x56e81f17… في أماكن أخرى من المقال كلما كانت هناك شجرة لا تحتوي شيئاً.
  • الأمر cast proof يستدعي الطريقة eth_getProof، وهي لا تعيد الحقول فقط، وإنما تعيد معها الهاشات التي تثبت أن هذا الحساب موجود فعلاً في حالة الكتلة، وسوف نعود إليها في قسم الحالة.

والصورة التالية تجمع الطريق من المفتاح إلى العنوان، ونوعي الحساب بحقولهما، بنفس القيم التي ظهرت في المخرج:

مخطط من جزأين: في الأعلى سلسلة من أربعة صناديق من اليمين إلى اليسار، المفتاح الخاص باللون الأحمر 0xac0974، ثم المفتاح العام بطول 64 بايت عبر secp256k1، ثم هاش Keccak-256 بطول 32 بايت وآخر 20 بايت منه ملونة، ثم العنوان 0xf39Fd6e5 مع حروف EIP-55، وفي الأسفل صندوقان: الحساب الخارجي EOA بالأزرق مع nonce 0 والرصيد وهاش كود فارغ وجذر تخزين فارغ، وحساب العقد Store بالبنفسجي مع nonce 1 وهاش الكود 0x0ee397f5 وجذر تخزينه، وتحتهما ملاحظة أن عنوان العقد يأتي من عنوان منشئه وnonce
العنوان آخر 20 بايت من هاش المفتاح العام، والحسابان لهما نفس الحقول الأربعة، ولكن الكود والتخزين لا يوجدان إلا في العقد

عنوان العقد يعرف قبل أن يوجد

آخر سطرين في المخرج لا علاقة لهما بأي مفتاح: عنوان العقد في الطريقة العادية CREATE يحسب من عنوان من ينشره ومن قيمة nonce عنده وقت النشر، كما في توثيق الحسابات، ولذلك يستطيع أي شخص أن يعرف أن أول عقد ينشره الحساب 0 سوف يكون على العنوان 0x5FbDB231…، وأن الثاني سوف يكون 0xe7f1725E…، وسوف ترى هذا العنوان الأول بالضبط بعد قليل عندما ننشر العقد. وهناك طريقة ثانية اسمها CREATE2 موصوفة في EIP-1014، تحسب العنوان من عنوان المنشئ وقيمة اختيارية Salt وهاش كود الإنشاء، فلا علاقة لها بالعداد، وهذه هي الطريقة التي تستخدمها المنصات التي تريد أن تعطي العميل عنواناً ثابتاً قبل أن تنشر له عقده.

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

📌
منذ ترقية Pectra يوم 7 مايو 2025 لم يعد الفرق بين النوعين حاداً كما كان، والسبب EIP-7702 الذي يسمح للحساب الخارجي EOA أن يوقع تفويضاً يجعل كود عقد معين يعمل باسمه، فيستطيع مثلاً أن يجمع عدة عمليات في معاملة واحدة أو أن يدفع رسومه طرف آخر. وفكرة هذا المقال لا تتغير: المفتاح الخاص ما زال هو من يملك الحساب، والتفويض نفسه معاملة يوقعها، لذلك التوقيع على تفويض لا تفهمه خطر مثل التوقيع على أي معاملة لا تفهمها.

الوحدات Wei وgwei: لماذا لا توجد كسور عشرية؟

لاحظت في المخرج أن الرصيد رقم صحيح ضخم وليس 10000.0، والسبب أن آلة EVM لا تعرف الأرقام العشرية Floating Point إطلاقاً، وكل المبالغ أعداد صحيحة بأصغر وحدة وهي Wei، و1 ETH يساوي 10 أس 18 Wei. والوحدة الوسطى التي تسمعها في الحديث عن الرسوم هي gwei، أي مليار Wei، وكما يقول توثيق الغاز فإن gwei الواحدة تساوي 10 أس سالب 9 من ETH. وهذا اختيار مقصود: الأرقام العشرية تعطي نتائج مختلفة قليلاً بحسب المعالج وترتيب العمليات، كما شرحنا في مقال الأرقام العشرية، وآلة يجب أن تصل فيها آلاف العقد إلى نفس الحالة بالبايت لا تتحمل هذا الاختلاف.

والقاعدة العملية لفريق المنصة: خزن المبالغ في قاعدة بياناتك أعداداً صحيحة بأصغر وحدة، أو بنوع Decimal دقيق، ولا تحولها إلى float في أي مكان في الكود، لا في الواجهة ولا في التقارير. ومن أشهر الأخطاء أن يقرأ تطبيق JavaScript رصيداً بوحدة Wei في متغير من نوع Number، فيفقد الدقة بعد 2 أس 53 دون أي رسالة خطأ، والحل أن تستخدم BigInt أو مكتبة أرقام كبيرة.

المعاملة Transaction: ماذا توقع بالضبط؟

المعاملة كما يعرفها توثيق المعاملات تعليمة موقعة من حساب خارجي، وهي إما تحويل قيمة، أو استدعاء عقد، أو إنشاء عقد جديد. والسؤال الذي يجب أن يسأله كل مطور: ما الذي يدخل تحت التوقيع بالضبط؟ لأن ما لا يدخل تحت التوقيع يستطيع أي وسيط أن يغيره. والسكربت التالي جزء من ملف التجارب الرابع، وفيه نبني معاملة موقعة دون أن نرسلها بالأمر cast mktx، ثم نفك حقولها:

RAW=$(cast mktx --private-key $PK1 $A2 --value 1ether)
echo "raw: ${RAW:0:42}... ($(( (${#RAW}-2)/2 )) bytes)"
cast decode-transaction $RAW | grep -E "\"(type|chainId|nonce|gas|maxFeePerGas|maxPriorityFeePerGas|to|value|r|s|yParity|hash)\""

والمخرج سوف يكون كما يلي:

raw: 0x02f870827a690401841834a635825208943c44cd... (115 bytes)
  "type": "0x2",
  "chainId": "0x7a69",
  "nonce": "0x4",
  "gas": "0x5208",
  "maxFeePerGas": "0x1834a635",
  "maxPriorityFeePerGas": "0x1",
  "to": "0x3c44cdddb6a900fa2b585dd299e03d12fa4293bc",
  "value": "0xde0b6b3a7640000",
  "r": "0xbfa5cb1cbb422c321bd3acdc70aec49c56079381fe91dea420ce416aaccc3df3",
  "s": "0x569380355d155e68f487550a1e958f6f6a765d13234b36011d39c73cc4408cd",
  "yParity": "0x0",
  "hash": "0x66e393b72568c4ab416ee4614013e2aca88f62bd3ba414c815697832afdbbcc3"

في المخرج أعلاه لاحظ التالي:

  • المعاملة كلها 115 بايت فقط، وأول بايت فيها 0x02 هو نوعها، وبعده قائمة الحقول بترميز RLP. ونظام الأنواع نفسه موصوف في EIP-2718، والنوع 2 هو معاملة الرسوم الحديثة EIP-1559 التي سوف نشرحها بعد قليل، والنوع 3 لمعاملات البيانات الكبيرة Blobs التي جاءت مع EIP-4844، والنوع 4 لتفويض EIP-7702.
  • chainId يساوي 0x7a69، أي 31337، وهو رقم شبكة Anvil. وهذا الحقل تحت التوقيع منذ EIP-155، والسبب أن المعاملة الموقعة لشبكة اختبار كانت قبله صالحة على الشبكة الرئيسية أيضاً، فيستطيع أي شخص أن ينقلها ويعيد إرسالها هناك، وهذا ما يسمى هجوم الإعادة Replay Attack بين الشبكات.
  • gas يساوي 0x5208، أي 21000، وهو الحد الأقصى للغاز الذي تقبل أن تستهلكه هذه المعاملة، وmaxFeePerGas وmaxPriorityFeePerGas هما أقصى ما تقبل دفعه لكل وحدة غاز.
  • value يساوي 0xde0b6b3a7640000، أي 10 أس 18 Wei، أي 1 ETH بالضبط.
  • r وs وyParity هي توقيع ECDSA، ولا يوجد حقل اسمه from إطلاقاً، فالعقدة تستخرج عنوان المرسل من التوقيع نفسه، وهذا معنى أن التوقيع هو الهوية.

والخلاصة أن كل ما يحدد معنى المعاملة موجود تحت التوقيع: الشبكة، والعداد، وحد الغاز، والرسوم القصوى، والمستقبل، والمبلغ، وبيانات الاستدعاء. لذلك لا يستطيع الوسيط الذي ينقل معاملتك، سواءً كان مزود RPC أو عقدة في الطريق، أن يغير المبلغ أو المستقبل، وكل ما يستطيعه أن يؤخر المعاملة أو لا يرسلها.

العداد nonce: الترتيب ومنع الإعادة

الملاحظة الثالثة كانت المعاملة التي بقيت معلقة، وسببها هذا الحقل الصغير. فالعداد nonce في الحساب الخارجي هو عدد المعاملات التي أرسلها، وكل معاملة جديدة يجب أن تحمل الرقم التالي بالضبط، فإذا كانت آخر معاملة دخلت الشبكة رقمها 1 فالتالية يجب أن تكون 2، لا 3 ولا 1. وهذه القاعدة البسيطة تحل مشكلتين معاً: تمنع إعادة إرسال معاملة قديمة لأن رقمها استخدم، وتجعل معاملات الحساب الواحد تنفذ بالترتيب الذي أراده صاحبها. والسكربت 04-failures.sh يجرب الحالتين، وهذا الجزء الخاص بهما:

echo "== 4. nonce gap"
N=$(cast nonce $A1); echo "current nonce of A1: $N"
cast send --async --nonce $((N+1)) --private-key $PK1 $A2 --value 1ether > /dev/null
echo "txpool: $(cast rpc txpool_status)"; echo "block: $(cast block-number)"
cast send --async --nonce $N --private-key $PK1 $A2 --value 1ether > /dev/null
sleep 1
echo "txpool: $(cast rpc txpool_status)"; echo "block: $(cast block-number)"; echo "nonce now: $(cast nonce $A1)"
echo "== 5. replay: send the same signed transaction twice"
RAW=$(cast mktx --private-key $PK1 $A2 --value 1ether)
echo "first:  $(cast publish $RAW | grep -o '"status": "[^"]*"')"
echo "second: $(cast publish $RAW 2>&1 | tail -1)"

والمخرج سوف يكون كما يلي، وقد حذفنا منه سطور فك المعاملة التي رأيناها في القسم السابق:

== 4. nonce gap
current nonce of A1: 2
txpool: {"pending":"0x0","queued":"0x1"}
block: 12
txpool: {"pending":"0x0","queued":"0x0"}
block: 13
nonce now: 4
== 5. replay: send the same signed transaction twice
first:  "status": "0x1"
second: Error: server returned an error response: error code -32003: nonce too low

في المخرج أعلاه لاحظ التالي:

  • عداد الحساب كان 2، فأرسلنا عن قصد المعاملة رقم 3 أولاً، فقبلتها العقدة ولكنها وضعتها في قسم المعلقة queued وليس في قسم الجاهزة pending، ولم تنشئ أي كتلة، لأن المعاملة رقم 2 لم تصل بعد. وهذا بالضبط ما يحدث عندما تحسب المحفظة العداد من معاملة سابقة فشل إرسالها، فتترك فجوة.
  • عندما أرسلنا المعاملة رقم 2 أصبحت المعاملتان جاهزتين ودخلتا الكتلة 13 معاً، وأصبح العداد 4. والدرس أن المعاملة المعلقة لا تلغى ولا تحذف نفسها، وإنما تنتظر سد الفجوة.
  • في تجربة الإعادة نجحت المعاملة الموقعة في المرة الأولى، وعندما أرسلنا نفس البايتات مرة ثانية رفضتها العقدة بالرسالة nonce too low، فلا يستطيع أحد أن يكرر دفعة رآها على الشبكة.

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

طريق المعاملة من المحفظة إلى الكتلة

الآن نجمع ما سبق في طريق واحد: المحفظة تبني المعاملة وتوقعها على جهاز صاحبها، ثم ترسلها إلى عقدة عبر واجهة JSON-RPC، فتتحقق العقدة من التوقيع والعداد وأن الرصيد يغطي أقصى ما قد تدفعه المعاملة، أي حد الغاز ضرب الرسوم القصوى مضافاً إليه المبلغ، ثم تضعها في طابور المعاملات Mempool وتنشرها لبقية العقد. وفي كل خانة زمنية Slot مدتها 12 ثانية يختار البروتوكول مدققاً Validator واحداً يقترح الكتلة، وقد شرحنا اختياره في مقال الإجماع، فيأخذ من الطابور المعاملات التي تدفع له أكثر حتى يصل إلى حد الغاز في الكتلة، ثم تنفذها آلة EVM واحدة بعد الأخرى، وتخرج لكل معاملة إيصالاً Receipt وحالة جديدة. والصورة التالية تبين الطريق، وفي أسفلها الأماكن التي رأينا فيها المعاملة ترفض أو تفشل في التجربة:

مخطط بخمس خطوات من اليمين إلى اليسار: المحفظة تبني المعاملة وتوقعها وفيها type 2 وchainId 31337 وnonce 4 وgas 21000 وmaxFee وtip وvalue والتوقيع، ثم العقدة تتحقق من التوقيع والعداد والرصيد، ثم الانتظار في Mempool بقسمين جاهزة pending ومعلقة queued مع مثال الفجوة في العداد، ثم المقترح Proposer كل 12 ثانية يرتب المعاملات بحسب الإكرامية، ثم آلة EVM تنفذ وتخرج حالة جديدة وإيصالاً، وفي الأسفل ثلاثة صناديق: ترفض قبل الكتلة ولا تدفع شيئاً مثل nonce too low ونقص الرصيد، وتدخل الكتلة وتفشل وتدفع الرسوم مثل Revert ونفاد الغاز، وتنجح وتدفع الغاز ضرب الرسوم الأساسية والإكرامية
الرفض قبل الكتلة لا يكلف شيئاً، أما الفشل داخلها فيكلف الغاز الذي استهلك

آلة EVM من الداخل

آلة Ethereum الافتراضية Ethereum Virtual Machine واختصاراً EVM هي البرنامج الذي ينفذ كود العقود داخل كل عقدة، وكما يصف توثيقها فهي آلة مكدس Stack Machine بعمق أقصاه 1024 عنصراً، وكل عنصر كلمة Word بطول 256 بت، وهذا الطول اختير لأنه يناسب هاش Keccak-256 ومفاتيح secp256k1 التي تتعامل معها الآلة طوال الوقت. وآلة المكدس تعني أنه لا توجد مسجلات Registers بأسماء كما في المعالج الذي تعرفه، وإنما كل تعليمة تأخذ مدخلاتها من أعلى المكدس وتضع نتيجتها عليه، فالتعليمة ADD تأخذ رقمين وتضع مجموعهما. وإذا كنت قد قرأت كيف ينفذ المعالج التعليمات في مقال المعالج من الداخل فالفكرة نفسها، ولكن على آلة افتراضية صغيرة يجب أن تتصرف بنفس الطريقة على كل جهاز في العالم.

ولكي نرى ذلك بأعيننا نحتاج عقداً صغيراً، والعقد التالي سوف نستخدمه في بقية المقال، وفيه متغير واحد ودالة تكتب فيه وتطلق حدثاً Event، ودالة ترفض الصفر، ودالة فيها حلقة لنقيس تكلفة الحساب. احفظه في المسار lab/store/src/Store.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;

contract Store {
    uint256 public value;

    event ValueSet(address indexed who, uint256 newValue);

    function set(uint256 v) external {
        value = v;
        emit ValueSet(msg.sender, v);
    }

    function setPositive(uint256 v) external {
        require(v > 0, "value must be positive");
        value = v;
    }

    function loop(uint256 n) external {
        uint256 x;
        for (uint256 i = 0; i < n; i++) {
            x += i;
        }
        value = x;
    }
}

ومعه ملف إعداد Foundry في lab/store/foundry.toml، وفيه نثبت إصدار المترجم Compiler حتى يعطي نفس الكود الثنائي في كل مرة:

[profile.default]
src = "src"
out = "out"
solc = "0.8.36"

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

docker exec lab-eth bash -c 'cast disassemble $(cast code 0x5FbDB2315678afecb367f032d93F642f64180aa3) | head -12'

والمخرج سوف يكون كما يلي:

00000000: PUSH1 0x80
00000002: PUSH1 0x40
00000004: MSTORE
00000005: CALLVALUE
00000006: DUP1
00000007: ISZERO
00000008: PUSH2 0x000f
0000000b: JUMPI
0000000c: PUSH0
0000000d: PUSH0
0000000e: REVERT
0000000f: JUMPDEST

هذه أول 12 تعليمة في كل استدعاء للعقد: التعليمات الثلاث الأولى تجهز مؤشر الذاكرة الحرة، ثم CALLVALUE يضع على المكدس كمية ETH المرسلة مع الاستدعاء، وISZERO يتحقق أنها صفر، فإذا لم تكن صفراً لا تتحقق القفزة JUMPI ويصل التنفيذ إلى REVERT. ولماذا؟ لأن دوال العقد ليست payable، فأضاف المترجم بنفسه حماية ترفض أي استدعاء يرسل معه مالاً، وهذا مثال على أن الكود الثنائي يحمل قواعد لم تكتبها بيدك.

أين تعيش البيانات أثناء التنفيذ؟

للآلة عدة أماكن تحفظ فيها البيانات، والفرق بينها هو مدة بقائها، ومن مدة البقاء تأتي التكلفة:

  • الكود Bytecode: ثابت بعد النشر، وفي حالة العقد Store طوله 819 بايت كما سوف نرى في مخرج التجربة.
  • المكدس Stack: يعيش أثناء الاستدعاء فقط، ورخيص جداً، وأغلب التعليمات تكلف 3 وحدات غاز.
  • الذاكرة Memory: مصفوفة بايتات مؤقتة للاستدعاء الواحد، وتكلفتها تكبر كلما توسعت، وتختفي بعد انتهائه.
  • بيانات الاستدعاء Calldata: مدخلات المعاملة، للقراءة فقط، وهي التي يدفع المرسل ثمن كل بايت فيها.
  • التخزين المؤقت Transient Storage: جاء مع EIP-1153 في ترقية Dencun، ويبقى طوال المعاملة الواحدة بين كل الاستدعاءات ثم يمسح دون أن يدخل الحالة، ويستخدم مثلاً لقفل يمنع إعادة الدخول إلى دالة أثناء تنفيذها.
  • التخزين الدائم Storage: خانات Slots من 32 بايت لكل عقد، وهي جزء من حالة الشبكة، تبقى إلى الأبد وتحفظها كل عقدة كاملة، ولذلك هي أغلى مكان في الآلة كما سوف نرى بالأرقام.

تتبع حقيقي: كل خطوة في استدعاء واحد

الآن نطلب من Anvil تتبع معاملة set(8) التي سوف ننفذها في قسم الغاز، لذلك شغل الأمرين التاليين بعد تجربة ذلك القسم، والطريقة debug_traceTransaction تعيد كل خطوة نفذتها الآلة مع الغاز المتبقي قبلها وتكلفتها وأعلى عناصر المكدس، وهي تعمل هنا لأننا شغلنا Anvil بالخيار --steps-tracing:

docker exec lab-eth bash -c 'cast rpc debug_traceTransaction 0xe274574e47c4c9b7c4e65573c28f638c0e8d1fedac2ad45df50a2eb653fdcb6d "{\"enableMemory\":false}" > /lab/trace-set8.json'
docker run --rm -v "$PWD:/lab" -w /lab python:3.13-slim python trace.py trace-set8.json

والملف trace.py يقرأ التتبع ويعرض أول الخطوات، وخطوات القراءة والكتابة والأحداث، ومجموع الغاز لكل تعليمة:

import json, sys, collections
t = json.load(open(sys.argv[1]))
logs = t["structLogs"]
print("gas:", t["gas"], "failed:", t["failed"], "steps:", len(logs))
for s in logs[:8]:
    print(f'{s["pc"]:>4} {s["op"]:<10} gas={s["gas"]:<6} cost={s["gasCost"]:<4} stack={s.get("stack", [])[-3:]}')
print("...")
for s in logs:
    if s["op"] in ("SLOAD", "SSTORE", "LOG2", "CALLDATALOAD"):
        print(f'{s["pc"]:>4} {s["op"]:<10} gas={s["gas"]:<6} cost={s["gasCost"]:<5} stack={s.get("stack", [])[-2:]}')
c = collections.Counter(s["op"] for s in logs)
tot = collections.Counter()
for s in logs: tot[s["op"]] += s["gasCost"]
print("top by gas:", tot.most_common(5))
print("execution gas (sum of costs):", sum(tot.values()))

والمخرج سوف يكون كما يلي:

gas: 28211 failed: False steps: 188
   0 PUSH1      gas=7007   cost=3    stack=[]
   2 PUSH1      gas=7004   cost=3    stack=['0x80']
   4 MSTORE     gas=7001   cost=12   stack=['0x80', '0x40']
   5 CALLVALUE  gas=6989   cost=2    stack=[]
   6 DUP1       gas=6987   cost=3    stack=['0x0']
   7 ISZERO     gas=6984   cost=3    stack=['0x0', '0x0']
   8 PUSH2      gas=6981   cost=3    stack=['0x0', '0x1']
  11 JUMPI      gas=6978   cost=10   stack=['0x0', '0x1', '0xf']
...
  26 CALLDATALOAD gas=6942   cost=3     stack=['0x0']
 449 CALLDATALOAD gas=6754   cost=3     stack=['0x0', '0x4']
 254 SSTORE     gas=6584   cost=5000  stack=['0x8', '0x0']
 333 LOG2       gas=1392   cost=1381  stack=['0x20', '0x80']
top by gas: [('SSTORE', 5000), ('LOG2', 1381), ('JUMP', 128), ('JUMPI', 70), ('PUSH2', 69)]
execution gas (sum of costs): 7007

في المخرج أعلاه لاحظ التالي:

  • نفذت الآلة 188 خطوة لاستدعاء دالة من سطرين، وأول ثماني خطوات هي نفس التعليمات التي رأيناها في cast disassemble، وهذه المرة مع المكدس: بعد CALLVALUE أصبح على المكدس 0x0 لأننا لم نرسل مالاً، فنجحت القفزة إلى 0xf.
  • أول CALLDATALOAD من الموضع 0 يقرأ أول 32 بايت من بيانات الاستدعاء ليستخرج منها محدد الدالة Function Selector، وهو أول 4 بايتات من هاش اسم الدالة set(uint256)، وقيمته 0x60fe47b1. والثاني من الموضع 4 يقرأ الوسيط نفسه، أي الرقم 8.
  • SSTORE وجد على المكدس 0x8 و0x0، أي «اكتب 8 في الخانة 0»، وهي الخانة التي يعيش فيها المتغير value لأنه أول متغير في العقد. وهذه التعليمة وحدها كلفت 5000 وحدة غاز من أصل 7007 صرفت في التنفيذ كله.
  • LOG2 هو إطلاق الحدث ValueSet، والرقم 2 يعني أن له موضوعين Topics، وكلف 1381. وكل الحساب والقفزات والمقارنات في 186 خطوة أخرى كلفت معاً 626 وحدة فقط.
  • الغاز المتاح في أول خطوة 7007، وهو كل ما بقي من حد المعاملة بعد الكلفة الثابتة، لأن cast send قدر الحد بالضبط، وسوف نرى أن هذا التقدير الدقيق له جانب خطر.

والصورة التالية تجمع أجزاء الآلة مع هذا التتبع:

مخطط من جزأين: على اليمين أجزاء آلة EVM من الأقصر عمراً إلى الأطول، الكود بحجم 819 بايت للعقد Store، والمكدس بعمق 1024 عنصراً كل منها 256 بت وعليه 0x8 و0x0 كما وجدتهما SSTORE، والذاكرة المؤقتة، وبيانات الاستدعاء 0x60fe47b1 ثم الرقم 8، والتخزين المؤقت Transient Storage، والتخزين الدائم Storage الذي تكلف الكتابة فيه من صفر قرابة 20000 غاز، وعلى اليسار تتبع حقيقي للدالة set(8) بأعمدة pc وop وgas وcost تنتهي بالسطرين SSTORE بتكلفة 5000 وLOG2 بتكلفة 1381، وشريط يقسم 7007 من غاز التنفيذ إلى 5000 و1381 و626، مع الدرس أن الحساب رخيص والكتابة في الحالة هي التي تكلف
من 7007 وحدة غاز صرفتها الآلة، ذهبت 6381 إلى الكتابة في الحالة والحدث، و626 فقط إلى كل الحساب

الغاز Gas: لماذا يدفع كل سطر ثمنه؟

قد يبدو الغاز من الوهلة الأولى طريقة لجمع المال من المستخدمين، ولكن سببه الحقيقي تقني بحت. فآلة EVM لغة كاملة تورنغ Turing Complete، أي أنها تستطيع أن تنفذ أي برنامج، ومنها برامج لا تنتهي، ولا توجد طريقة عامة لمعرفة هل سينتهي برنامج ما قبل تشغيله، وهذه هي مسألة التوقف Halting Problem المعروفة. فإذا كانت كل عقدة في العالم ستنفذ كود أي شخص، فما الذي يمنع أحداً من نشر حلقة لا نهائية توقف الشبكة كلها؟ والإجابة هي الغاز: لكل تعليمة ثمن ثابت بوحدات الغاز، ولكل معاملة حد أقصى يحدده المرسل ويدفع ثمنه مقدماً، فإذا نفد الغاز توقف التنفيذ مهما كان الكود، وبالتالي لا توجد حلقة لا نهائية، وإنما توجد حلقة يدفع صاحبها ثمن كل دورة فيها.

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

📌
في سبتمبر وأكتوبر 2016 تعرضت شبكة Ethereum لهجوم حجب خدمة DoS، وكما كتبت مؤسسة Ethereum في إعلان الترقية فإن المهاجم كان يستدعي تعليمات معينة مراراً داخل عقوده، وهي تعليمات صعبة على العقد في المعالجة ولكنها رخيصة جداً في الغاز، فامتلأت الشبكة بالمعاملات المعلقة وتأخرت المعاملات كثيراً. وجاء الحل في ترقية Tangerine Whistle يوم 18 أكتوبر 2016 عند الكتلة 2,463,000، وفيها رفع EIP-150 أثمان التعليمات التي تقرأ الحالة: EXTCODESIZE من 20 إلى 700، وBALANCE من 20 إلى 400، وSLOAD من 50 إلى 200، وCALL من 40 إلى 700، وكتب في سببه أن الهجمات أثبتت أن التعليمات التي تقرأ شجرة الحالة كانت أرخص من حقيقتها. والدرس أن ثمن الغاز ليس رقماً تجارياً، وإنما جزء من أمن الشبكة نفسها، ولذلك تتغير الأثمان مع الترقيات وما زالت تتغير.

تجربة: نفس الدالة بأثمان مختلفة

الآن نجيب عن الملاحظة الثانية بالأرقام. السكربت التالي ينشر العقد، ثم يرسل تحويلين بسيطين، ثم يستدعي set أربع مرات بقيم مختلفة، ثم loop بثلاثة أحجام، ويعرض الغاز المستهلك في كل مرة. واحفظه باسم 02-gas.sh:

set -e
PK0=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
A1=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
cd /lab/store
forge build --quiet
echo "== deploy"
forge create src/Store.sol:Store --broadcast --private-key $PK0 | grep -E 'Deployed to|Transaction hash'
C=0x5FbDB2315678afecb367f032d93F642f64180aa3
echo "== code size (bytes)"; echo $(( ($(cast code $C | wc -c) - 3) / 2 ))
g() { cast send "$@" --private-key $PK0 | grep -E "^(status|gasUsed|transactionHash) "; }
echo "== plain transfer 1 ETH"; g $A1 --value 1ether
echo "== transfer with 4 bytes of data"; g $A1 --value 1ether 0x12345678 || true
echo "== set(7): slot 0 from zero to non-zero"; g $C "set(uint256)" 7
echo "== set(8): non-zero to non-zero"; g $C "set(uint256)" 8
echo "== set(8) again: same value"; g $C "set(uint256)" 8
echo "== set(0): back to zero, refund"; g $C "set(uint256)" 0
echo "== loop(10) / loop(100) / loop(1000)"
for n in 10 100 1000; do g $C "loop(uint256)" $n; done
echo "== slot 0 after loop(1000)"; cast storage $C 0; cast call $C "value()(uint256)"

ثم شغله:

docker exec lab-eth bash /lab/02-gas.sh

والمخرج سوف يكون كما يلي:

== deploy
Deployed to: 0x5FbDB2315678afecb367f032d93F642f64180aa3
Transaction hash: 0x300a2369233e9f260c3a9d42af1062638aa57191f44e36f0f97aebeb4202537f
== code size (bytes)
819
== plain transfer 1 ETH
gasUsed              21000
status               1 (success)
transactionHash      0x22d37fe154bf01a58a4eb7b33f37caaca40048de51df6b6036cfe25b82979474
== transfer with 4 bytes of data
gasUsed              21160
status               1 (success)
transactionHash      0x1c5ff2038bd56014c4bf3d757e692077fcbcade21be45d13b99a9a3ee7f14b44
== set(7): slot 0 from zero to non-zero
gasUsed              45311
status               1 (success)
transactionHash      0x936be5c213681c6d5c5104ffdb43c1582db7f44946d9952283df9abc94e7c39e
== set(8): non-zero to non-zero
gasUsed              28211
status               1 (success)
transactionHash      0xe274574e47c4c9b7c4e65573c28f638c0e8d1fedac2ad45df50a2eb653fdcb6d
== set(8) again: same value
gasUsed              25411
status               1 (success)
transactionHash      0x5b892586466696ef15f86d309173f1653b83b65b970574723e6b1a49b097e766
== set(0): back to zero, refund
gasUsed              23399
status               1 (success)
transactionHash      0x5539ce60b5a88104453d689c03b90097ea1329e7000ab252cc88b8602f21d5a5
== loop(10) / loop(100) / loop(1000)
gasUsed              46228
status               1 (success)
transactionHash      0xb6782f2e860f61c07e35a1a41012b4f9d5225f7376f5407cfe757e5b6030720e
gasUsed              51538
status               1 (success)
transactionHash      0x27e01b94890294a1b4020923af501ba1826b6a2e280ee7bf9eafa070c80b9f03
gasUsed              275650
status               1 (success)
transactionHash      0xeebad5f4f33a945f9ef5a6f1a9d010d879be648284305cadfb5ebf31bb7709cb
== slot 0 after loop(1000)
0x0000000000000000000000000000000000000000000000000000000000079f2c
499500 [4.995e5]

لاحظ أولاً أن العقد نشر على العنوان 0x5FbDB231… الذي حسبناه مسبقاً بالأمر cast compute-address، لأنه أول عقد ينشره الحساب 0 والعداد كان صفراً. والآن نقرأ أرقام الغاز رقماً رقماً.

الكلفة الثابتة وبيانات الاستدعاء

التحويل البسيط كلف 21000 بالضبط، وهذه الكلفة الثابتة لأي معاملة كما يذكر توثيق الغاز، وهي تغطي التحقق من التوقيع وتعديل حسابين. وعندما أضفنا أربعة بايتات من البيانات أصبح 21160، أي 40 وحدة لكل بايت، وهذا رقم غريب إذا كنت تعرف القاعدة القديمة في EIP-2028: 16 وحدة لكل بايت غير صفري و4 لكل بايت صفري، أي 64 فقط. والسبب ترقية Pectra التي أضافت EIP-7623، وفيها حد أدنى لثمن بيانات الاستدعاء: إذا كانت المعاملة تحمل بيانات كثيرة وتنفذ قليلاً، تدفع 10 وحدات لكل «رمز»، والبايت غير الصفري أربعة رموز، فيصبح 40 وحدة. والهدف أن تذهب البيانات الكبيرة إلى معاملات Blobs المخصصة لها، ولا تملأ الكتل العادية.

وفي استدعاء الدالة set(8) تحمل المعاملة 36 بايت: 4 بايتات لمحدد الدالة، ثم الرقم 8 في 32 بايت أغلبها أصفار، فتكلفتها بالقاعدة العادية 5 بايتات غير صفرية × 16 + 31 بايتاً صفرياً × 4 = 204، لأن التنفيذ هنا أكبر من الحد الأدنى. ولذلك 28211 = 21000 + 204 + 7007، والرقم 7007 هو بالضبط مجموع التنفيذ الذي رأيناه في التتبع.

لماذا كلفت set(7) ضعف set(8) تقريباً؟

هنا جواب الملاحظة الثانية. نفس الدالة ونفس الكود كلفت 45311 مرة و23399 مرة أخرى، والفرق كله في تعليمة واحدة هي SSTORE، وثمنها لا يعتمد على القيمة التي تكتبها، وإنما على حالة الخانة قبل الكتابة وبعدها، وهذا ما يسمى قياس الغاز الصافي Net Gas Metering الموصوف في EIP-2200، مع إضافة ثمن القراءة الأولى Cold Access من EIP-2929 في ترقية Berlin:

الاستدعاءالخانة قبل ثم بعدgasUsedثمن SSTOREلماذا؟
set(7)0 ثم 74531122100إنشاء خانة جديدة: 20000، مع 2100 للوصول الأول إليها في المعاملة
set(8)7 ثم 8282115000تعديل خانة موجودة: 2900، مع 2100 للوصول الأول
set(8) مرة ثانية8 ثم 8254112200لا يوجد تغيير: ثمن القراءة فقط 100، مع 2100 للوصول الأول
set(0)8 ثم 0233995000 ثم يسترد 4800مسح الخانة يعيد جزءاً من الغاز في آخر المعاملة

وكل الأرقام في الجدول تتسق مع المخرج: 45311 = 21204 + 22100 + 2007، حيث 2007 هي بقية التنفيذ من حدث LOG2 وتعليمات الحساب، وهي نفس 1381 + 626 التي رأيناها في التتبع. و25411 = 21204 + 2200 + 2007. أما set(0) فكلفت قبل الاسترداد 28199، ثم استردت 4800، والاسترداد Refund محدود منذ EIP-3529 بخمس الغاز المستهلك، وخمس 28199 أكبر من 4800 فاسترد الرقم كاملاً. وقبل هذا التحديد كانت هناك عقود تكتب خانات وقت رخص الرسوم ثم تمسحها وقت ارتفاعها لتسترد الغاز، فكان الاسترداد نفسه وسيلة للمضاربة على الغاز وتضخيم حجم الحالة، فحددوه.

والصورة التالية تبين كل معاملة مقسمة إلى أجزائها:

رسم بأشرطة أفقية لست معاملات من التجربة: تحويل 1 ETH بغاز 21000 كله رمادي للكلفة الثابتة، وتحويل مع 4 بايت بغاز 21160، والدالة set(7) من صفر إلى قيمة 45311 منها 24107 تنفيذ باللون البرتقالي، وset(8) من قيمة إلى قيمة 28211 منها 7007 تنفيذ، وset(8) بنفس القيمة 25411 منها 4207 تنفيذ، وset(0) العودة إلى الصفر 23399 مع جزء مخطط بالأخضر يمثل استرداد 4800، وتحت الرسم ملاحظة أن نفس الدالة كلفت من 23399 إلى 45311 بحسب حالة الخانة قبلها
الكلفة الثابتة واحدة في كل المعاملات، والفرق كله في الكتابة على التخزين الدائم

وما الذي يعنيه ذلك لفريق المنصة؟ أن تصميم المتغيرات في العقد قرار مالي وليس قراراً جمالياً، فعقد الضمان الذي ينشئ خانة جديدة لكل مشروع يدفع 22100 تقريباً لكل خانة جديدة، والعقد الذي يحدث نفس الخانة يدفع 5000، والعقد الذي يمسح الخانة عند انتهاء المشروع يسترد جزءاً. وأدوات مثل forge test --gas-report التي سوف نراها في مقال أدوات التطوير تعطيك هذه الأرقام لكل دالة قبل النشر.

الحلقات Loops: الحساب رخيص ولكنه ليس مجانياً

أرقام loop تقول شيئاً مهماً: loop(100) كلفت 51538، وloop(1000) كلفت 275650، أي أن 900 دورة إضافية كلفت 224112 وحدة، أي قرابة 249 وحدة لكل دورة، لأن كل دورة فيها جمع ومقارنة وقفزة وفحص التجاوز Overflow الذي يضيفه المترجم منذ Solidity 0.8. أما loop(10) فكلفت 46228، أكثر من loop(100)، والسبب ليس الحلقة، وإنما أن الخانة كانت صفراً بعد set(0) فدفعت ثمن إنشائها من جديد. وهذا مثال جيد على أن تفسير رقم الغاز يحتاج أن تعرف حالة التخزين قبله.

والخطر الحقيقي في الحلقات أن عدد دوراتها يحدده المستخدم أو تحدده البيانات، فإذا كان عقد الضمان يمر في حلقة على كل المستقلين ليوزع الدفعات، فإن الحلقة تكبر مع كل مستقل جديد حتى يتجاوز ثمنها حد الغاز للمعاملة، فتفشل الدالة إلى الأبد ويعلق المال داخل العقد. ومنذ ترقية Fusaka يوم 3 ديسمبر 2025 هناك حد أقصى لأي معاملة واحدة قدره 16,777,216 وحدة غاز، أي 2 أس 24، كما في EIP-7825، وحد الكتلة نفسه ارتفع إلى 60 مليوناً كما تذكر صفحة Fusaka، وبالتالي الحلقة غير المحدودة ليست خطأ أداء فقط، وإنما خطأ قد يوقف العقد كله. والحل المعروف أن يسحب كل مستفيد ماله بنفسه بدل أن يوزع العقد على الجميع في حلقة، وهذا ما سوف نعود إليه في مقال أمن العقود الذكية.

الرسوم بعد EIP-1559: الأساسية والإكرامية

الغاز يقيس كمية العمل، أما السعر الذي تدفعه لكل وحدة منه فيتحدد في سوق، وهذا السوق تغير جذرياً مع ترقية London يوم 5 أغسطس 2021 عند الكتلة 12,965,000، التي جاءت بالمقترح EIP-1559. وقبله كانت الرسوم مزاداً بسيطاً: كل مرسل يحدد سعراً، ومن يقوم بالتعدين يختار الأعلى، فتتقلب الرسوم بشكل حاد ولا تعرف المحفظة كم تضع. وبعده أصبح سعر الوحدة من جزأين، كما يشرح توثيق الغاز:

  • الرسوم الأساسية Base Fee: يحددها البروتوكول نفسه لكل كتلة من الكتلة التي قبلها، ويجب أن تدفعها كل معاملة في الكتلة، وهي تحرق Burn ولا يأخذها أحد.
  • الإكرامية Priority Fee أو Tip: مبلغ إضافي يذهب إلى المدقق الذي يقترح الكتلة، وهو الذي يجعله يفضل معاملتك على غيرها عند الزحام.

والقاعدة التي تحرك الرسوم الأساسية بسيطة: لكل كتلة هدف Target هو نصف حد الغاز فيها، فإذا استهلكت الكتلة أكثر من الهدف ترتفع الرسوم الأساسية للكتلة التالية، وإذا استهلكت أقل تنخفض، والتغيير في الكتلة الواحدة لا يتجاوز 12.5%. وفي المعاملة نفسها تحدد أنت حدين: maxFeePerGas وهو أقصى ما تقبل دفعه لكل وحدة، وmaxPriorityFeePerGas وهو أقصى إكرامية، فتدفع فعلياً الرسوم الأساسية زائد الإكرامية، ويعود إليك الفرق حتى الحد الأقصى، وإذا ارتفعت الرسوم الأساسية فوق حدك الأقصى تنتظر معاملتك في الطابور حتى تنخفض.

تجربة: نرفع الرسوم الأساسية ونخفضها بأيدينا

على Anvil العادي حد الكتلة 30 مليون وحدة، فلن تمتلئ أي كتلة بتجاربنا الصغيرة، بل سوف ترى الرسوم الأساسية تنخفض في كل كتلة لأن كل الكتل تحت الهدف. لذلك نشغل عقدة ثانية داخل نفس ال Container بحد غاز صغير عن قصد هو 105000، أي خمسة تحويلات فقط، ونوقف الإنشاء التلقائي للكتل حتى نتحكم فيه بالأمر evm_mine، ثم نرسل 15 تحويلاً وننشئ ست كتل. واحفظ السكربت باسم 03-basefee.sh:

set -e
R=http://127.0.0.1:8546
anvil --port 8546 --no-mining --gas-limit 105000 --silent &
sleep 2
PK0=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
A1=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
for i in $(seq 1 15); do
  cast send --rpc-url $R --async --nonce $((i-1)) --private-key $PK0 --gas-limit 21000 --gas-price 5gwei --priority-gas-price 1gwei $A1 --value 0.01ether > /dev/null
done
echo "pending in txpool: $(cast rpc --rpc-url $R txpool_status)"
for b in 1 2 3 4 5 6; do cast rpc --rpc-url $R evm_mine > /dev/null; done
printf "%-6s %-4s %-8s %-14s %s\n" block txs gasUsed baseFee burned_gwei
for n in 0 1 2 3 4 5 6; do
  J=$(cast block $n --rpc-url $R --json)
  used=$(echo "$J" | grep -o '"gasUsed":"[^"]*"' | cut -d'"' -f4)
  fee=$(echo "$J" | grep -o '"baseFeePerGas":"[^"]*"' | cut -d'"' -f4)
  txs=$(( used / 21000 ))
  printf "%-6s %-4s %-8s %-14s %s\n" $n $txs $((used)) $((fee)) $(( used * fee / 1000000000 ))
done
H=$(cast block 3 --rpc-url $R --full --json | grep -o "\"hash\":\"0x[0-9a-f]*\"" | sed -n 2p | cut -d\" -f4)
echo "one tx in block 3:"; cast receipt $H --rpc-url $R | grep -E "^(blockNumber|effectiveGasPrice|gasUsed) "
kill %1

ثم شغله:

docker exec lab-eth bash /lab/03-basefee.sh

والمخرج سوف يكون كما يلي:

pending in txpool: {"pending":"0xf","queued":"0x0"}
block  txs  gasUsed  baseFee        burned_gwei
0      0    0        1000000000     0
1      5    105000   1000000000     105000
2      5    105000   1125000000     118125
3      5    105000   1265625000     132890
4      0    0        1423828125     0
5      0    0        1245849610     0
6      0    0        1090118409     0
one tx in block 3:
blockNumber          3
effectiveGasPrice    2265625000
gasUsed              21000

في المخرج أعلاه لاحظ التالي:

  • الطابور كان فيه 15 معاملة جاهزة 0xf، فأخذت كل كتلة خمساً منها فقط لأن حدها 105000، وامتلأت الكتل 1 و2 و3 تماماً، وهذا ضعف الهدف 52500.
  • الرسوم الأساسية للكتلة 1 كانت 1 gwei، وبعد كل كتلة ممتلئة ارتفعت 12.5% بالضبط: 1.125 ثم 1.265625 ثم 1.423828125. ولاحظ أن الكتلة 4 فارغة ومع ذلك رسومها الأعلى، والسبب أن الرسوم تحسب من الكتلة التي قبلها، كما يذكر التوثيق أن هذا يجعل الرسوم قابلة للتوقع.
  • بعد الكتل الفارغة انخفضت الرسوم 12.5% في كل مرة: 1.2458 ثم 1.0901.
  • العمود الأخير هو ما احترق في كل كتلة: الغاز المستهلك ضرب الرسوم الأساسية، فاحترق في الكتلة 3 قرابة 132890 gwei، ولم يذهب منها شيء إلى المدقق.
  • المعاملة في الكتلة 3 دفعت لكل وحدة 2.265625 gwei، وهي الرسوم الأساسية 1.265625 زائد الإكرامية 1 gwei التي حددناها، ولم تدفع 5 gwei التي وضعناها حداً أقصى، فعاد الفرق تلقائياً.

والصورة التالية ترسم نفس الأرقام:

رسم من جزأين: في الأعلى أعمدة الغاز المستهلك لست كتل، الكتل 1 و2 و3 ممتلئة بخمسة تحويلات من 21000 حتى خط الحد Gas Limit المتقطع الأحمر عند 105000 وفوق خط الهدف Target الأخضر عند 52500، مع ما احترق في كل منها 105000 و118125 و132890 gwei، والكتل 4 و5 و6 فارغة، وفي الأسفل خط برتقالي للرسوم الأساسية بوحدة gwei يرتفع من 1.0000 إلى 1.1250 ثم 1.2656 ثم 1.4238 ثم ينخفض إلى 1.2458 ثم 1.0901
ثلاث كتل ممتلئة رفعت الرسوم الأساسية 42%، وثلاث كتل فارغة أعادتها قريباً من البداية

والمثال العددي في توثيق الغاز يلخص نفس الحساب: تحويل من 21000 وحدة برسوم أساسية 10 gwei وإكرامية 2 gwei يكلف 252,000 gwei، يحترق منها 210,000 ويذهب 42,000 إلى المدقق. ولماذا تحرق الرسوم الأساسية بدل أن تعطى للمدقق؟ لأن المدقق لو أخذها لكان له مصلحة في ملء الكتل بمعاملات وهمية من نفسه ليرفع الرسوم على الجميع ثم يستردها، أما الحرق فيجعل رفع الرسوم الأساسية يكلف من يحاول التلاعب بها.

تقدير الغاز Estimation وحدوده

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

ماذا يحدث عندما تفشل المعاملة؟

نصل الآن إلى الملاحظة الأولى: معاملة فشلت ونقص الرصيد. والسكربت 04-failures.sh يجرب حالتين من الفشل بحساب ثان هو الحساب 1، ويقيس كم دفع في كل مرة، وهذه بدايته:

PK1=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
A1=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
A2=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
C=0x5FbDB2315678afecb367f032d93F642f64180aa3
bal() { cast balance $A1; }
echo "== 1. revert: setPositive(0), the node refuses to estimate"
cast send $C "setPositive(uint256)" 0 --private-key $PK1 2>&1 | tail -1
echo "== 2. the same call forced with a gas limit"
B=$(bal); cast send $C "setPositive(uint256)" 0 --gas-limit 60000 --private-key $PK1 2>&1 | grep -E '^(status|gasUsed|effectiveGasPrice) |Error'
echo "paid (wei): $(( B - $(bal) ))"
echo "== 3. out of gas: loop(1000) with 60000 gas"
B=$(bal); cast send $C "loop(uint256)" 1000 --gas-limit 60000 --private-key $PK1 2>&1 | grep -E '^(status|gasUsed) |Error'
echo "paid (wei): $(( B - $(bal) ))"; echo "value is still: $(cast call $C 'value()(uint256)')"

ثم نشغل السكربت كاملاً، وبقيته هي أجزاء العداد والإعادة التي رأيناها في قسم المعاملة:

docker exec lab-eth bash /lab/04-failures.sh

والجزء الأول من المخرج سوف يكون كما يلي:

== 1. revert: setPositive(0), the node refuses to estimate
Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: value must be positive, data: "0x08c379a00000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000001676616c7565206d75737420626520706f73697469766500000000000000000000": Error("value must be positive")
== 2. the same call forced with a gas limit
effectiveGasPrice    265003679
gasUsed              21941
status               0 (failed)
paid (wei): 5814445720939
== 3. out of gas: loop(1000) with 60000 gas
gasUsed              60000
status               0 (failed)
paid (wei): 13915600380000
value is still: 499500 [4.995e5]

في المخرج أعلاه لاحظ التالي:

  • في المحاولة الأولى لم تصل المعاملة إلى الشبكة أصلاً: التقدير التجريبي نفذ الدالة فوجد أن require سوف يوقفها، فرفض cast الإرسال ولم يدفع شيئاً، والبيانات الطويلة بعد data هي رسالة الخطأ مرمزة، حيث 0x08c379a0 هو محدد Error(string) وبعده النص «value must be positive» بالبايتات.
  • في المحاولة الثانية حددنا حد الغاز بأيدينا فتجاوزنا التقدير، فدخلت المعاملة كتلة وفشلت بالحالة status 0، واستهلكت 21941 وحدة، ودفعنا ثمنها بالضبط: 21941 × 265003679 = 5814445720939 Wei. وهذا سبب الملاحظة الأولى: المحفظة أرسلت رغم التحذير، أو تغيرت الحالة بين التقدير والتنفيذ.
  • في المحاولة الثالثة لم يوجد أي خطأ في المنطق، وإنما حد الغاز 60000 أقل مما تحتاجه 1000 دورة، فتوقفت الآلة عند نفاد الغاز Out of Gas، واستهلكت الحد كاملاً 60000، ودفعنا ثمنه كاملاً.
  • في الحالتين لم تتغير القيمة المخزنة، فما زالت 499500 من loop(1000) السابقة، لأن الفشل يلغي كل تغييرات المعاملة في الحالة، ولكنه لا يلغي الرسوم.

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

⚠️
لا تتجاوز تحذير تقدير الغاز بوضع حد يدوي إلا إذا كنت تعرف لماذا فشل التقدير. فالرسالة Failed to estimate gas مع execution reverted تعني في أغلب الحالات أن المعاملة سوف تفشل فعلاً، وفرض إرسالها يحول الفشل المجاني إلى فشل مدفوع.

ماذا يحدث إذا أرسلنا مالاً إلى دالة لا تقبله؟

رأينا في الكود الثنائي أن المترجم أضاف فحصاً يرفض أي استدعاء يحمل ETH لأن الدوال ليست payable. ولكي ترى ذلك جرب الأمر التالي، الذي يحاول إرسال 1 ETH مع استدعاء set:

docker exec lab-eth cast send 0x5FbDB2315678afecb367f032d93F642f64180aa3 "set(uint256)" 5 --value 1ether \
  --private-key 0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d

وسوف تجد أن التقدير يفشل بالرسالة execution reverted دون نص، لأن الرفض جاء من التعليمات PUSH0 PUSH0 REVERT التي رأيناها في أول الكود، وهي لا تحمل رسالة. وهذه الحماية مهمة لعقد الضمان: الدالة التي لا يجب أن تستقبل مالاً يجب ألا تكون payable، وإلا علق في العقد مال لا توجد دالة تخرجه.

الحالة State ورأس الكتلة

بقي سؤال يطرحه كل من يقترب من Ethereum: إذا كانت كل عقدة تحفظ ملايين الحسابات، فكيف تتأكد عقدتان أن حالتيهما متطابقتان دون أن تقارنا كل شيء؟ والإجابة في رأس الكتلة Block Header، كما يصفه توثيق الكتل. والسكربت 05-block.sh يعرض رأس الكتلة التي دخلت فيها معاملة set(8)، والحدث الذي أطلقته، وحساب العقد كما كان عندها:

C=0x5FbDB2315678afecb367f032d93F642f64180aa3
echo "== header of block 5 (the set(8) transaction)"
cast block 5 | grep -E '^(number|hash|parentHash|stateRoot|transactionsRoot|receiptsRoot|gasLimit|gasUsed|baseFeePerGas|timestamp|miner|difficulty|withdrawalsRoot|blobGasUsed|requestsHash) '
echo "== the log of set(8)"
cast receipt 0xe274574e47c4c9b7c4e65573c28f638c0e8d1fedac2ad45df50a2eb653fdcb6d logs | grep -E 'address|topics|data|0x' | head -8
echo "== event signature hash"
cast keccak "ValueSet(address,uint256)"
echo "== the contract account"
cast proof $C 0 | grep -E '"(codeHash|nonce|storageHash|key|value)"'
cast keccak $(cast code $C)
echo "== the same account and slot as they were at block 5"
cast proof $C 0 --block 5 | grep -E '"(storageHash|value)"'
cast proof 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 --block 5 | grep -E '"nonce"'

والمخرج سوف يكون كما يلي، وقد قسمنا سطر الحدث الطويل حتى يقرأ، وحذفنا منه الحقول blockHash وblockTimestamp وtransactionIndex:

== header of block 5 (the set(8) transaction)
baseFeePerGas        587952361
difficulty           0
gasLimit             30000000
gasUsed              28211
hash                 0x927b5539002d22f63643c75c9c426c641cef43ee8a38d2aef0df87e74e8573fc
miner                0x0000000000000000000000000000000000000000
number               5
parentHash           0x014d71491b59182ed4aea3a7812e50b1a20595050763849ce792b5958a8922ff
transactionsRoot     0x124433a82a4e458f97868b8b7943523e5c3e6ff5410a5932e884c39e004aaacd
receiptsRoot         0x8313ed20dc86964f57699edb6a8733836dd4a3a0f84aeb8f598aebe108152e34
stateRoot            0xe82ccdd24db48845b415bbe20187d0fb7c7854a8c5d18234c83271327ea43ae5
timestamp            1793145667 (Wed, 28 Oct 2026 00:01:07 +0000)
withdrawalsRoot      0x56e81f171bcc55a6ff8345e692c0f86e5b48e01b996cadc001622fb5e363b421
blobGasUsed          0
requestsHash         0xe3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
== the log of set(8)
[{"address":"0x5fbdb2315678afecb367f032d93f642f64180aa3",
  "topics":["0xf3f57717dff9f5f10af315efdbfadc60c42152c11fc0c3c413bbfbdc661f143c",
            "0x000000000000000000000000f39fd6e51aad88f6f4ce6ab8827279cfffb92266"],
  "data":"0x0000000000000000000000000000000000000000000000000000000000000008",
  "blockNumber":"0x5","transactionHash":"0xe274574e47c4c9b7c4e65573c28f638c0e8d1fedac2ad45df50a2eb653fdcb6d","logIndex":"0x0","removed":false}]
== event signature hash
0xf3f57717dff9f5f10af315efdbfadc60c42152c11fc0c3c413bbfbdc661f143c
== the contract account
  "codeHash": "0x0ee397f50a8556987d9c35ea537966355f7d153ea53d242135ff9ebe01b386cf",
  "nonce": "0x1",
  "storageHash": "0xdc756dc393d84b465fd0ec1c851a41787d7f337918a6947237aca57cddf25eb9",
      "key": "0x0000000000000000000000000000000000000000000000000000000000000000",
      "value": "0x79f2c",
0x0ee397f50a8556987d9c35ea537966355f7d153ea53d242135ff9ebe01b386cf
== the same account and slot as they were at block 5
  "storageHash": "0x1c2e599f5f2a6cd75de40aada2a11971863dabd7a7378f1a3b268856a95829ba",
      "value": "0x8",
  "nonce": "0x5",

في المخرج أعلاه لاحظ التالي:

  • في الرأس ثلاثة جذور: transactionsRoot لمعاملات الكتلة، وreceiptsRoot لإيصالاتها، وstateRoot للحالة كلها بعد تنفيذ الكتلة. والجذر الأخير هو المهم هنا: هاش واحد من 32 بايت يلخص كل الحسابات في الشبكة، فإذا تطابق عند عقدتين فحالتاهما متطابقتان.
  • difficulty صفر وminer أصفار لأن الشبكة تعمل بإثبات الحصة، وwithdrawalsRoot يساوي جذر الشجرة الفارغة الذي رأيناه في حساب EOA، لأنه لا توجد سحوبات من المدققين في شبكتنا المحلية. وrequestsHash جاء مع Pectra.
  • الحدث له موضوعان Topics: الأول هاش توقيع الحدث ValueSet(address,uint256)، وقد حسبناه بأنفسنا في السطر الذي بعده فوجدناه متطابقاً، والثاني العنوان الذي استدعى الدالة لأنه معرف بالكلمة indexed، أما القيمة 8 فهي في data. والموضوعات قابلة للبحث، لذلك يستطيع نظام المنصة أن يطلب من العقدة «كل أحداث ValueSet من هذا العنوان» دون أن يقرأ كل الكتل.
  • codeHash لحساب العقد يساوي هاش الكود الذي حسبناه بالأمر cast keccak $(cast code $C)، فأي عقدة تستطيع أن تتأكد أن الكود الذي تنفذه هو الكود المنشور.
  • الخانة 0 الآن 499500 من loop(1000)، ولكن بالخيار --block 5 طلبنا الحالة كما كانت عند الكتلة 5، فوجدنا القيمة 8 وجذر تخزين مختلفاً، ووجدنا عداد الحساب 0 يساوي 5. فالعقدة تحفظ الحالات السابقة أيضاً، وهذا ما يسمى عقدة أرشيفية Archive Node عندما تحفظها كلها، وسوف نتكلم عن أنواع العقد في مقال تشغيل عقدة Ethereum.

والصورة التالية تبين كيف ترتبط هذه الجذور ببعضها:

مخطط من ثلاثة أجزاء: على اليمين رأس الكتلة رقم 5 من التجربة بحقوله number وparentHash وgasUsed وbaseFeePerGas وثلاثة جذور transactionsRoot وreceiptsRoot وstateRoot الملون بالبرتقالي، وسهم من stateRoot إلى شجرة الحالة State Trie في الوسط بجذر وعقد وسطى وورقتين، حساب EOA بقيمة nonce 5 وكود وتخزين فارغين، وحساب العقد Store بقيمة nonce 1 وcodeHash وstorageHash 0x1c2e599f، وسهم من حساب العقد إلى شجرة التخزين على اليسار وفيها الخانة slot 0 بقيمة 0x8، وصندوق يشرح أن تغيير خانة واحدة يغير storageHash ثم هاش الحساب ثم stateRoot
تغيير خانة واحدة في عقد واحد يغير جذر الحالة في رأس الكتلة، فتكفي مقارنة 32 بايت لمقارنة الحالة كلها

والبنية التي تربط الجذر بالحسابات اسمها شجرة Merkle Patricia Trie، وهي مزيج من شجرة Merkle التي بنيناها في مقال البلوكتشين للمبتدئين وشجرة البحث بالبادئة Patricia Trie، وتفاصيلها في توثيق الشجرة. والفائدة العملية التي تهم فريق المنصة هي eth_getProof: عندما تطلب حساباً وخانة من مزود RPC لا تثق به، تستطيع أن تطلب معها الإثبات Proof، وهو الهاشات التي على الطريق من الخانة إلى stateRoot، فتتحقق بنفسك أن القيمة صحيحة إذا كان عندك رأس الكتلة من مصدر موثوق، وهذه هي الفكرة التي تقوم عليها العملاء الخفيفة Light Clients.

الشبكة اليوم: كتلة كل 12 ثانية وترقيات منتظمة

كل ما جربناه على Anvil يحدث على الشبكة الرئيسية بنفس القواعد، والفرق في الإيقاع والحجم: كتلة في كل خانة مدتها 12 ثانية يقترحها مدقق واحد كما شرحنا في مقال الإجماع، وحد غاز للكتلة يتغير بتصويت المدققين وبالترقيات، ورسوم أساسية تتحرك مع الطلب الحقيقي. والعقدة الكاملة اليوم برنامجان يعملان معاً، عميل تنفيذ Execution Client فيه آلة EVM والحالة، وعميل إجماع Consensus Client يتابع المدققين، كما يشرح توثيق العقد والعملاء. والقواعد نفسها تتغير بالترقيات، وأغلب الأثمان التي رأيناها في هذا المقال جاءت من ترقية معينة، والجدول التالي يجمع الترقيات التي ذكرناها بتواريخها من السجل الرسمي:

الترقيةالتاريخما يهمنا منها في هذا المقال
Frontier30 يوليو 2015إطلاق الشبكة الأولى
Tangerine Whistle18 أكتوبر 2016، الكتلة 2,463,000EIP-150: رفع أثمان تعليمات قراءة الحالة بعد هجوم DoS
Spurious Dragon22 نوفمبر 2016، الكتلة 2,675,000EIP-155: رقم الشبكة chainId تحت التوقيع
Berlin15 أبريل 2021، الكتلة 12,244,000EIP-2929: ثمن الوصول الأول Cold Access
London5 أغسطس 2021، الكتلة 12,965,000EIP-1559: الرسوم الأساسية والحرق، وEIP-3529: تحديد الاسترداد
The Merge15 سبتمبر 2022الانتقال إلى إثبات الحصة
Dencun13 مارس 2024، الكتلة 19,426,587EIP-4844: معاملات Blobs، وEIP-1153: التخزين المؤقت
Pectra7 مايو 2025، الكتلة 22,431,084EIP-7702: تفويض الحساب الخارجي، وEIP-7623: الحد الأدنى لثمن البيانات
Fusaka3 ديسمبر 2025، الكتلة 23,935,694EIP-7825: حد 2 أس 24 للمعاملة، وحد الكتلة الافتراضي 60 مليوناً

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

أخطاء شائعة وكيف تلاحظها

كل خطأ في القائمة التالية رأينا أثره في تجربة من تجارب المقال:

  • اعتبار المعاملة الفاشلة مجانية: الحالة status 0 مع gasUsed أكبر من صفر تعني أنك دفعت، فراقب نسبة المعاملات الفاشلة في نظامك كما تراقب أي تكلفة.
  • إدارة العداد nonce من عدة أماكن: تلاحظه عندما ترى معاملات في queued لا تتحرك، أو الخطأ nonce too low في سجلات العمال، والحل أن يحسب العداد مكان واحد في النظام.
  • الاعتماد على تقدير الغاز دون هامش: تلاحظه في معاملات تفشل بنفاد الغاز وغازها المستهلك يساوي الحد بالضبط، كما في gasUsed 60000.
  • تخزين المبالغ في متغيرات عشرية: تلاحظه في فروق صغيرة بين رصيد النظام ورصيد الشبكة بعد عمليات كثيرة، والحل الأعداد الصحيحة بوحدة Wei.
  • حلقة يكبر عددها مع البيانات: تلاحظه في دالة يرتفع غازها مع كل مستخدم جديد كما ارتفع loop مع n، وتصل يوماً إلى حد المعاملة.
  • افتراض أن الأثمان والحدود ثابتة: تلاحظه بعد كل ترقية في أدوات حساب الرسوم، والحل أن تقرأ الأثمان من التقدير والإيصالات وليس من ثوابت في الكود.
  • إرسال مال إلى عقد لا يحتاجه: دالة payable بلا سبب تعني أن المال الذي يصلها خطأً قد يبقى في العقد للأبد.

قائمة تحقق للفريق قبل أول عقد

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

ما تراهالسبب الغالبماذا تفعل
معاملة فشلت ونقص الرصيدRevert أو نفاد الغاز داخل الكتلةاقرأ الإيصال والتتبع، ولا تتجاوز تحذير التقدير
نفس الدالة بأثمان مختلفةحالة الخانات قبل الكتابة، والوصول الأولقس بالأمر forge test --gas-report، وصمم المتغيرات بناءً عليه
معاملة لا تدخل أي كتلةفجوة في nonce أو رسوم قصوى أقل من الرسوم الأساسيةافحص txpool_status، وسد الفجوة أو استبدل المعاملة بنفس الرقم ورسوم أعلى
nonce too lowالمعاملة نفذت من قبل، أو أرسل عامل آخر نفس الرقماجعل مكاناً واحداً يدير العداد
الرسوم ارتفعت فجأةكتل ممتلئة متتالية رفعت الرسوم الأساسية 12.5% في كل مرةضع حداً أقصى معقولاً وانتظر، أو أجل العمليات غير العاجلة
دالة تقترب من حد الغازحلقة على بيانات تكبراجعل كل مستفيد يسحب بنفسه، وقسم العمل على عدة معاملات

وقبل أن تنشر أي عقد يحمل قيمة حقيقية، تحقق من التالي:

  1. كل المبالغ في الكود وقاعدة البيانات أعداد صحيحة بوحدة Wei، أو بأصغر وحدة للرمز Token.
  2. لا توجد دالة payable إلا إذا كانت تستقبل المال فعلاً، ولكل مال يدخل العقد طريق معروف للخروج.
  3. لا توجد حلقة يتحكم المستخدم في عدد دوراتها، أو يكبر عددها مع البيانات دون حد.
  4. جربت كل دالة في حالتي الخانة الفارغة والممتلئة، وتعرف أعلى غاز تحتاجه كل دالة.
  5. نظام الإرسال يدير العداد nonce في مكان واحد، ويضع هامشاً فوق تقدير الغاز، ويراقب المعاملات المعلقة والفاشلة.
  6. تعرف رقم الشبكة chainId التي تعمل عليها، ولا يوجد مفتاح حقيقي في أي ملف أو مستودع.
  7. راجعت الجانب النظامي لأي استخدام يتعامل مع أموال حقيقية من مصادره الرسمية قبل أي قرار تقني.

تدريب عملي

  1. أضف إلى العقد Store متغيراً ثانياً من نوع uint128 بجانب ثالث من نفس النوع، ثم قارن غاز كتابتهما معاً بغاز كتابة متغيرين من نوع uint256، وفسر الفرق بعدد الخانات التي تكتبها SSTORE.
  2. في تجربة الرسوم الأساسية غير حد الغاز إلى 63000، أي ثلاثة تحويلات، وأرسل أربعة تحويلات في كل كتلة، ثم احسب بنفسك الرسوم الأساسية المتوقعة لكل كتلة قبل أن تشغل السكربت، وقارنها بالمخرج.

حذف التجربة

بعد الانتهاء احذف ال Container، واحذف ال Image إذا لم تعد تحتاجها، والعقدة الثانية على المنفذ 8546 تتوقف وحدها مع نهاية السكربت:

docker rm -f lab-eth
docker image rm ghcr.io/foundry-rs/foundry:v1.8.6

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Ethereum آلة حالة حتمية: الحالة الحالية مع معاملات صالحة تعطي حالة جديدة واحدة عند كل عقدة، ولذلك لا توجد فيها أرقام عشرية، وكل المبالغ أعداد صحيحة بوحدة Wei.
  • هناك نوعان من الحسابات بنفس الحقول الأربعة، والحساب الخارجي وحده يبدأ المعاملات، والعنوان آخر 20 بايت من هاش المفتاح العام، وعنوان العقد يحسب قبل نشره.
  • كل ما يحدد معنى المعاملة تحت التوقيع، والعداد nonce يرتب معاملات الحساب ويمنع إعادتها، وفجوة واحدة فيه توقف كل ما بعدها.
  • آلة EVM آلة مكدس، والحساب فيها رخيص، والكتابة في التخزين الدائم هي التي تكلف: 22100 لخانة جديدة و5000 لتعديلها و2200 لكتابة نفس القيمة.
  • الغاز يمنع البرامج التي لا تنتهي ويقيس الحمل الحقيقي، وأثمانه جزء من أمن الشبكة كما أثبت هجوم 2016.
  • الرسوم الأساسية ترتفع وتنخفض 12.5% بحسب امتلاء الكتلة السابقة وتحرق، والإكرامية للمدقق، والفرق حتى الحد الأقصى يعود إليك.
  • المعاملة الفاشلة داخل الكتلة تدفع ثمن ما استهلكته، والرفض قبل الكتلة لا يكلف شيئاً، ورأس الكتلة يلخص الحالة كلها في جذر واحد يمكن إثبات أي حساب منه.

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

دليلكيف يعمل البلوكتشين Blockchain ومتى لا تحتاجهما المشكلة التي يحلها البلوكتشين أصلاً وكيف يعمل من الداخل، ومتى تكون قاعدة البيانات العادية هي الحل الأصح.دليلكيف تتفق شبكة البلوكتشين؟ إثبات العمل وإثبات الحصةكيف تتفق آلاف العقد التي لا يثق بعضها ببعض على سجل واحد، ولماذا يكلف الغش كهرباء في شبكة ومالاً محجوزاً في أخرى.دليلما هي العقود الذكية Smart Contracts وما مخاطرها؟نكتب عقد ضمان لمنصة عمل حر ونجربه على شبكة محلية، لنرى أين يفي العقد الذكي بوعده وأين ينقل الثقة إلى مفتاح أو أوراكل.دليلتطوير العقود الذكية بـ Solidity مع Foundry وHardhatنأخذ عقد الضمان إلى بيئة تطوير كاملة باختبارات وحدة وعشوائية وثوابت، ونرى كيف وجدت الثوابت خطأ لم تره ثمانية اختبارات.دليلتشغيل عقدة Ethereum: عميل التنفيذ وعميل الإجماعنشغل عميل تنفيذ حقيقي على جهازنا ونرسل إليه معاملة، ونفهم لماذا صارت العقدة برنامجين، ومتى تشغل عقدتك بدل مزود خارجي.دليلالمحفظة الرقمية من الداخل: دفتر القيد المزدوج Ledgerكيف تبني محفظة رقمية لا يضيع فيها ريال ولا يخصم مرتين، بالقيد المزدوج والمعاملات والمطابقة مع مزود الدفع.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتشغيل التجارب على Docker Engine 29.8.0 مع Foundry v1.8.6 (anvil وcast وforge) وSolc 0.8.36، وPython 3.13.17 لقراءة التتبع، ومراجعة الأثمان والتواريخ على ethereum.org وEIPs.
Could not load content
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.