إذا جربت أن ترسل أول معاملاتك على 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، وهي لا تعيد الحقول فقط، وإنما تعيد معها الهاشات التي تثبت أن هذا الحساب موجود فعلاً في حالة الكتلة، وسوف نعود إليها في قسم الحالة.
والصورة التالية تجمع الطريق من المفتاح إلى العنوان، ونوعي الحساب بحقولهما، بنفس القيم التي ظهرت في المخرج:

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

آلة 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قدر الحد بالضبط، وسوف نرى أن هذا التقدير الدقيق له جانب خطر.
والصورة التالية تجمع أجزاء الآلة مع هذا التتبع:

الغاز Gas: لماذا يدفع كل سطر ثمنه؟
قد يبدو الغاز من الوهلة الأولى طريقة لجمع المال من المستخدمين، ولكن سببه الحقيقي تقني بحت. فآلة EVM لغة كاملة تورنغ Turing Complete، أي أنها تستطيع أن تنفذ أي برنامج، ومنها برامج لا تنتهي، ولا توجد طريقة عامة لمعرفة هل سينتهي برنامج ما قبل تشغيله، وهذه هي مسألة التوقف Halting Problem المعروفة. فإذا كانت كل عقدة في العالم ستنفذ كود أي شخص، فما الذي يمنع أحداً من نشر حلقة لا نهائية توقف الشبكة كلها؟ والإجابة هي الغاز: لكل تعليمة ثمن ثابت بوحدات الغاز، ولكل معاملة حد أقصى يحدده المرسل ويدفع ثمنه مقدماً، فإذا نفد الغاز توقف التنفيذ مهما كان الكود، وبالتالي لا توجد حلقة لا نهائية، وإنما توجد حلقة يدفع صاحبها ثمن كل دورة فيها.
والسبب الثاني أن الغاز يقيس الحمل الحقيقي على العقد، وليس الوقت فقط، فالكتابة في التخزين الدائم تكلف كثيراً لأن كل عقدة ستحفظ الخانة إلى الأبد، وقراءة حساب لم يلمسه أحد في هذه المعاملة تكلف أكثر من قراءته مرة ثانية لأن العقدة تحتاج أن تجلبه من القرص. وإذا كانت هذه الأثمان غير دقيقة يجد المهاجمون التعليمة الأرخص من حقيقتها ويستخدمونها لإبطاء الشبكة، وهذا ما حدث فعلاً.
تجربة: نفس الدالة بأثمان مختلفة
الآن نجيب عن الملاحظة الثانية بالأرقام. السكربت التالي ينشر العقد، ثم يرسل تحويلين بسيطين، ثم يستدعي 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 ثم 7 | 45311 | 22100 | إنشاء خانة جديدة: 20000، مع 2100 للوصول الأول إليها في المعاملة |
set(8) | 7 ثم 8 | 28211 | 5000 | تعديل خانة موجودة: 2900، مع 2100 للوصول الأول |
set(8) مرة ثانية | 8 ثم 8 | 25411 | 2200 | لا يوجد تغيير: ثمن القراءة فقط 100، مع 2100 للوصول الأول |
set(0) | 8 ثم 0 | 23399 | 5000 ثم يسترد 4800 | مسح الخانة يعيد جزءاً من الغاز في آخر المعاملة |
وكل الأرقام في الجدول تتسق مع المخرج: 45311 = 21204 + 22100 + 2007، حيث 2007 هي بقية التنفيذ من حدث LOG2 وتعليمات الحساب، وهي نفس 1381 + 626 التي رأيناها في التتبع. و25411 = 21204 + 2200 + 2007. أما set(0) فكلفت قبل الاسترداد 28199، ثم استردت 4800، والاسترداد Refund محدود منذ EIP-3529 بخمس الغاز المستهلك، وخمس 28199 أكبر من 4800 فاسترد الرقم كاملاً. وقبل هذا التحديد كانت هناك عقود تكتب خانات وقت رخص الرسوم ثم تمسحها وقت ارتفاعها لتسترد الغاز، فكان الاسترداد نفسه وسيلة للمضاربة على الغاز وتضخيم حجم الحالة، فحددوه.
والصورة التالية تبين كل معاملة مقسمة إلى أجزائها:

وما الذي يعنيه ذلك لفريق المنصة؟ أن تصميم المتغيرات في العقد قرار مالي وليس قراراً جمالياً، فعقد الضمان الذي ينشئ خانة جديدة لكل مشروع يدفع 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 التي وضعناها حداً أقصى، فعاد الفرق تلقائياً.
والصورة التالية ترسم نفس الأرقام:

والمثال العددي في توثيق الغاز يلخص نفس الحساب: تحويل من 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.
والصورة التالية تبين كيف ترتبط هذه الجذور ببعضها:

