انتهى المقال السابق بعقد ضمان يعمل، وبقائمة طويلة من المخاطر، وبحالة واحدة يعلق فيها المال إلى الأبد إذا اختلف العميل والمستقل بعد التسليم. فأضاف فريق منصة العمل الحر حكماً Arbiter يفصل في النزاع، وأعاد ترتيب دوال الدفع في دالة واحدة، وأصبح السؤال: كيف نعرف أن هذا التعديل لم يفسد شيئاً آخر؟ والطريقة التي جربناها حتى الآن، أي نشر العقد على Anvil ثم إرسال المعاملات بالأمر cast send واحدة بعد الأخرى، تصلح لنفهم كيف يعمل العقد، ولكنها لا تصلح لنتأكد أنه يعمل، لأنها تختبر ما خطر على بالنا فقط، وتحتاج أن نعيدها بأيدينا بعد كل تعديل. وفي التطبيقات العادية يكتشف الفريق الخطأ في الإنتاج فيصلحه وينشر نسخة جديدة في نفس اليوم، أما هنا فالخطأ بعد النشر يعني عقداً جديداً على عنوان جديد، وربما مالاً لا يعود.
وهذا المقال تعليمي تقني فقط، ولا يتضمن أي نصيحة استثمارية أو مالية، وكل التجارب فيه تعمل على جهازك بحسابات اختبار ليس لها قيمة. وهو المقال الثالث في مرحلة Ethereum، وقد شرحنا قبله كيف يعمل Ethereum من الداخل، ثم كتبنا عقد الضمان في مقال العقود الذكية، وهنا ننقله إلى بيئة تطوير كاملة.
وسوف نناقش في هذا المقال ما يلي:
- لماذا يحتاج تطوير العقود أدوات خاصة، وما الخيارات المعروفة، ولماذا اخترنا Foundry وHardhat.
- العقد بعد إضافة الحكم، وملاحظة جديدة من المدقق الآلي ظهرت بسبب إعادة الهيكلة.
- اختبارات الوحدة Unit Tests بلغة Solidity، وأدوات الغش Cheatcodes، والخطأ الذي ارتكبناه في الاختبار نفسه وكيف قرأناه.
- الاختبار العشوائي Fuzz Testing، واختبار الثوابت Invariant Testing، ونجاح كاذب كشفناه، وخطأ زرعناه فوجده الاختبار في ثلاث خطوات.
- تقرير الغاز Gas Report ولقطة الغاز Gas Snapshot وتغطية الكود Coverage، وما لا تقوله التغطية.
- سكربت النشر في Foundry: التجربة ثم البث، وأين تضع المفتاح.
- نفس العقد في Hardhat 3: اختبارات بلغة TypeScript مع viem، ونشر مع Ignition، ثم مقارنة الأداتين وقائمة تحقق.
لماذا يحتاج العقد الذكي أدوات خاصة؟
قد تبدو كتابة الاختبارات للعقد الذكي مثل كتابتها لأي كود، ولكن هناك أربعة فروق تجعل الأدوات العادية غير كافية:
- الخطأ لا يصلح: كما رأينا في المقال السابق، الكود على عنوانه لا يتغير، والترقية نفسها تضيف مخاطر، لذلك يجب أن يكون الاختبار قبل النشر أعمق بكثير مما تفعله في تطبيق ويب.
- المستخدم قد يكون خصماً: في تطبيق الويب تختبر ما يفعله المستخدم العادي، أما العقد فيستدعيه أي شخص بأي ترتيب وبأي قيم، ومنهم من يبحث عن الخطأ عن قصد لأن الخطأ يعني مالاً. لذلك تحتاج اختبارات تجرب قيماً وتسلسلات لم تفكر فيها.
- الحالة والوقت جزء من المنطق: عقد الضمان يتصرف بحسب من يستدعيه، وكم أرسل، ومتى، وفي أي حالة، فتحتاج أداة تستطيع أن تغير المرسل والرصيد والساعة في سطر واحد.
- كل سطر له ثمن: الغاز جزء من التصميم كما رأينا، فتحتاج أن تعرف ثمن كل دالة قبل النشر، وأن تلاحظ إذا ارتفع بعد أي تعديل.
والأدوات التي تجمع هذه الاحتياجات تسمى بيئات تطوير العقود، وأشهرها اليوم اثنتان مفتوحتا المصدر: Foundry، وهي مجموعة برامج مكتوبة بلغة Rust برخصة MIT أو Apache-2.0، تكتب فيها الاختبارات بلغة Solidity نفسها، وقد استخدمنا منها anvil وcast وforge في المقالين السابقين، وHardhat، وهي بيئة تعمل على Node.js برخصة MIT، وتكتب فيها الاختبارات بلغة TypeScript أو Solidity. وهناك أيضاً Remix، وهي بيئة تعمل في المتصفح ومناسبة جداً للتعلم وتجربة عقد صغير، ولكنها لا تناسب مشروعاً فيه اختبارات آلية وتحكم بالنسخ Version Control وتكامل مستمر CI. وسوف نأخذ نفس العقد إلى الأداتين، حتى تكون المقارنة في آخر المقال مبنية على تجربة وليس على وصف.
العقد بعد إضافة الحكم Arbiter
التعديل الذي أضافه الفريق صغير في ظاهره: متغير ثالث ثابت هو عنوان الحكم، ودالة resolve لا يستدعيها إلا الحكم وفي حالة Delivered فقط، تصرف للمستقل أو تعيد للعميل. ولأن الدفع أصبح في ثلاث دوال، نقل الفريق منطق الدفع إلى دالة خاصة واحدة. والنسخة الأولى من هذه الدالة الخاصة كانت تأخذ عنوان المستقبل والحالة الجديدة كوسيطين:
function _pay(address to, State next) private {
state = next;
if (next == State.Released) emit Released(to, amount);
else emit Refunded(to, amount);
(bool ok, ) = to.call{value: amount}("");
require(ok, "transfer failed");
}نشغل Anvil مع مجلد التجربة كما في المقالين السابقين، ثم نبني المشروع في المجلد lab/foundry الذي سوف نشرح ملفاته بعد قليل:
docker run -d --name lab-dev -p 127.0.0.1:8545:8545 -v "$PWD/lab:/lab" -w /lab \
ghcr.io/foundry-rs/foundry:v1.8.6 "anvil --host 0.0.0.0"
docker exec -w /lab/foundry lab-dev forge buildوالمخرج سوف يكون كما يلي:
Compiling 1 files with Solc 0.8.36
Solc 0.8.36 finished in 16.48ms
Compiler run successful!
warning[arbitrary-send-eth]: ETH is sent to a user-controlled destination; restrict the destination or the caller
╭▸ src/Escrow.sol:81:23
│
81 │ (bool ok, ) = to.call{value: amount}("");
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━
│
╰ help: https://getfoundry.sh/forge/linting/arbitrary-send-ethهذه الملاحظة لم تظهر في النسخة السابقة من العقد، وظهرت فقط بعد إعادة الهيكلة. والمدقق الآلي لا يستطيع أن يعرف أن to في كل الاستدعاءات هو المستقل أو العميل فقط، فكل ما يراه دالة ترسل المال إلى عنوان يأتي من وسيط. وقد يقول البعض إنها ملاحظة خاطئة لأن الدالة خاصة private، وهذا صحيح اليوم، ولكن بعد سنة قد يضيف أحد في الفريق استدعاءً جديداً لهذه الدالة بعنوان آخر دون أن ينتبه. لذلك بدل أن نسكت الملاحظة غيرنا التصميم حتى يصبح الأمر مستحيلاً: الدالة تأخذ قيمة منطقية واحدة، وتحسب العنوان بنفسها من المتغيرين الثابتين. وهذا هو العقد كاملاً في lab/foundry/src/Escrow.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
/// Escrow for one freelance job, with an arbiter for disputes.
/// The client funds it, the freelancer delivers, the client releases the money,
/// or takes it back after the deadline; if they disagree after delivery, the arbiter decides.
/// A teaching example for a local devnet, not audited.
contract Escrow {
enum State { Created, Funded, Delivered, Released, Refunded }
address public immutable client;
address public immutable freelancer;
address public immutable arbiter;
uint256 public immutable amount;
uint256 public immutable deadline;
State public state;
event Funded(uint256 amount);
event Delivered(string proof);
event Released(address indexed to, uint256 amount);
event Refunded(address indexed to, uint256 amount);
error NotClient();
error NotFreelancer();
error NotArbiter();
error WrongState(State current);
error WrongAmount(uint256 sent, uint256 expected);
error TooEarly(uint256 nowTime, uint256 deadline);
error ZeroAddress();
constructor(address _freelancer, address _arbiter, uint256 _amount, uint256 _days) {
if (_freelancer == address(0) || _arbiter == address(0)) revert ZeroAddress();
client = msg.sender;
freelancer = _freelancer;
arbiter = _arbiter;
amount = _amount;
deadline = block.timestamp + _days * 1 days;
}
function fund() external payable {
if (msg.sender != client) revert NotClient();
if (state != State.Created) revert WrongState(state);
if (msg.value != amount) revert WrongAmount(msg.value, amount);
state = State.Funded;
emit Funded(msg.value);
}
function deliver(string calldata proof) external {
if (msg.sender != freelancer) revert NotFreelancer();
if (state != State.Funded) revert WrongState(state);
state = State.Delivered;
emit Delivered(proof);
}
function release() external {
if (msg.sender != client) revert NotClient();
if (state != State.Delivered) revert WrongState(state);
_pay(true);
}
function refund() external {
if (msg.sender != client) revert NotClient();
if (state != State.Funded) revert WrongState(state);
// forge-lint: disable-next-line(block-timestamp)
if (block.timestamp < deadline) revert TooEarly(block.timestamp, deadline);
_pay(false);
}
/// The arbiter settles a dispute after delivery: pay the freelancer or return the money.
function resolve(bool payFreelancer) external {
if (msg.sender != arbiter) revert NotArbiter();
if (state != State.Delivered) revert WrongState(state);
_pay(payFreelancer);
}
/// Pays the whole amount to one of the two fixed parties; the destination is never an input.
function _pay(bool toFreelancer) private {
address to = toFreelancer ? freelancer : client;
state = toFreelancer ? State.Released : State.Refunded;
if (toFreelancer) emit Released(to, amount);
else emit Refunded(to, amount);
(bool ok, ) = to.call{value: amount}("");
require(ok, "transfer failed");
}
}وبعد هذا التغيير يكون مخرج forge build نظيفاً دون أي ملاحظة. ولاحظ أن الدالة الخاصة ما زالت تغير الحالة وتطلق الحدث قبل الإرسال كما تعلمنا في المقال السابق، وأن قاعدة «الحالة أولاً» أصبحت الآن في مكان واحد، وهذا جيد لأن أي خطأ فيها سوف يظهر في كل الدوال معاً، وسيء للسبب نفسه. وسوف نرى بعد قليل ماذا يحدث لو نسينا سطراً واحداً في هذه الدالة.
مشروع Foundry: الملفات والإعداد
المشروع في Foundry مجلد فيه ملف إعداد ومجلدات للعقود والاختبارات والسكربتات، والصورة التالية تبين شكل المشروعين اللذين سوف نبنيهما في هذا المقال جنباً إلى جنب:

