إنشاء محفظة متعددة التوقيع (Multi-sig Wallet) لحماية أموال الشركات عبر العقود الذكية
مقدمة
تُعد المحفظة متعددة التوقيع Multi-sig Wallet من أهم الأنماط الأمنية لحماية خزائن الشركات في عالم Web3. الفكرة الجوهرية بسيطة: بدلاً من منح صلاحية نقل الأموال لمفتاح خاص واحد، يتم اشتراط موافقة عدة مالكين قبل تنفيذ أي معاملة، ما يقلل بشكل كبير خطر السرقة أو الخطأ البشري أو إساءة استخدام الصلاحيات.
هذا التصميم مناسب للشركات الناشئة، فرق DAO، وخزائن المشاريع التي تحتاج إلى فصل القرار المالي عن الفرد الواحد. وإذا كنت بحاجة إلى فهم أعمق للبنية العامة للويب اللامركزي، فمراجعة مقال مدخل إلى Web3: ما هو البلوكتشين ولماذا يغير شكل الإنترنت والأنظمة المالية؟ ستمنحك أساساً ممتازاً قبل الدخول في هذا النمط المتقدم.
في هذا المقال سنبني عقداً ذكياً عملياً باستخدام Solidity، ونشرح كيف يتم تخزين المعاملات المقترحة، آلية التأكيد، التنفيذ، وإلغاء الموافقات، مع التركيز على الأمان وتحسين استهلاك Gas Fees.
ما هي المحفظة متعددة التوقيع ولماذا تحتاجها الشركات؟
المحفظة متعددة التوقيع هي عقد ذكي يحتفظ بالأموال ويشترط عدداً محدداً من التوقيعات لتنفيذ عملية السحب أو الاستدعاء الخارجي. مثلاً، يمكن تعيين 3 مالكين مع قاعدة 2-of-3، أي أن أي معاملة تحتاج إلى موافقة اثنين على الأقل.
من الناحية العملية، هذا يمنع السيناريوهات الخطرة مثل فقدان مفتاح المدير المالي، أو اختراق جهاز موظف واحد، أو محاولة شخص منفرد نقل كامل رصيد الخزينة. وهنا تظهر أهمية فهم التشفير والمفاتيح: كيف تعمل المحافظ الرقمية (Public & Private Keys) برمجياً؟ لأن قوة هذا النموذج تنبع من توزيع سلطة التوقيع على عدة مفاتيح.
الخصائص الأساسية في التصميم
- مجموعة مالكين
ownersمع تحقق من عدم التكرار. - عدد مطلوب من التأكيدات
required. - هيكل بيانات للمعاملات باستخدام الهياكل (Structs).
- تسجيل التأكيدات عبر القواميس (Mappings).
- إرسال واستقبال
Etherأو استدعاء عقود أخرى عبرcall.
بنية العقد الذكي
سنستخدم مجموعة من modifiers للتحقق من أن المستدعي مالك، وأن المعاملة موجودة، وأنها لم تُنفذ بعد. إذا كنت تريد مراجعة أعمق لهذا المفهوم فراجع المعدلات (Modifiers): حماية الدوال برمجياً.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract MultiSigWallet {
event Deposit(address indexed sender, uint256 amount, uint256 balance);
event SubmitTransaction(
address indexed owner,
uint256 indexed txIndex,
address indexed to,
uint256 value,
bytes data
);
event ConfirmTransaction(address indexed owner, uint256 indexed txIndex);
event RevokeConfirmation(address indexed owner, uint256 indexed txIndex);
event ExecuteTransaction(address indexed owner, uint256 indexed txIndex);
address[] public owners;
mapping(address => bool) public isOwner;
uint256 public required;
struct Transaction {
address to;
uint256 value;
bytes data;
bool executed;
uint256 numConfirmations;
}
Transaction[] public transactions;
mapping(uint256 => mapping(address => bool)) public isConfirmed;
modifier onlyOwner() {
require(isOwner[msg.sender], "not owner");
_;
}
modifier txExists(uint256 _txIndex) {
require(_txIndex < transactions.length, "tx does not exist");
_;
}
modifier notExecuted(uint256 _txIndex) {
require(!transactions[_txIndex].executed, "tx already executed");
_;
}
modifier notConfirmed(uint256 _txIndex) {
require(!isConfirmed[_txIndex][msg.sender], "tx already confirmed");
_;
}
constructor(address[] memory _owners, uint256 _required) {
require(_owners.length > 0, "owners required");
require(
_required > 0 && _required <= _owners.length,
"invalid required confirmations"
);
for (uint256 i = 0; i < _owners.length; i++) {
address owner = _owners[i];
require(owner != address(0), "invalid owner");
require(!isOwner[owner], "owner not unique");
isOwner[owner] = true;
owners.push(owner);
}
required = _required;
}
receive() external payable {
emit Deposit(msg.sender, msg.value, address(this).balance);
}
function submitTransaction(
address _to,
uint256 _value,
bytes memory _data
) external onlyOwner {
uint256 txIndex = transactions.length;
transactions.push(
Transaction({
to: _to,
value: _value,
data: _data,
executed: false,
numConfirmations: 0
})
);
emit SubmitTransaction(msg.sender, txIndex, _to, _value, _data);
}
function confirmTransaction(
uint256 _txIndex
) external onlyOwner txExists(_txIndex) notExecuted(_txIndex) notConfirmed(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
transaction.numConfirmations += 1;
isConfirmed[_txIndex][msg.sender] = true;
emit ConfirmTransaction(msg.sender, _txIndex);
}
function executeTransaction(
uint256 _txIndex
) external onlyOwner txExists(_txIndex) notExecuted(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
require(
transaction.numConfirmations >= required,
"cannot execute tx"
);
transaction.executed = true;
(bool success, ) = transaction.to.call{value: transaction.value}(transaction.data);
require(success, "tx failed");
emit ExecuteTransaction(msg.sender, _txIndex);
}
function revokeConfirmation(
uint256 _txIndex
) external onlyOwner txExists(_txIndex) notExecuted(_txIndex) {
Transaction storage transaction = transactions[_txIndex];
require(isConfirmed[_txIndex][msg.sender], "tx not confirmed");
transaction.numConfirmations -= 1;
isConfirmed[_txIndex][msg.sender] = false;
emit RevokeConfirmation(msg.sender, _txIndex);
}
function getOwners() external view returns (address[] memory) {
return owners;
}
function getTransactionCount() external view returns (uint256) {
return transactions.length;
}
function getTransaction(
uint256 _txIndex
)
external
view
returns (
address to,
uint256 value,
bytes memory data,
bool executed,
uint256 numConfirmations
)
{
Transaction storage transaction = transactions[_txIndex];
return (
transaction.to,
transaction.value,
transaction.data,
transaction.executed,
transaction.numConfirmations
);
}
}
شرح آلية العمل خطوة بخطوة
1) تخزين المالكين وعدد التأكيدات
في constructor نستقبل مصفوفة العناوين وعدد التوقيعات المطلوبة. يتم التحقق من أن كل عنوان فريد وغير صفري. هذا يعتمد عملياً على مفاهيم المصفوفات (Arrays) وأنواع البيانات والمتغيرات (State Variables).
2) إرسال الأموال إلى المحفظة
الدالة receive() تسمح للعقد باستلام Ether مباشرة، مع إطلاق حدث Deposit. لفهم هذا الجزء أكثر، راجع استلام وإرسال الأموال (Ether) برمجياً: فهم الدوال payable و fallback.
3) اقتراح معاملة جديدة
عندما يريد أحد المالكين إرسال أموال أو استدعاء عقد آخر، يستخدم الدالة submitTransaction. يتم تخزين الهدف to، القيمة value، والبيانات الخام data داخل struct واحد.
4) التأكيد على المعاملة
كل مالك يستطيع استدعاء confirmTransaction مرة واحدة فقط لكل معاملة. يتم تسجيل ذلك في mapping ثنائي الأبعاد، وهو نمط شائع لبناء أنظمة التصويت والموافقات.
5) تنفيذ المعاملة
بمجرد وصول عدد الموافقات إلى الحد المطلوب، يمكن لأي مالك استدعاء executeTransaction. هنا يستخدم العقد call لإرسال الأموال أو تمرير البيانات إلى عقد آخر، وهو ما يربط هذا المثال بمفهوم التفاعل بين العقود الذكية.
كيف تختبر المحفظة عملياً؟
يمكنك ترجمة العقد وتجربته أولاً في محرر Remix IDE، لكن بيئة العمل الاحترافية ستكون عبر Hardhat مع اختبارات وحدات دقيقة. بعد ذلك ستحتاج إلى إعداد محفظة MetaMask والاتصال بشبكات الاختبار ثم جلب رصيد تجريبي من Faucet.
- انشر العقد مع 3 عناوين وشرط
2تأكيدات. - أرسل إلى العقد مبلغاً تجريبياً.
- أنشئ معاملة سحب إلى عنوان آخر.
- أكدها من محفظتين مختلفتين.
- نفّذ المعاملة وتحقق من الرصيد والـ
events.
ولبناء اختبارات أكثر موثوقية، يفيدك الرجوع إلى اختبار العقود الذكية محلياً: كتابة اختبارات الوحدة (Unit Tests) باستخدام Chai & Mocha.
اعتبارات أمنية مهمة جداً
لا تعتبر محفظة
Multi-sigآمنة بالكامل لمجرد أنها تتطلب عدة توقيعات. إذا كانت المعاملة تستدعي عقداً خارجياً خبيثاً، فقد تظهر مخاطر متعلقة بإعادة الدخول أو منطق الأعمال. راجع ثغرة إعادة الدخول (Reentrancy Attack) والحماية باستخدام ReentrancyGuard عند توسيع وظائف العقد.
قبل النشر على شبكة حقيقية، أضف طبقات تشغيلية خارج السلسلة مثل سياسات مراجعة داخلية، قائمة بيضاء للجهات المسموح الإرسال إليها، ومراجعة يدوية لقيمة
data. كما يُستحسن توثيق الكود علناً بعد النشر عبر التحقق من الكود المصدري على Etherscan لتعزيز الثقة والشفافية.
تحسين استهلاك الغاز في هذا النمط
من أكثر أسباب ارتفاع
Gasفي المحافظ متعددة التوقيع هو التخزين المتكرر داخلstorage. لذلك من المفيد تقليل عدد الكتابات، استخدام متغيرات محلية عند القراءة، وضبط أنواع البيانات بعناية. لفهم هذه النقطة بعمق، راجع الفرق بين Storage, Memory, و Calldata وتحسين استهلاك الـ Gas: حيل وأسرار متقدمة في Solidity.
يمكن أيضاً تحسين التصميم بإضافة دوال قراءة من نوع view لعرض حالة المعاملات مجاناً خارج السلسلة، وهو ما يرتبط بمقال أنواع الدوال : فهم view و pure لتوفير رسوم الـ Gas.
ربط المحفظة بواجهة ويب
بعد نشر العقد، يمكن بناء لوحة تحكم داخلية للشركة تعرض المعاملات، عدد المؤكدين، وحالة التنفيذ. هنا تستخدم Ethers.js لقراءة البيانات وإرسال المعاملات، مع الاستفادة من قراءة البيانات من البلوكتشين وعرضها في واجهة الموقع مجاناً وكتابة البيانات وإرسال المعاملات من واجهة الويب.
كما أن إطلاق events داخل العقد يسهّل تحديث الواجهة مباشرة عند تقديم أو تأكيد أو تنفيذ أي معاملة، وهو امتداد عملي لما شرحناه في الأحداث (Events): كيف يخبر العقد الذكي واجهة الموقع بأن شيئاً ما قد حدث؟.
خاتمة
إن بناء محفظة Multi-sig Wallet ليس مجرد تمرين أكاديمي في Solidity، بل هو حجر أساس في حوكمة أموال الشركات داخل بيئة Blockchain. هذا النموذج يوزّع الثقة، يقلل نقطة الفشل الواحدة، ويمنح المشروع انضباطاً مالياً قابلاً للتدقيق.
لكن القيمة الحقيقية لا تأتي من الكود فقط، بل من جمع الكود الآمن مع الاختبارات الدقيقة، المراجعة الأمنية، التوثيق العام، وربط العقد بواجهة تشغيل واضحة لأصحاب القرار. وعندما تُنفذ هذه العناصر معاً، تتحول المحفظة متعددة التوقيع من عقد بسيط إلى بنية خزينة احترافية صالحة للاستخدام المؤسسي.
1 comment