والبنية التي تربط الجذر بالحسابات اسمها شجرة Merkle Patricia Trie، وهي مزيج من شجرة Merkle التي بنيناها في مقال البلوكتشين للمبتدئين وشجرة البحث بالبادئة Patricia Trie، وتفاصيلها في توثيق الشجرة. والفائدة العملية التي تهم فريق المنصة هي eth_getProof: عندما تطلب حساباً وخانة من مزود RPC لا تثق به، تستطيع أن تطلب معها الإثبات Proof، وهو الهاشات التي على الطريق من الخانة إلى stateRoot، فتتحقق بنفسك أن القيمة صحيحة إذا كان عندك رأس الكتلة من مصدر موثوق، وهذه هي الفكرة التي تقوم عليها العملاء الخفيفة Light Clients.
الشبكة اليوم: كتلة كل 12 ثانية وترقيات منتظمة
كل ما جربناه على Anvil يحدث على الشبكة الرئيسية بنفس القواعد، والفرق في الإيقاع والحجم: كتلة في كل خانة مدتها 12 ثانية يقترحها مدقق واحد كما شرحنا في مقال الإجماع، وحد غاز للكتلة يتغير بتصويت المدققين وبالترقيات، ورسوم أساسية تتحرك مع الطلب الحقيقي. والعقدة الكاملة اليوم برنامجان يعملان معاً، عميل تنفيذ Execution Client فيه آلة EVM والحالة، وعميل إجماع Consensus Client يتابع المدققين، كما يشرح توثيق العقد والعملاء. والقواعد نفسها تتغير بالترقيات، وأغلب الأثمان التي رأيناها في هذا المقال جاءت من ترقية معينة، والجدول التالي يجمع الترقيات التي ذكرناها بتواريخها من السجل الرسمي:
| الترقية | التاريخ | ما يهمنا منها في هذا المقال |
|---|---|---|
| Frontier | 30 يوليو 2015 | إطلاق الشبكة الأولى |
| Tangerine Whistle | 18 أكتوبر 2016، الكتلة 2,463,000 | EIP-150: رفع أثمان تعليمات قراءة الحالة بعد هجوم DoS |
| Spurious Dragon | 22 نوفمبر 2016، الكتلة 2,675,000 | EIP-155: رقم الشبكة chainId تحت التوقيع |
| Berlin | 15 أبريل 2021، الكتلة 12,244,000 | EIP-2929: ثمن الوصول الأول Cold Access |
| London | 5 أغسطس 2021، الكتلة 12,965,000 | EIP-1559: الرسوم الأساسية والحرق، وEIP-3529: تحديد الاسترداد |
| The Merge | 15 سبتمبر 2022 | الانتقال إلى إثبات الحصة |
| Dencun | 13 مارس 2024، الكتلة 19,426,587 | EIP-4844: معاملات Blobs، وEIP-1153: التخزين المؤقت |
| Pectra | 7 مايو 2025، الكتلة 22,431,084 | EIP-7702: تفويض الحساب الخارجي، وEIP-7623: الحد الأدنى لثمن البيانات |
| Fusaka | 3 ديسمبر 2025، الكتلة 23,935,694 | EIP-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% في كل مرة | ضع حداً أقصى معقولاً وانتظر، أو أجل العمليات غير العاجلة |
| دالة تقترب من حد الغاز | حلقة على بيانات تكبر | اجعل كل مستفيد يسحب بنفسه، وقسم العمل على عدة معاملات |
وقبل أن تنشر أي عقد يحمل قيمة حقيقية، تحقق من التالي:
- كل المبالغ في الكود وقاعدة البيانات أعداد صحيحة بوحدة Wei، أو بأصغر وحدة للرمز Token.
- لا توجد دالة
payableإلا إذا كانت تستقبل المال فعلاً، ولكل مال يدخل العقد طريق معروف للخروج. - لا توجد حلقة يتحكم المستخدم في عدد دوراتها، أو يكبر عددها مع البيانات دون حد.
- جربت كل دالة في حالتي الخانة الفارغة والممتلئة، وتعرف أعلى غاز تحتاجه كل دالة.
- نظام الإرسال يدير العداد nonce في مكان واحد، ويضع هامشاً فوق تقدير الغاز، ويراقب المعاملات المعلقة والفاشلة.
- تعرف رقم الشبكة chainId التي تعمل عليها، ولا يوجد مفتاح حقيقي في أي ملف أو مستودع.
- راجعت الجانب النظامي لأي استخدام يتعامل مع أموال حقيقية من مصادره الرسمية قبل أي قرار تقني.
تدريب عملي
- أضف إلى العقد Store متغيراً ثانياً من نوع
uint128بجانب ثالث من نفس النوع، ثم قارن غاز كتابتهما معاً بغاز كتابة متغيرين من نوعuint256، وفسر الفرق بعدد الخانات التي تكتبهاSSTORE. - في تجربة الرسوم الأساسية غير حد الغاز إلى 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.