وملف الإعداد lab/foundry/foundry.toml فيه إصدار المترجم، وعدد مرات الاختبار العشوائي، وإعدادات اختبار الثوابت:
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc = "0.8.36"
[fuzz]
runs = 1000
[invariant]
runs = 256
depth = 50والمكتبة الوحيدة التي نحتاجها هي forge-std، وفيها العقد الأساسي Test وأدوات الغش والتأكيدات Assertions، ونحملها بإصدار محدد:
docker exec -w /lab/foundry lab-dev forge install --no-git foundry-rs/[email protected]والمخرج سوف يكون كما يلي:
Installed forge-std v1.17.0ولماذا نثبت الإصدار بدل أن نأخذ الأحدث؟ لأن المكتبة التي تتغير بين تشغيلين قد تغير نتيجة الاختبار أو الكود الثنائي، وفي عقد سوف يحمل المال تريد أن تعرف بالضبط ما الذي اختبرته وما الذي نشرته.
اختبارات الوحدة Unit Tests بلغة Solidity
في Foundry يكون ملف الاختبار عقداً بلغة Solidity يرث من Test، وكل دالة يبدأ اسمها بالكلمة test اختبار مستقل، والدالة setUp تنفذ قبل كل اختبار على حالة جديدة نظيفة، كما يشرح توثيق الاختبار في Foundry. والفائدة الكبيرة أنك تكتب الاختبار بنفس لغة العقد، فلا تحتاج طبقة بين اللغتين، والاختبار يستدعي دوال العقد مباشرة كما يستدعيها أي عقد آخر. وهذا ملف الاختبار lab/foundry/test/Escrow.t.sol كما كتبناه أول مرة:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Test} from "forge-std/Test.sol";
import {Escrow} from "../src/Escrow.sol";
contract EscrowTest is Test {
Escrow escrow;
address client = makeAddr("client");
address freelancer = makeAddr("freelancer");
address arbiter = makeAddr("arbiter");
uint256 constant AMOUNT = 2 ether;
function setUp() public {
vm.deal(client, 10 ether);
vm.prank(client);
escrow = new Escrow(freelancer, arbiter, AMOUNT, 7);
}
function _fund() internal {
vm.prank(client);
escrow.fund{value: AMOUNT}();
}
function _deliver() internal {
vm.prank(freelancer);
escrow.deliver("https://example.com/jobs/17 sha256:9f2c");
}
function test_HappyPath() public {
_fund();
_deliver();
vm.prank(client);
escrow.release();
assertEq(freelancer.balance, AMOUNT);
assertEq(address(escrow).balance, 0);
assertEq(uint8(escrow.state()), uint8(Escrow.State.Released));
}
function test_RevertWhen_FreelancerFunds() public {
vm.deal(freelancer, AMOUNT);
vm.prank(freelancer);
vm.expectRevert(Escrow.NotClient.selector);
escrow.fund{value: AMOUNT}();
}
function test_RevertWhen_RefundBeforeDeadline() public {
_fund();
vm.prank(client);
vm.expectRevert(abi.encodeWithSelector(Escrow.TooEarly.selector, block.timestamp, escrow.deadline()));
escrow.refund();
}
function test_RefundAfterDeadline() public {
_fund();
vm.warp(escrow.deadline());
vm.prank(client);
escrow.refund();
assertEq(client.balance, 10 ether);
}
function test_ArbiterSettlesDispute() public {
_fund();
_deliver();
vm.prank(arbiter);
escrow.resolve(false);
assertEq(client.balance, 10 ether);
assertEq(uint8(escrow.state()), uint8(Escrow.State.Refunded));
}
function test_EmitsReleased() public {
_fund();
_deliver();
vm.expectEmit(true, false, false, true, address(escrow));
emit Escrow.Released(freelancer, AMOUNT);
vm.prank(client);
escrow.release();
}
/// Fuzz: any amount other than the agreed one must be refused
function testFuzz_RevertWhen_WrongAmount(uint256 sent) public {
vm.assume(sent != AMOUNT);
sent = bound(sent, 0, 10 ether);
vm.assume(sent != AMOUNT);
vm.prank(client);
vm.expectRevert(abi.encodeWithSelector(Escrow.WrongAmount.selector, sent, AMOUNT));
escrow.fund{value: sent}();
}
/// Fuzz: nobody but the arbiter can resolve
function testFuzz_RevertWhen_NotArbiter(address caller) public {
vm.assume(caller != arbiter);
_fund();
_deliver();
vm.prank(caller);
vm.expectRevert(Escrow.NotArbiter.selector);
escrow.resolve(true);
}
}والأسطر التي تبدأ بالحرف vm هي أدوات الغش Cheatcodes، وهي تعليمات خاصة لا توجد إلا في بيئة الاختبار، تعطي الاختبار قدرات لا يملكها أي عقد حقيقي:
makeAddr("client")ينشئ عنواناً ثابتاً من اسم مقروء، فتظهر الأسماء في رسائل الخطأ بدل أرقام طويلة.vm.deal(client, 10 ether)يضع في رصيد العنوان 10 ETH مباشرة، دون معاملة ولا مرسل.vm.prank(client)يجعل الاستدعاء التالي يصل إلى العقد وكأنclientهو المرسل، وبهذا نختبر كل دالة من جهة كل طرف.vm.warp(t)يغير وقت الكتلة، كما فعلنا بالطريقةevm_increaseTimeفي المقال السابق، ولكن دون انتظار ولا كتل.vm.expectRevert(...)يقول إن الاستدعاء التالي يجب أن يفشل بهذا الخطأ بالضبط، ومع الأخطاء المخصصة نستطيع أن نتحقق من القيم التي يحملها الخطأ أيضاً.vm.expectEmitثمemitيقولان إن الاستدعاء التالي يجب أن يطلق هذا الحدث بهذه القيم، والقيم المنطقية الأربع تحدد أي المواضيع والبيانات نقارن.
أول تشغيل: اختبار فشل، والخطأ ليس في العقد
نشغل الاختبارات:
docker exec -w /lab/foundry lab-dev forge testوالجزء المهم من المخرج كان كما يلي:
Ran 8 tests for test/Escrow.t.sol:EscrowTest
[PASS] testFuzz_RevertWhen_NotArbiter(address) (runs: 1000, μ: 121832, ~: 121832)
[PASS] testFuzz_RevertWhen_WrongAmount(uint256) (runs: 1000, μ: 43899, ~: 43493)
[PASS] test_ArbiterSettlesDispute() (gas: 140828)
[PASS] test_EmitsReleased() (gas: 137484)
[PASS] test_HappyPath() (gas: 138621)
[PASS] test_RefundAfterDeadline() (gas: 109393)
[PASS] test_RevertWhen_FreelancerFunds() (gas: 39703)
[FAIL: Error != expected error: NotClient() != TooEarly(1, 604801 [6.048e5])] test_RevertWhen_RefundBeforeDeadline() (gas: 93682)
Suite result: FAILED. 7 passed; 1 failed; 0 skipped; finished in 118.18ms (241.53ms CPU time)اختبار الاسترداد قبل الموعد توقع الخطأ TooEarly فجاءه NotClient، أي أن العقد رأى أن من طلب الاسترداد ليس العميل. والعقد هنا صحيح، فقد جربنا نفس الحالة بالأمر cast send في المقال السابق ورأينا TooEarly، فأين المشكلة؟ المشكلة في هذا السطر من الاختبار:
vm.prank(client);
vm.expectRevert(abi.encodeWithSelector(Escrow.TooEarly.selector, block.timestamp, escrow.deadline()));
escrow.refund();الأداة vm.prank تؤثر على الاستدعاء التالي فقط، وكما يقول توثيقها فإن الاستدعاء التالي يشمل استدعاءات القراءة Static Calls أيضاً. والاستدعاء التالي هنا لم يكن refund، وإنما escrow.deadline() داخل وسائط expectRevert، فاستهلكت قراءة الموعد أثر prank، ووصل refund من عقد الاختبار نفسه، فرفضه العقد بحق. ولاحظ أيضاً القيمتين في الخطأ المتوقع: الوقت 1 والموعد 604801، فبيئة الاختبار تبدأ الساعة من الثانية 1، والموعد بعد سبعة أيام منها. والإصلاح أن نقرأ الموعد قبل prank:
_fund();
uint256 deadline = escrow.deadline(); // read it before vm.prank, which applies to the next call only
vm.prank(client);
vm.expectRevert(abi.encodeWithSelector(Escrow.TooEarly.selector, block.timestamp, deadline));
escrow.refund();وبعد الإصلاح أصبح المخرج كما يلي:
Ran 8 tests for test/Escrow.t.sol:EscrowTest
[PASS] testFuzz_RevertWhen_NotArbiter(address) (runs: 1000, μ: 121832, ~: 121832)
[PASS] testFuzz_RevertWhen_WrongAmount(uint256) (runs: 1000, μ: 43723, ~: 43493)
[PASS] test_ArbiterSettlesDispute() (gas: 140828)
[PASS] test_EmitsReleased() (gas: 137484)
[PASS] test_HappyPath() (gas: 138621)
[PASS] test_RefundAfterDeadline() (gas: 109393)
[PASS] test_RevertWhen_FreelancerFunds() (gas: 39703)
[PASS] test_RevertWhen_RefundBeforeDeadline() (gas: 98168)
Suite result: ok. 8 passed; 0 failed; 0 skipped; finished in 120.21ms (233.85ms CPU time)والدرس من هذا الفشل أوسع من أداة prank: عندما يفشل اختبار فاقرأ رسالة الخطأ كاملة قبل أن تغير العقد، فالرسالة هنا قالت بالضبط من رفض ولماذا، ولو غيرنا العقد ليجعل الاختبار ينجح لأدخلنا خطأً حقيقياً لنصلح خطأً في الاختبار. وفي المخرج لاحظ أيضاً أن كل اختبار يطبع الغاز الذي استهلكه، وأن الاختبارات الثمانية انتهت في 120 مللي ثانية تقريباً، لأنها تعمل داخل آلة EVM في الذاكرة دون شبكة ولا كتل، وهذه السرعة هي التي تجعل تشغيلها بعد كل تعديل أمراً طبيعياً.
الاختبار العشوائي Fuzz Testing
الاختباران الأخيران في الملف مختلفان: لهما وسائط، sent وcaller، ولم نحدد قيمتهما. وعندما يجد Foundry دالة اختبار لها وسائط يعتبرها اختباراً عشوائياً، كما يشرح توثيق الاختبار العشوائي، فيستدعيها مرات كثيرة بقيم يولدها، ومنها القيم الحدية المعروفة مثل الصفر وأكبر رقم ممكن، وقد جعلناها 1000 مرة في ملف الإعداد. والسؤال الذي يجيب عنه الاختبار العشوائي ليس «هل يعمل العقد مع القيمة 1 ETH؟» وإنما «هل القاعدة صحيحة مع أي قيمة؟»، وهنا القاعدتان: أي مبلغ غير المتفق عليه مرفوض، وأي عنوان غير الحكم لا يستطيع الفصل في النزاع.
وفي الكود أعلاه أداتان مهمتان للتحكم في القيم العشوائية:
bound(sent, 0, 10 ether)يحول أي رقم عشوائي إلى رقم داخل المجال، فلا يضيع الاختبار في قيم مثل 10 أس 70 لا يملكها أحد. والفرق بينها وبينvm.assumeأنassumeيرفض القيمة ويطلب غيرها، فإذا رفض كثيراً فشل الاختبار لأنه لم يجد قيماً كافية، أماboundفيحول القيمة ولا يرفضها.vm.assume(sent != AMOUNT)قبل التحويل وبعده، لأن الرقم المحول قد يصبح 2 ETH بالصدفة، وهو المبلغ الصحيح الذي لا يجب أن يفشل.
والمخرج يبين runs: 1000 لكل اختبار، ومتوسط الغاز وأوسطه، وكلاهما نجح. ولكن لاحظ حدود هذا النوع: كل تشغيل استدعاء واحد في حالة واحدة، فهو لا يجرب تسلسلات مثل «مول ثم استرد ثم سلم»، وهذه هي التسلسلات التي تخفي أخطر أخطاء العقود.
اختبار الثوابت Invariant Testing
الثابت Invariant قاعدة يجب أن تبقى صحيحة مهما كان ترتيب الاستدعاءات، وكما يعرفه دليل اختبار الثوابت فإنه يتحقق من خصائص يجب أن تكون صحيحة دائماً بغض النظر عن تسلسل الأفعال، فيستدعي Foundry دوال عشوائية بترتيب عشوائي، ويتحقق من الثوابت بعد كل استدعاء. ولعقد الضمان كتبنا ثابتين يعبران عن أهم ما يعد به العقد:
- الرصيد يطابق الحالة: في الحالتين
FundedوDeliveredيحمل العقد المبلغ المتفق عليه بالضبط، وفي غيرهما لا يحمل شيئاً. - المال يخرج مرة واحدة: مجموع ما خرج من العقد لا يتجاوز المبلغ أبداً.
ولو تركنا Foundry يستدعي العقد مباشرة بقيم عشوائية لضاعت أغلب الاستدعاءات، لأن المرسل عنوان عشوائي لا يملك صلاحية أي دالة. لذلك يوصي الدليل بعقد وسيط اسمه Handler، يقيد المدخلات إلى مجالات مفيدة، ويختار المرسل من الأطراف الحقيقية، ويحفظ متغيرات تتبع اسمها المتغيرات الشبحية Ghost Variables، وهي قيم لا يحفظها العقد نفسه ولكننا نحتاجها لنتحقق منه، مثل مجموع ما خرج. وهذا الملف lab/foundry/test/Escrow.invariant.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Test, console2} from "forge-std/Test.sol";
import {Escrow} from "../src/Escrow.sol";
/// The handler is the only thing the fuzzer calls: random sequences of fund, deliver, release,
/// refund, resolve and time jumps, each made by the right or a random caller.
contract Handler is Test {
Escrow public escrow;
address public client;
address public freelancer;
address public arbiter;
uint256 public paidOut;
mapping(bytes32 => uint256) public ok; // ghost counters: how many calls of each kind succeeded
constructor(Escrow e, address c, address f, address a) {
escrow = e; client = c; freelancer = f; arbiter = a;
}
function _who(uint256 seed) internal view returns (address) {
address[4] memory people = [client, freelancer, arbiter, address(0xBEEF)];
return people[seed % 4];
}
function fund(uint256 seed, uint256 value) external {
// half of the calls send exactly the agreed amount, or the fuzzer almost never funds the job
value = value % 2 == 0 ? 2 ether : bound(value, 0, 3 ether);
address who = _who(seed);
vm.deal(who, who.balance + value);
vm.prank(who);
try escrow.fund{value: value}() { ok["fund"]++; } catch {}
}
function deliver(uint256 seed) external {
vm.prank(_who(seed));
try escrow.deliver("proof") { ok["deliver"]++; } catch {}
}
function release(uint256 seed) external {
uint256 before = address(escrow).balance;
vm.prank(_who(seed));
try escrow.release() { paidOut += before; ok["release"]++; } catch {}
}
function refund(uint256 seed) external {
uint256 before = address(escrow).balance;
vm.prank(_who(seed));
try escrow.refund() { paidOut += before; ok["refund"]++; } catch {}
}
function resolve(uint256 seed, bool pay) external {
uint256 before = address(escrow).balance;
vm.prank(_who(seed));
try escrow.resolve(pay) { paidOut += before; ok["resolve"]++; } catch {}
}
function warp(uint256 secondsAhead) external {
vm.warp(block.timestamp + bound(secondsAhead, 0, 10 days));
}
}
contract EscrowInvariantTest is Test {
Escrow escrow;
Handler handler;
uint256 constant AMOUNT = 2 ether;
function setUp() public {
address client = makeAddr("client");
vm.prank(client);
escrow = new Escrow(makeAddr("freelancer"), makeAddr("arbiter"), AMOUNT, 7);
handler = new Handler(escrow, client, makeAddr("freelancer"), makeAddr("arbiter"));
targetContract(address(handler));
}
/// The contract holds exactly the agreed amount while the job is open, and nothing otherwise.
function invariant_BalanceMatchesState() public view {
Escrow.State s = escrow.state();
if (s == Escrow.State.Funded || s == Escrow.State.Delivered) {
assertEq(address(escrow).balance, AMOUNT);
} else {
assertEq(address(escrow).balance, 0);
}
}
/// After each run: how far did the random sequence really get?
function afterInvariant() public view {
console2.log("fund ok", handler.ok("fund"), "deliver ok", handler.ok("deliver"));
console2.log("release ok", handler.ok("release"), "refund ok", handler.ok("refund"));
}
/// Money leaves the contract at most once.
function invariant_PaidAtMostOnce() public view {
assertLe(handler.paidOut(), AMOUNT);
}
}في الكود أعلاه لاحظ التالي:
targetContract(address(handler))يقول لـ Foundry أن يستدعي دوال الوسيط فقط، والوسيط هو الذي يستدعي العقد، والدالةwarpفيه تحرك الساعة حتى عشرة أيام حتى يصل الاختبار إلى ما بعد الموعد.- كل استدعاء للعقد داخل
tryوcatch، لأن أغلب الاستدعاءات العشوائية سوف ترفض، مثل أن يحاول المستقل أن يمول، والرفض هنا سلوك صحيح وليس فشلاً للاختبار. - الدالة
afterInvariantتنفذ في نهاية كل تشغيل بعد أن تنجح الثوابت، كما يشرح الدليل، ونستخدمها لنطبع العدادات، وسوف نرى بعد قليل لماذا أضفناها.
نجاح لا يعني شيئاً
في النسخة الأولى من الوسيط كان سطر المبلغ في الدالة fund هكذا:
value = bound(value, 0, 3 ether);وكانت النتيجة نجاح الثابتين في 256 تشغيلاً، وفي كل تشغيل 50 استدعاءً، أي 12800 استدعاء لكل ثابت. ولكن هذا الرقم لم يطمئننا، فسؤال واحد بقي دون إجابة: كم مرة وصل العقد فعلاً إلى حالة فيها مال؟ فأضفنا العدادات والدالة afterInvariant، وشغلنا الاختبار مع الخيار -vv الذي يطبع السجلات:
docker exec -w /lab/foundry lab-dev forge test --match-contract Invariant -vvوالمخرج كان كما يلي:
EscrowInvariantTest invariants:
[PASS] invariant_BalanceMatchesState
[PASS] invariant_PaidAtMostOnce
EscrowInvariantTest invariants (runs: 256, calls: 12800, reverts: 0)
╭----------+----------+-------+---------+----------╮
| Contract | Selector | Calls | Reverts | Discards |
+==================================================+
| Handler | deliver | 2140 | 0 | 0 |
|----------+----------+-------+---------+----------|
| Handler | fund | 2137 | 0 | 0 |
|----------+----------+-------+---------+----------|
| Handler | refund | 2206 | 0 | 0 |
|----------+----------+-------+---------+----------|
| Handler | release | 2111 | 0 | 0 |
|----------+----------+-------+---------+----------|
| Handler | resolve | 2123 | 0 | 0 |
|----------+----------+-------+---------+----------|
| Handler | warp | 2083 | 0 | 0 |
╰----------+----------+-------+---------+----------╯
Logs:
fund ok 0 deliver ok 0
release ok 0 refund ok 0الجدول يقول إن الوسيط استدعى fund أكثر من ألفي مرة، والعداد يقول إن التمويل لم ينجح ولا مرة في هذا التشغيل. والسبب بسيط عندما تراه: العقد لا يقبل إلا 2 ETH بالضبط، أي 2 × 10 أس 18 Wei، والرقم العشوائي المحول إلى مجال من صفر إلى 3 ETH نادراً ما يكون هذا الرقم بالضبط. فبقي العقد في حالة Created طوال الوقت، ورصيده صفر، والثابتان صحيحان لأن العقد لم يتحرك أصلاً. ولاحظ أيضاً عمود Reverts، فهو صفر لأن الوسيط يلتقط كل رفض بالكلمة catch، فالجدول لا يقول لك كم استدعاء رفضه العقد، والعدادات هي التي تقول.
وهذا أخطر ما في الاختبارات العشوائية: النجاح الذي لا يختبر شيئاً يبدو تماماً مثل النجاح الحقيقي. والإصلاح كان السطر الذي رأيته في الكود: نصف الاستدعاءات ترسل المبلغ المتفق عليه بالضبط، والنصف الآخر قيماً عشوائية. وبعده أصبح سطر العدادات في آخر تشغيل fund ok 1 وrefund ok 1، أي أن التسلسلات أصبحت تصل إلى التمويل والاسترداد. والقاعدة التي خرجنا بها: لا تثق بثابت نجح حتى تعرف أن التسلسلات وصلت إلى الحالات التي يحميها.
ماذا يحدث إذا نسينا سطراً؟ نزرع خطأ ونرى
الآن نختبر الاختبار نفسه: نحذف سطر تغيير الحالة من الدالة الخاصة _pay، وكأن أحداً في الفريق حذفه أثناء إعادة الهيكلة، ثم نشغل اختبارات الثوابت، ثم نعيد السطر:
docker exec -w /lab/foundry lab-dev bash -c '
cp src/Escrow.sol /tmp/Escrow.sol.bak
sed -i "s| state = toFreelancer ? State.Released : State.Refunded;| // state = toFreelancer ? State.Released : State.Refunded; (the bug: we forgot this line)|" src/Escrow.sol
forge test --match-contract Invariant
cp /tmp/Escrow.sol.bak src/Escrow.sol'والمخرج سوف يكون كما يلي، وقد حذفنا منه جدول الاستدعاءات وتكرار نفس الملخص في آخره:
Ran 1 test for test/Escrow.invariant.t.sol:EscrowInvariantTest
[FAIL: assertion failed: 0 != 2000000000000000000] invariant_BalanceMatchesState
[Sequence] (original: 42, shrunk: 3)
sender=0x0ca13ea83C777F236F66376c580693f2b59Eb84d addr=[test/Escrow.invariant.t.sol:Handler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=fund(uint256,uint256) args=[0, 52670383448186445861358136120353180461094388504232064340117296047664696131584 [5.267e76]]
sender=0x0000000000000000000000000000000000001f0E addr=[test/Escrow.invariant.t.sol:Handler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=warp(uint256) args=[6575803418568124237962872774790878336725315206556391586 [6.575e54]]
sender=0x246804f5E5E872353361a398539cD24e80B12bAb addr=[test/Escrow.invariant.t.sol:Handler]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=refund(uint256) args=[0]
EscrowInvariantTest invariants: 1/2 invariants broken
[FAIL: assertion failed: 0 != 2000000000000000000] invariant_BalanceMatchesState
[PASS] invariant_PaidAtMostOnce
EscrowInvariantTest invariants (runs: 256, calls: 12800, reverts: 0)
Fuzz seed: 0x9633ea7a5b94633ca69b8597e1531ca052af59c9f0cfedc8c300d63373f3cf70 (use `--fuzz-seed` to reproduce)في المخرج أعلاه لاحظ التالي:
- الثابت الأول فشل لأن رصيد العقد 0 بينما الحالة تقول إن فيه 2 ETH، أي أن العقد أرسل المال ولم يغير حالته.
- التسلسل الأصلي الذي وجد الخطأ كان 42 استدعاءً، ثم قلصه Foundry Shrinking إلى 3 فقط، وهو أقصر تسلسل يعيد الخطأ:
fundبالبذرة 0 أي من العميل، ورقم زوجي أي 2 ETH، ثمwarpيحرك الساعة إلى ما بعد الموعد، ثمrefundمن العميل. وهذا التقليص هو ما يجعل الفشل قابلاً للفهم، فقراءة 42 خطوة عشوائية مهمة شاقة. - الثابت الثاني نجح، لأن الاسترداد الثاني يفشل عند الإرسال، فالعقد لم يعد فيه مال. ولكن في عقد يحمل أموال عدة مشاريع في نفس الرصيد كان الاسترداد الثاني سوف ينجح من مال غيره، فالخطأ خطير حتى لو لم يظهر هنا كسرقة.
- البذرة Fuzz Seed في السطر الأخير تسمح لك أن تعيد نفس التسلسل بالضبط بالخيار
--fuzz-seed، وهذا مهم عندما يظهر الفشل في التكامل المستمر ولا يظهر على جهازك. - والأهم: اختبارات الوحدة الثمانية كانت ستنجح كلها مع هذا الخطأ، لأن كل واحد منها ينفذ مساراً واحداً ثم يتحقق من الأرصدة، ولا أحد منها يستدعي الاسترداد ثم يقرأ الحالة بعده.
والصورة التالية تجمع الخطأ والتسلسل:

والصورة التالية تضع الأنواع الثلاثة من الاختبارات جنباً إلى جنب، وما يسأله كل نوع:

الغاز والتغطية Coverage
بعد أن نجحت الاختبارات نطلب ثلاثة تقارير في أمر واحد: تقرير الغاز لكل دالة، ولقطة الغاز التي تحفظ في ملف، وتقرير التغطية:
docker exec -w /lab/foundry lab-dev bash -c 'forge test --gas-report; forge snapshot; forge coverage --report summary'وجزء تقرير الغاز الخاص بالعقد كان كما يلي:
| src/Escrow.sol:Escrow Contract | | | | | |
+=====================================================================================+
| Deployment Cost | Deployment Size | | | | |
|--------------------------------+-----------------+-------+--------+-------+---------|
| 0 | 5181 | | | | |
|--------------------------------+-----------------+-------+--------+-------+---------|
| Function Name | Min | Avg | Median | Max | # Calls |
|--------------------------------+-----------------+-------+--------+-------+---------|
| deadline | 370 | 370 | 370 | 370 | 2 |
|--------------------------------+-----------------+-------+--------+-------+---------|
| deliver | 22330 | 23729 | 22330 | 30034 | 2393 |
|--------------------------------+-----------------+-------+--------+-------+---------|
| fund | 21318 | 26986 | 23906 | 44885 | 2679 |
|--------------------------------+-----------------+-------+--------+-------+---------|
| refund | 21343 | 23178 | 23830 | 35420 | 2119 |
|--------------------------------+-----------------+-------+--------+-------+---------|
| release | 21365 | 23356 | 23954 | 37908 | 2080 |
|--------------------------------+-----------------+-------+--------+-------+---------|
| resolve | 21667 | 22097 | 21679 | 38224 | 2484 |
|--------------------------------+-----------------+-------+--------+-------+---------|
| state | 2641 | 2641 | 2641 | 2641 | 3 |
╰--------------------------------+-----------------+-------+--------+-------+---------╯في المخرج أعلاه لاحظ التالي:
- العمود
# Callsفيه آلاف الاستدعاءات، لأن التقرير يحسب استدعاءات الاختبارات العشوائية والثوابت أيضاً، ولذلك القيمة الدنيا Min لكل دالة قرابة 21300، وهي استدعاءات رفضت في أول سطر، أما القيمة العليا Max فهي المسار الناجح الكامل. - أعلى قيمة للدالة
fundهي 44885، وهو نفس الرقم الذي قسناه على Anvil في المقال السابق، لأن الكتابة من صفر إلى قيمة لم تتغير. أماreleaseفأصبحت 37908 بدل 37778، أي أن تعديلات هذا المقال، الحكم وإعادة الهيكلة، أضافت 130 وحدة غاز تقريباً لكل صرف، وهذا ثمن صغير مقابل حالة نزاع لها مخرج ومستقبل لا يمكن تغييره. - حجم النشر 5181 بايت لأن العقد أصبح فيه متغير ثالث ودالة جديدة. أما تكلفة النشر فظهرت صفراً، لأن العقد في اختباراتنا ينشر داخل
setUpبالأمرnew، ولذلك سوف نعتمد على سكربت النشر لنعرف تكلفته الحقيقية.
والأمر forge snapshot يكتب غاز كل اختبار في الملف .gas-snapshot، وهذا جزء منه:
EscrowTest:test_ArbiterSettlesDispute() (gas: 140828)
EscrowTest:test_EmitsReleased() (gas: 137484)
EscrowTest:test_HappyPath() (gas: 138621)
EscrowTest:test_RefundAfterDeadline() (gas: 109393)
EscrowTest:test_RevertWhen_FreelancerFunds() (gas: 39703)
EscrowTest:test_RevertWhen_RefundBeforeDeadline() (gas: 98168)وفائدته أن تحفظه في المستودع مع الكود، فأي طلب دمج Pull Request يغير هذا الملف يظهر في المراجعة، ويرى المراجع أن تعديلاً بسيطاً رفع تكلفة الصرف مثلاً، كما يشرح توثيق تقارير الغاز. وأما تقرير التغطية فكان كما يلي:
╭-----------------------------+-----------------+-----------------+----------------+-----------------╮
| File | % Lines | % Statements | % Branches | % Funcs |
+====================================================================================================+
| src/Escrow.sol | 100.00% (38/38) | 97.92% (47/48) | 90.48% (19/21) | 100.00% (7/7) |
|-----------------------------+-----------------+-----------------+----------------+-----------------|
| test/Escrow.invariant.t.sol | 100.00% (28/28) | 100.00% (32/32) | 100.00% (7/7) | 100.00% (8/8) |
|-----------------------------+-----------------+-----------------+----------------+-----------------|
| Total | 100.00% (66/66) | 98.75% (79/80) | 92.86% (26/28) | 100.00% (15/15) |
╰-----------------------------+-----------------+-----------------+----------------+-----------------╯كل الأسطر في العقد نفذت، ولكن فرعين من 21 لم ينفذا، وعبارة واحدة من 48. ولكي نعرف أيها نطلب التقرير المفصل بالخيار --report debug ونبحث فيه عن الأسطر التي لم تنفذ:
docker exec -w /lab/foundry lab-dev bash -c 'forge coverage --report debug | grep -B1 -A40 "src/Escrow.sol" | grep -i "hits: 0"'والمخرج سوف يكون كما يلي:
- Statement (location: (source ID: 20, lines: 32..33, bytes: 1238..1258), hits: 0) -> "revert ZeroAddress()"
- Branch (branch: 0, path: 0) (location: (source ID: 20, lines: 32..33, bytes: 1181..1258), hits: 0) -> "if (_freelancer == address(0) ||...address(0)) revert ZeroAddress()"أي أننا لم نكتب أي اختبار ينشر العقد بعنوان صفري، فالحماية التي أضفناها في المقال السابق بعد ملاحظة المدقق الآلي لم يختبرها أحد. وهذا ما تقدمه التغطية فعلاً: تقول لك ما لم تختبره، ولكنها لا تقول إن ما اختبرته صحيح، فقد كانت التغطية ستبقى 100% للأسطر حتى مع الخطأ الذي زرعناه، لأن سطراً محذوفاً لا يظهر في أي تقرير تغطية. والتفاصيل في دليل التغطية.
سكربت النشر: التجربة ثم البث
في المقالين السابقين نشرنا العقود بالأمر forge create، وهو مناسب لعقد واحد. أما في المشروع الحقيقي فالنشر خطوات: ننشر العقد، ثم نموله، وربما ننشر عقوداً أخرى ونربطها. ولذلك يقدم Foundry سكربتات النشر بلغة Solidity نفسها، كما يشرح توثيق السكربتات، وهذا سكربتنا في lab/foundry/script/Deploy.s.sol:
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;
import {Script, console2} from "forge-std/Script.sol";
import {Escrow} from "../src/Escrow.sol";
/// Deploys one escrow. The key comes from the environment, never from a file in the repository.
contract Deploy is Script {
function run() external returns (Escrow escrow) {
address freelancer = vm.envAddress("FREELANCER");
address arbiter = vm.envAddress("ARBITER");
vm.startBroadcast(vm.envUint("CLIENT_KEY"));
escrow = new Escrow(freelancer, arbiter, 2 ether, 7);
escrow.fund{value: 2 ether}();
vm.stopBroadcast();
console2.log("escrow", address(escrow), "state", uint8(escrow.state()));
}
}والسكربت التالي يشغله مرتين، مرة تجربة ومرة بثاً حقيقياً إلى Anvil، واحفظه باسم lab/07-deploy.sh:
cd /lab/foundry
export CLIENT_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
export FREELANCER=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
export ARBITER=0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC
echo "== dry run (simulation only)"
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 2>&1 | grep -E 'escrow|SIMULATION|Estimated|broadcast'
echo "== broadcast"
forge script script/Deploy.s.sol --rpc-url http://127.0.0.1:8545 --broadcast 2>&1 | grep -E 'escrow|ONCHAIN EXECUTION|Hash|Contract Address|Block|Paid|Transactions saved'
echo "== what the script left behind"
ls broadcast/Deploy.s.sol/31337/
cast call 0x5FbDB2315678afecb367f032d93F642f64180aa3 "state()(uint8)"ثم شغله:
docker exec lab-dev bash /lab/07-deploy.shوالمخرج سوف يكون كما يلي، والرقم الطويل في اسم الملف وقت التشغيل:
== dry run (simulation only)
escrow: contract Escrow 0x5FbDB2315678afecb367f032d93F642f64180aa3
escrow 0x5FbDB2315678afecb367f032d93F642f64180aa3 state 1
Estimated max fee per gas: 2.000000001 gwei
Estimated base fee per gas: 1 gwei
Estimated max priority fee per gas: 0.000000001 gwei
Estimated total gas used for script: 1283388
Estimated amount required: 0.002566776001283388 ETH
SIMULATION COMPLETE. To broadcast these transactions, add --broadcast and wallet configuration(s) to the previous command. See forge script --help for more.
Transactions saved to: /lab/foundry/broadcast/Deploy.s.sol/31337/dry-run/run-latest.json
== broadcast
escrow: contract Escrow 0x5FbDB2315678afecb367f032d93F642f64180aa3
escrow 0x5FbDB2315678afecb367f032d93F642f64180aa3 state 1
ONCHAIN EXECUTION COMPLETE & SUCCESSFUL.
Transactions saved to: /lab/foundry/broadcast/Deploy.s.sol/31337/run-latest.json
== what the script left behind
dry-run
run-1793149484563.json
run-latest.json
run-latest.recovery.lock
1في المخرج أعلاه لاحظ التالي:
- التشغيل الأول دون
--broadcastنفذ السكربت كاملاً على نسخة محلية من حالة الشبكة، ولم يرسل أي معاملة، وأعطانا العنوان المتوقع والحالة بعد التمويل، وتقديراً للغاز الكلي 1283388 وحدة والمبلغ المطلوب لدفع الرسوم القصوى. وهذه التجربة هي ما يجب أن تقرأه بعناية قبل أي نشر على شبكة حقيقية، فإذا كان العنوان أو المبلغ غير ما تتوقعه فتوقف هنا. - التشغيل الثاني بث المعاملتين، النشر ثم التمويل، وحفظ في المجلد
broadcastسجلاً بكل معاملة أرسلها لكل شبكة برقمها، وهنا 31337. وهذا السجل مفيد لتعرف ما نشر وأين، وإذا انقطع السكربت في الوسط يستطيع أن يكمل منه. - المفتاح جاء من متغير بيئة وليس من ملف في المستودع، ولكن متغير البيئة ما زال يظهر في سجل ال Shell وفي بيئة العملية. وللمفاتيح التي تحمل قيمة حقيقية يوفر Foundry ملف مفاتيح مشفراً Keystore تنشئه بالأمر
cast wallet import، ثم تشير إليه بالخيار--accountفيطلب كلمة المرور عند التشغيل، وأفضل منه محفظة عتاد Hardware Wallet.
نفس العقد في Hardhat 3
الآن نأخذ نفس العقد إلى Hardhat. وكما يقول دليل البدء يحتاج Hardhat 3 الإصدار 22.13.0 من Node.js أو أحدث، ويوفر قوالب تنشئ المشروع بأمر واحد. ونشغله في Container منفصل فيه Node.js، ونضع المشروع داخل ال Container وليس في المجلد المشترك مع Windows، لأن مجلد node_modules فيه آلاف الملفات الصغيرة وكتابتها عبر المجلد المشترك بطيئة جداً:
docker run -d --name lab-hh -v "$PWD/lab:/lab" -w /work node:24.22.1-bookworm sleep infinity
docker exec lab-hh bash -c 'mkdir -p /work/escrow-hh && cd /work/escrow-hh && npx --yes [email protected] --init --template node-test-runner-viem'
docker exec -w /work/escrow-hh lab-hh bash -c 'npx hardhat --version; npm ls --depth=0'والمخرج سوف يكون كما يلي، بعد سطور التثبيت:
3.21.1
[email protected] /work/escrow-hh
+-- @nomicfoundation/[email protected]
+-- @nomicfoundation/[email protected]
+-- @types/[email protected]
+-- [email protected] (git+ssh://[email protected]/foundry-rs/forge-std.git#bf647bd6046f2f7da30d0c2bf435e5c76a780c1b)
+-- [email protected]
+-- [email protected]
`-- [email protected]في المخرج أعلاه لاحظ التالي:
- القالب
node-test-runner-viemيستخدم مكتبة viem للتعامل مع العقود من TypeScript، ومشغل الاختبارات المدمج في Node.js نفسهnode:test، فلا يحتاج مكتبة اختبار إضافية. - القالب ثبت forge-std أيضاً، الإصدار 1.16.2 من GitHub، لأن Hardhat 3 يشغل اختبارات Solidity المكتوبة بنفس أسلوب Foundry، وهذا ما سوف نستفيد منه بعد قليل.
- حجم
node_modulesبعد التثبيت 273 ميجابايت، مقابل 1.3 ميجابايت لمكتبة forge-std في مشروع Foundry. - القالب أنشأ أيضاً ملفات اسمها
AGENTS.mdومجلد.agents/skills، وهي ملفات إرشاد لأدوات الذكاء الاصطناعي التي تكتب الكود، تشرح لها كيف تستخدم Hardhat في هذا المشروع. وإذا كان فريقك لا يستخدم هذه الأدوات فاحذفها أو راجع محتواها قبل أن تحفظها في المستودع.
ثم حذفنا عقد المثال، ونسخنا Escrow.sol وملف الاختبار Escrow.t.sol نفسه إلى المجلد contracts، بعد تعديل مسار الاستيراد إلى ./Escrow.sol، وغيرنا إصدار المترجم في ملف الإعداد hardhat.config.ts إلى نفس الإصدار، وهذا جزؤه الذي يحدد المترجم:
import hardhatToolboxViemPlugin from "@nomicfoundation/hardhat-toolbox-viem";
import { configVariable, defineConfig } from "hardhat/config";
export default defineConfig({
plugins: [hardhatToolboxViemPlugin],
solidity: {
profiles: {
default: {
version: "0.8.36",
},
production: {
version: "0.8.36",
settings: {
optimizer: {
enabled: true,
runs: 200,
},
},
},
},
},وبقية الملف شبكات، منها شبكة Sepolia تأخذ عنوانها ومفتاحها من configVariable، أي من متغيرات خارج الملف، وهي نفس فكرة متغيرات البيئة في سكربت Foundry.
اختبار من جهة التطبيق بلغة TypeScript
اختبار Solidity يختبر العقد من الداخل، أما تطبيق المنصة فسوف يتكلم مع العقد من TypeScript، فيستحق أن نختبر العقد بالطريقة التي سوف يستخدمه بها التطبيق نفسه. وهذا الملف test/Escrow.ts:
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseEther } from "viem";
import { network } from "hardhat";
describe("Escrow", async function () {
const { viem, networkHelpers } = await network.create();
const [client, freelancer, arbiter] = await viem.getWalletClients();
const publicClient = await viem.getPublicClient();
const AMOUNT = parseEther("2");
async function deploy() {
return viem.deployContract("Escrow", [freelancer.account.address, arbiter.account.address, AMOUNT, 7n]);
}
it("pays the freelancer after delivery and release", async function () {
const escrow = await deploy();
await escrow.write.fund({ value: AMOUNT });
const asFreelancer = await viem.getContractAt("Escrow", escrow.address, { client: { wallet: freelancer } });
await asFreelancer.write.deliver(["https://example.com/jobs/17 sha256:9f2c"]);
const before = await publicClient.getBalance({ address: freelancer.account.address });
await viem.assertions.emitWithArgs(escrow.write.release(), escrow, "Released", [freelancer.account.address, AMOUNT]);
const after = await publicClient.getBalance({ address: freelancer.account.address });
assert.equal(after - before, AMOUNT);
assert.equal(await escrow.read.state(), 3);
});
it("refuses a refund before the deadline, allows it after", async function () {
const escrow = await deploy();
await escrow.write.fund({ value: AMOUNT });
await viem.assertions.revertWithCustomError(escrow.write.refund(), escrow, "TooEarly");
await networkHelpers.time.increase(8 * 24 * 60 * 60);
await escrow.write.refund();
assert.equal(await escrow.read.state(), 4);
});
});في الكود أعلاه لاحظ التالي:
network.create()ينشئ شبكة محلية في نفس العملية، وهي شبكة EDR التي يبنيها فريق Hardhat بلغة Rust، فلا نحتاج Anvil ولا عقدة منفصلة.- المبالغ من نوع
bigintفي TypeScript، مثل7nوناتجparseEther، وهذا ما قلناه في المقال الأول عن المبالغ بوحدة Wei: لا مكان للنوعnumberهنا. - تغيير المرسل لا يتم بأداة غش، وإنما بربط العقد بمحفظة أخرى بالأمر
getContractAtمع محفظة المستقل، وهو بالضبط ما سوف يفعله التطبيق عندما يوقع المستقل بمحفظته. networkHelpers.time.increaseيحرك الساعة ثمانية أيام كما فعلنا بالطريقةevm_increaseTime.
ثم نشغل كل الاختبارات بأمر واحد:
docker exec -w /work/escrow-hh lab-hh npx hardhat testوالمخرج سوف يكون كما يلي:
Running Solidity tests
contracts/Escrow.t.sol:EscrowTest
✔ test_RevertWhen_RefundBeforeDeadline()
✔ test_RevertWhen_FreelancerFunds()
✔ test_RefundAfterDeadline()
✔ test_HappyPath()
✔ test_EmitsReleased()
✔ test_ArbiterSettlesDispute()
✔ testFuzz_RevertWhen_WrongAmount(uint256) (runs: 256)
✔ testFuzz_RevertWhen_NotArbiter(address) (runs: 256)
Running node:test tests
Escrow
✔ pays the freelancer after delivery and release
✔ refuses a refund before the deadline, allows it after
10 passing (8 solidity, 2 nodejs)نفس ملف الاختبار بلغة Solidity عمل في Hardhat دون أي تغيير إلا مسار الاستيراد، كما يشرح دليل اختبارات Solidity في Hardhat، والاختبارات العشوائية عملت 256 مرة لأننا لم نضبط عددها في Hardhat كما ضبطناه في foundry.toml. وجربنا أيضاً أن ننسخ ملف الثوابت نفسه، فشغله Hardhat بالأمر npx hardhat test solidity ونجح الثابتان، وكل واحد 256 تشغيلاً. وفي أول تشغيل حمل Hardhat المترجم 0.8.36 تلقائياً، وظهر ذلك في سطري Downloading solc 0.8.36 قبل الاختبارات.
النشر مع Ignition
النشر في Hardhat يتم بأداة Ignition، والفرق في الفكرة عن سكربت Foundry أنك لا تكتب خطوات تنفذ بالترتيب، وإنما تصف ما تريده في وحدة Module، وIgnition يحسب الترتيب وينفذه ويحفظ ما نفذه حتى يكمل من حيث توقف. وهذه وحدتنا في ignition/modules/EscrowModule.ts:
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import { parseEther } from "viem";
// One escrow: who is the freelancer and the arbiter is passed as parameters, not written in the code
export default buildModule("EscrowModule", (m) => {
const freelancer = m.getParameter("freelancer");
const arbiter = m.getParameter("arbiter");
const escrow = m.contract("Escrow", [freelancer, arbiter, parseEther("2"), 7n]);
m.call(escrow, "fund", [], { value: parseEther("2") });
return { escrow };
});والمعاملات في ملف منفصل ignition/params.json، حتى تنشر نفس الوحدة لمشاريع مختلفة دون أن تغير الكود:
{
"EscrowModule": {
"freelancer": "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
"arbiter": "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC"
}
}ثم ننشر على الشبكة المحلية المدمجة:
docker exec -w /work/escrow-hh lab-hh npx hardhat ignition deploy ignition/modules/EscrowModule.ts --parameters ignition/params.jsonوالمخرج سوف يكون كما يلي:
Batch #1
Executed EscrowModule#Escrow
Batch #2
Executing EscrowModule#Escrow.fund...
Batch #2
Executing EscrowModule#Escrow.fund...
Batch #2
Executed EscrowModule#Escrow.fund
Batch #2
Executed EscrowModule#Escrow.fund
[ EscrowModule ] successfully deployed 🚀
Deployed Addresses
EscrowModule#Escrow - 0x5FbDB2315678afecb367f032d93F642f64180aa3رتب Ignition العمل في دفعتين Batches: النشر أولاً، ثم التمويل لأنه يعتمد على عنوان العقد، والأسطر المكررة هي تحديثات الحالة التي يطبعها أثناء التنفيذ. والعنوان هو نفس العنوان الذي رأيناه في Foundry، لأنه أول عقد ينشره أول حساب في الشبكتين.
Foundry أم Hardhat؟
بعد أن عمل نفس العقد في الأداتين، هذه المقارنة كما رأيناها، والصورة تجمع أهم الفروق:

أما الزمن فقسناه بالأمر time على نفس الجهاز بعد التشغيل الأول: قرابة 3.6 ثانية لاختبارات Foundry الثمانية، وقرابة 3.3 ثانية لاختبارات Hardhat العشرة. والرقمان ليسا مقارنة عادلة، فاختبارات Foundry العشوائية تعمل 1000 مرة لا 256، ومشروع Foundry كان على المجلد المشترك مع Windows ومشروع Hardhat داخل ال Container، وكلاهما أسرع بكثير من أن يكون سبباً لاختيار أداة على أخرى في مشروع بهذا الحجم.
نقاط القوة في Foundry: الاختبارات بلغة العقد نفسها، واختبار الثوابت مع التقليص والعدادات كما رأينا، وأدوات cast وanvil التي استخدمناها في كل مقالات هذه المرحلة، ومدقق آلي مدمج وجد لنا ملاحظات حقيقية مرتين، ومشروع صغير لا يحتاج Node.js.
نقاط الضعف في Foundry: الاختبار من جهة التطبيق، أي بالطريقة التي سوف يستخدمها كود الواجهة، يحتاج أدوات أخرى، وسكربتات النشر المعقدة بلغة Solidity أصعب في القراءة من سكربت عادي.
نقاط القوة في Hardhat: نفس اللغة التي يكتب بها فريق الواجهة، واختبارات تستخدم نفس مكتبة viem التي سوف يستخدمها التطبيق، ونشر وصفي مع Ignition يكمل من حيث توقف، ونظام ملحقات Plugins واسع، ويشغل اختبارات Solidity المكتوبة بأسلوب Foundry.
نقاط الضعف في Hardhat: مشروع أكبر بكثير بسبب node_modules، وتحديثات كثيرة في الحزم يجب متابعتها، وقد ظهر في التثبيت تحذير من npm audit وتحذير عن سكربت تثبيت في حزمة esbuild يحتاج موافقة، وهذه أمور يجب أن يراجعها الفريق في أي مشروع npm كما شرحنا في مقال أمن سلسلة التوريد البرمجية.
والقرار العملي الذي اتخذه فريق المنصة: العقد واختباراته العميقة في Foundry، لأن من يكتب العقد يكتب Solidity، والاختبار من جهة التطبيق في Hardhat مع viem، لأن تطبيق المنصة مكتوب بلغة TypeScript، وملف اختبار Solidity واحد يعمل في الأداتين. وكثير من الفرق تفعل نفس الشيء، فالأداتان لا تتنافسان على نفس الملف.
حلقة التطوير كاملة
الصورة التالية تجمع الخطوات التي مررنا بها بالترتيب الذي يجب أن يمر به أي تعديل على العقد:

وفي فريق حقيقي تعمل الخطوات من 1 إلى 4 آلياً في التكامل المستمر CI مع كل طلب دمج، فلا يدمج تعديل لا يبني دون ملاحظات، أو يفشل فيه اختبار، أو يغير لقطة الغاز دون أن يراه أحد. والأوامر التي يشغلها التكامل المستمر هي نفسها التي شغلناها في هذا المقال، بنفس صورة Foundry ونفس إصدار المترجم، وهذا هو سبب تثبيت الإصدارات: الاختبار الذي نجح على جهاز المطور يجب أن ينجح بنفس الطريقة على سيرفر التكامل، وإلا فقد الفريق ثقته بالنتيجة. وهناك قراران صغيران يستحقان التفكير هنا. الأول عدد تشغيلات الاختبار العشوائي والثوابت: 1000 تشغيل و12800 استدعاء تكفي لطلب دمج يجب أن ينتهي في دقائق، ولكن قبل الإصدار يستحق العقد تشغيلاً أطول بكثير في الليل، لأن الأخطاء النادرة تحتاج تسلسلات أكثر حتى تظهر. والثاني البذرة: إذا فشل اختبار عشوائي في التكامل المستمر فاحفظ البذرة التي طبعها في سجل الفشل، وأعد بها نفس التسلسل على جهازك بالخيار --fuzz-seed، ثم حول التسلسل الذي وجد الخطأ إلى اختبار وحدة ثابت، حتى لا يعود الخطأ دون أن يلاحظه أحد. وأما الخطوتان 5 و6 فتحتاجان إنساناً يقرأ مخرج التجربة قبل البث، ومراجعة مستقلة Audit قبل أن يحمل العقد مالاً حقيقياً، وهذا موضوع مقال أمن العقود الذكية.
أخطاء شائعة وكيف تلاحظها
- أداة
prankتستهلكها قراءة في الوسائط: تلاحظها عندما يفشل اختبار بخطأ صلاحيات مثلNotClientمع أن الاختبار يبدو صحيحاً، والحل أن تقرأ القيم قبلprank، أو تستخدمvm.startPrankلعدة استدعاءات. - ثوابت تنجح دون أن تختبر شيئاً: تلاحظها عندما تضيف عدادات للاستدعاءات الناجحة فتجدها صفراً، والحل أن توجه الوسيط إلى القيم التي تفتح الحالات المهمة.
- تعديل العقد ليمر الاختبار: اقرأ رسالة الفشل كاملة، وتأكد أن الخطأ في العقد وليس في الاختبار، قبل أن تلمس العقد.
- التغطية كهدف: تلاحظها في اختبارات بلا تأكيدات تمر على الأسطر فقط، والحل أن يكون لكل وعد في العقد ثابت أو اختبار يتحقق منه.
- مكتبات وأدوات بلا إصدار ثابت: تلاحظها عندما يختلف الكود الثنائي أو نتيجة الاختبار بين جهازين، والحل تثبيت إصدار المترجم والمكتبات وصورة Foundry.
- مفتاح حقيقي في ملف أو سطر أوامر: تلاحظه في سجل ال Shell أو في المستودع، والحل ملف مفاتيح مشفر أو محفظة عتاد، وحساب منفصل لشبكة الاختبار.
- البث دون قراءة التجربة: كل سكربت نشر يشغل أولاً دون
--broadcast، ويقرأ أحد العنوان والمبالغ والغاز قبل البث.
قائمة تحقق قبل أن يغادر العقد جهازك
| البند | الأمر | متى يعتبر ناجحاً؟ |
|---|---|---|
| البناء والمدقق الآلي | forge build | لا توجد ملاحظات، أو كل ملاحظة مسكتة لها تعليق يشرح السبب |
| اختبارات الوحدة | forge test | اختبار لكل دالة من جهة كل طرف، ولكل خطأ مخصص |
| الاختبار العشوائي | [fuzz] runs | كل قاعدة على مدخل لها اختبار عشوائي بمئات التشغيلات على الأقل |
| الثوابت | forge test --match-contract Invariant -vv | لكل وعد في العقد ثابت، والعدادات تثبت أن الحالات المهمة وصلت |
| الغاز | forge snapshot | اللقطة في المستودع، وكل تغيير فيها مفهوم |
| التغطية | forge coverage | كل فرع غير مغطى إما له اختبار أو سبب مكتوب |
| اختبار التطبيق | npx hardhat test | المسارات التي سوف يستخدمها التطبيق تعمل من TypeScript |
| النشر | forge script دون بث | العنوان والمبالغ والغاز كما هو متوقع، والمفتاح ليس في ملف |
تدريب عملي
- اكتب الاختبار الناقص الذي كشفته التغطية:
test_RevertWhen_ZeroFreelancerينشر العقد بعنوان صفري ويتوقعZeroAddress، ثم أعد تقرير التغطية وتأكد أن الفروع أصبحت 21 من 21 أو اعرف ما بقي. - أضف إلى الوسيط عداداً للدالة
resolve، وثابتاً ثالثاً يقول إن الحالة لا تعود أبداً منReleasedأوRefundedإلى أي حالة أخرى، ثم ازرع خطأً يكسر هذا الوعد في نسخة مؤقتة من العقد وتأكد أن الثابت يجده.
حذف التجربة
احذف ال Containers، ومجلدات البناء والمكتبة، ومشروع Hardhat يحذف مع ال Container لأنه كان داخله:
docker rm -f lab-dev lab-hh
rm -rf lab/foundry/lib lab/foundry/out lab/foundry/cache lab/foundry/broadcastالخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- العقد الذكي يحتاج اختبارات أعمق من أي كود عادي، لأن الخطأ لا يصلح بعد النشر، والمستخدم قد يكون خصماً، والوقت والحالة جزء من المنطق.
- المدقق الآلي في Foundry وجد ملاحظة جديدة بعد إعادة الهيكلة، فأصلحناها بتصميم يجعل المستقبل ثابتاً بدل أن نسكتها.
- أدوات الغش تجعل الاختبار يغير المرسل والرصيد والساعة، و
prankيؤثر على الاستدعاء التالي فقط، ولو كان قراءة داخل الوسائط. - الاختبار العشوائي يسأل هل تصح القاعدة لأي مدخل، والثوابت تسأل هل تبقى صحيحة بعد أي تسلسل، وقد وجدت الثوابت خطأً مزروعاً في ثلاث خطوات لم تره ثمانية اختبارات.
- النجاح الذي لا يصل إلى الحالات المهمة لا يعني شيئاً، والعدادات هي التي تكشفه.
- التغطية تقول ما لم تختبره ولا تقول إن ما اختبرته صحيح، ولقطة الغاز في المستودع تجعل أي ارتفاع في التكلفة ظاهراً في المراجعة.
- Hardhat 3 شغل نفس اختبار Solidity، وأضاف اختبارات من جهة التطبيق بلغة TypeScript ونشراً وصفياً مع Ignition، والفرق بين الأداتين في لغة الفريق أكثر منه في القدرة.
وفي المقال التالي من هذه المرحلة سوف نبتعد قليلاً عن عقد الضمان لنفهم الرموز Tokens، أي العقود التي تمثل عملة أو أصلاً داخل Ethereum، ومعيار ERC-20 والرموز غير القابلة للاستبدال NFT.
دليلما هي العقود الذكية Smart Contracts وما مخاطرها؟نكتب عقد ضمان لمنصة عمل حر ونجربه على شبكة محلية، لنرى أين يفي العقد الذكي بوعده وأين ينقل الثقة إلى مفتاح أو أوراكل.
دليلكيف يعمل Ethereum؟ الحسابات والغاز وآلة EVMماذا يحدث داخل Ethereum عندما ترسل معاملة، ولماذا تدفع الرسوم حتى لو فشلت، ولماذا تختلف تكلفة نفس الدالة من مرة لأخرى.
دليلما هي الرموز Tokens؟ معيار ERC-20 ورموز NFTما الذي تملكه فعلاً عندما تقول المحفظة إن لديك رمزاً، ولماذا يحتاج العقد إذناً، وكيف تعلق الرموز داخل العقود إلى الأبد.
دليلأمن العقود الذكية: أشهر الثغرات وحوادثها الحقيقيةنكتب عقوداً معطوبة ونهاجمها على شبكة محلية لنرى كل صنف ثغرة يعمل، ثم نضع النسخة المصححة بجانبه، من جهة الدفاع.
دليلأنواع الاختبارات للمبتدئين: من Unit إلى E2E مع Playwrightما الذي يكشفه كل نوع من الاختبارات وما الذي يفوته، وكم اختباراً تكتب من كل نوع، وأين يعمل كل منها في خط CI.
دليلSecrets Scanning: كيف تكتشف الأسرار المسربة في الكودكيف يخرج المفتاح من جهازك إلى تاريخ Git وخط CI، وكيف تكتشفه قبل غيرك، وماذا تفعل في الساعة الأولى بعد تسربه.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتشغيل التجارب على Docker Engine 29.8.0 مع Foundry v1.8.6 وforge-std v1.17.0 وSolc 0.8.36، وHardhat 3.21.1 مع hardhat-toolbox-viem 5.0.7 وviem 2.57.12 وhardhat-ignition 3.1.9 على Node.js 24.22.1.