كلما قرأت خبراً عن بنك مركزي يجرب عملة رقمية، أو عن «ريال رقمي» محتمل، أو عن شركة عالمية أعلنت أنها أطلقت «عملتها» الخاصة، يعود السؤال نفسه: ما الذي يمنعني أن أبني عملة؟ فالعملات الرقمية في النهاية برامج، وكتابة رمز Token على Ethereum تأخذ دقائق كما رأينا في مقال الرموز ERC-20. فهل يستطيع مهندس أن يبني عملة وطنية لبلده؟ وإذا لم يكن ذلك ممكناً، فهل تستطيع شركته أن تصدر عملة خاصة بها لعملائها وشركائها؟ أم أن الفكرة كلها بلا قيمة، وما يسمى «عملة الشركة» قاعدة بيانات بتكلفة أعلى؟
والإجابة الأمينة فيها «نعم» و«لا» معاً، وتعتمد على معنى كلمة «عملة» في السؤال. فالعملة الرسمية لها مصدر واحد يحدده النظام، والكود لا يغير ذلك. ولكن تحت هذا الاسم أشياء كثيرة تستطيع الشركة أن تبنيها فعلاً: نقاط ولاء، ورصيد مسبق الدفع يستخدم داخل مجموعة الشركة، ورمز تسوية بين شركاء معروفين يمثل مالاً حقيقياً في حساب بنكي، وأصل مرمز. ولكل منها قيمة في حالات معينة، ولكل منها متطلبات نظامية، وأغلبها لا يحتاج بلوكتشين أصلاً.
وحتى يبقى النقاش عملياً، سوف نتبع سؤالاً يصل عادة إلى مهندس في شركة متوسطة: شركة لديها شبكة متاجر وموردون يتعاملون معها كل يوم، وقرأت إدارتها عن العملات الرقمية، فطلبت من فريق التقنية دراسة «عملة خاصة بالشركة» يدفع بها العملاء ويسوي بها الموردون. وفي كل قسم نجيب عن جزء من هذا الطلب: ما الذي يجوز للشركة أن تصدره أصلاً، ومن أين تأتي قيمة أي عملة، وما الذي تقوله الصفحات الرسمية في المملكة، وما قرارات التصميم التي لا يمكن تجاوزها، ثم نبني نموذجاً يعمل على شبكة Ethereum محلية لرمز شركة بصلاحيات وقائمة سماح وإصدار مقابل إيداع وحرق عند الاسترداد، ونجرب الحالات التي يجب أن يرفضها.
وسوف نناقش في هذا المقال ما يلي:
- من يملك حق إصدار العملة الوطنية بنص النظام، ولماذا لا يصنع الكود عملة رسمية، وما معنى «بناء عملة وطنية» لمهندس.
- خمسة أنواع من الرصيد الرقمي، ومن يصدر كل نوع، وعلى من يقع الالتزام.
- الخيارات الواقعية للشركة: نقاط الولاء، والرصيد المغلق، ورمز التسوية بين الشركاء، والأصل المرمز، والرمز العام.
- من أين تأتي قيمة العملة: الاحتياطي والاسترداد والثقة، مع حالات فشل موثقة من صفحات رسمية.
- ما تقوله الصفحات الرسمية في المملكة عن النقود الإلكترونية والشبكات المحدودة والعملات الافتراضية.
- قرارات التصميم: أدوار الإصدار والحرق، والاحتياطي، ومعرفة العميل، والتجميد والاستعادة، والخصوصية، والعمل دون اتصال، والتوافق، واختيار السجل.
- تجربة عملية بأداة Foundry وشبكة Anvil المحلية: رمز شركة مبني على OpenZeppelin، مع اختبارات forge وحالات الرفض.
- التشغيل اليومي، والأخطاء الشائعة، وجدول قرار وقائمة تحقق.
هل يستطيع أحد أن يبني عملة وطنية؟
نبدأ بالشق الأول من السؤال لأنه أقصرها إجابة. العملة الوطنية في أي بلد ليست برنامجاً، وإنما التزام يحدده النظام: من يصدرها، ومن يقبلها في سداد الديون والضرائب، ومن يحمي قيمتها. وفي المملكة ينص نظام البنك المركزي السعودي، الصادر بالمرسوم الملكي رقم م/36 بتاريخ 11/4/1442هـ، في مادته الرابعة على أن من المهمات التي يمارسها البنك إصدار النقد وتنظيمه، بما في ذلك سك وطباعة وإصدار العملة الوطنية، وطرحها وسحبها وإلغاؤها وإدارتها وحمايتها، بما لا يخالف أحكام نظام النقد العربي السعودي. أي أن إصدار الريال بأي شكل، ورقياً كان أو معدنياً أو رقمياً، مهمة البنك المركزي بالنص.
وهناك نص رسمي آخر يخص سؤالنا مباشرة. ففي 2019 نشرت وزارة المالية بياناً على موقع البنك المركزي السعودي حذرت فيه من التعامل في العملات الافتراضية، وتناولت عملات تدعي صلة بالمملكة أو تستخدم اسم العملة الوطنية أو شعار المملكة في التسويق، ونفت أي صلة لها بها، وذكرت أن من يستخدم اسم المملكة أو عملتها أو شعارها لتسويق عملات افتراضية أو رقمية قد يتعرض لإجراءات نظامية من الجهات المختصة. وقد جمعنا هذا البيان مع غيره من البيانات الرسمية بتواريخها في مقال العملات الرقمية في السعودية. والنتيجة العملية لطلب الإدارة في قصتنا واضحة من أول يوم: أي رمز تصدره الشركة لا يسمى ريالاً، ولا يسوق على أنه عملة وطنية أو رسمية، ولا يحمل شعار المملكة.
إذن ما هي «العملة الرقمية الوطنية» التي نقرأ عنها؟ هي العملة الرقمية للبنك المركزي CBDC، أي التزام مباشر على البنك المركزي بصيغة رقمية، وقد شرحناها بالتفصيل مع مشروع «عابر» بين البنك المركزي السعودي ومصرف الإمارات المركزي وما قاله البنك المركزي رسمياً في مقال العملات الرقمية للبنوك المركزية، فلن نعيده هنا. ونذكر منه نقطة واحدة فقط لأنها تجيب عن السؤال: البنك المركزي السعودي أعلن أنه يجرب حالات استخدام محلية لعملة رقمية للبنوك مع البنوك المحلية وشركات التقنية المالية، وأكد أنه لم يتخذ أي قرار بإصدار عملة رقمية للبنك المركزي.
لذلك فإن «بناء عملة وطنية» بالنسبة لمهندس لا يعني أن يصدرها، وإنما أن يشارك في بناء أنظمتها إذا دعت الجهة المختصة إلى ذلك: كما شاركت البنوك وشركات التقنية المالية في التجارب التي أعلنها البنك المركزي، وكما شغلت البنوك التجارية عقدها في مشروع عابر. والمعرفة التي تحتاجها لذلك هي بالضبط ما سوف نبنيه في هذا المقال بحجم صغير: أدوار الإصدار والحرق، وقوائم السماح، والإيقاف، والمطابقة بين السجل والمال الحقيقي.
لماذا لا يصنع الكود عملة رسمية؟
قد يتساءل البعض: إذا كانت العملة الرقمية للبنك المركزي سجلاً للأرصدة، وأي مهندس يستطيع أن يكتب سجلاً للأرصدة، فما الفرق؟ والفرق ليس في السجل، وإنما في ثلاث خصائص يشرحها بنك التسويات الدولية في الفصل الثالث من تقريره الاقتصادي السنوي لعام 2025، ويسميها اختبارات أي نقود تصلح أساساً للنظام النقدي:
- الوحدة Singleness: أن يقبل الناس المال الذي تصدره بنوك مختلفة بقيمته الكاملة دون أن يسألوا من أصدره، لأن التسوية النهائية تتم بمال البنك المركزي. والتقرير يذكر أن العملات المستقرة تحمل اسم مصدرها، مثل أوراق البنكنوت الخاصة في الولايات المتحدة في القرن التاسع عشر، ولذلك تتداول أحياناً بأسعار مختلفة.
- المرونة Elasticity: أن يتوفر المال بالقدر الذي تحتاجه المدفوعات الكبيرة في وقتها، كما يوفر البنك المركزي السيولة للبنوك في أنظمة التسوية الإجمالية الآنية RTGS، بينما الرمز المدعوم بالكامل لا يزيد إلا إذا دفع أحدهم ثمنه مقدماً.
- النزاهة Integrity: أن يكون النظام محمياً من الجرائم المالية، ويذكر التقرير أن الرموز التي تتنقل على شبكات عامة دون معرفة العميل تؤدي بشكل سيئ في هذا الاختبار.
ولاحظ أن الكود لا يعطي أياً من الثلاث: الوحدة تأتي من بنك مركزي يسوي بماله، والمرونة تأتي من سياسة نقدية، والنزاهة تأتي من أنظمة معرفة العميل والرقابة. ولهذا السبب يستطيع أي شخص أن يكتب عقداً اسمه «عملة»، ولكن لا يستطيع أحد غير الجهة التي يحددها النظام أن يجعله مالاً رسمياً. وهذه القاعدة نفسها، بحجم أصغر، هي ما يحدد قيمة «عملة الشركة» كما سوف نرى.
من يصدر ماذا؟ خمسة أنواع من الرصيد الرقمي
عندما عاد فريق التقنية في قصتنا إلى الإدارة، لم يبدأ بالتقنية، وإنما بجدول يضع الأشياء التي يسميها الناس «عملة رقمية» جنباً إلى جنب، والسؤال الذي يفرق بينها هو السؤال الذي استخدمناه في مقال CBDC: على من يقع الالتزام؟ ومن يحتاج ترخيصاً ليصدره؟ والصورة التالية تجمع الأنواع الخمسة:

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

نقاط الولاء Loyalty Points: الخيار الذي تملكه أغلب الشركات أصلاً
الرغبة الأولى، أن يعود العميل، تحققها نقاط الولاء، وهي أبسط «عملة شركة» وأقدمها. والنقطة هنا خصم مؤجل تمنحه الشركة مقابل الشراء، والعميل لم يدفع ثمنها، ولا يستردها نقداً، ويستخدمها عند الشركة فقط. ومن الناحية التقنية هي سطر في قاعدة البيانات لكل عميل، مع سجل للحركات: كسب واستخدام وانتهاء صلاحية. ومن الناحية المحاسبية هي التزام على الشركة، لأن كل نقطة لم تستخدم وعد بخصم في المستقبل، والفريق المالي يحتاج أن يعرف حجم هذا الالتزام كل شهر.
وهل تكسب نقاط الولاء شيئاً إذا انتقلت إلى بلوكتشين؟ نطبق عليها الأسئلة الخمسة التي بنيناها في مقال هل تحتاج بلوكتشين؟: من يكتب في السجل؟ الشركة وحدها. وهل توجد جهة يقبل الجميع أن تشغله؟ نعم، الشركة نفسها، والعميل يثق بها كما يثق بفاتورتها. وهل في البيانات ما هو شخصي؟ نعم، سجل مشتريات كل عميل. فالإجابات كلها تقول إن قاعدة البيانات هي الخيار الصحيح، والبلوكتشين هنا يضيف رسوماً وتعقيداً ويكشف بيانات العملاء دون أن يحل مشكلة موجودة. وهذا ما نقصده بأن بعض «عملات الشركات» قاعدة بيانات بتكلفة إضافية.
الرصيد المغلق Closed-loop: مال العميل عندك
الرغبة الثانية مختلفة جوهرياً: العميل يدفع مالاً حقيقياً مقدماً، ويستخدمه لاحقاً في متاجر المجموعة. وهنا لم تعد الشركة تمنح خصماً، وإنما تحتفظ بمال العميل، وهذا ما يجعل المنظم يهتم. وقد وجدنا في اللائحة التنفيذية لنظام المدفوعات وخدماتها على دليل أنظمة البنك المركزي ثلاثة تعريفات تحدد أين يقع هذا الرصيد، ننقلها بمعناها من النص الإنجليزي:
- النقود الإلكترونية Electronic Money: قيمة نقدية مخزنة إلكترونياً تمثل التزاماً على المصدر، تصدر عند استلام أموال لغرض تنفيذ عمليات دفع، ويقبلها شخص آخر غير المصدر وسيلة للدفع، مع استثناء الأصول الافتراضية Virtual Assets من التعريف. ومؤسسة النقود الإلكترونية EMI هي الجهة المرخص لها بإصدارها.
- الحساب المغلق Closed Loop Account: قيمة نقدية على وسيلة دفع يصدرها مصدر واحد، لا تسترد نقداً ولا تحول إلى حساب آخر، وتستخدم فقط لشراء سلع أو خدمات داخل شبكة محدودة من مقدمي الخدمة تربطهم بالمصدر اتفاقيات تجارية مباشرة، ويكونون في نفس المجموعة أو يجمعهم نفس الاسم التجاري أو العلامة.
- خدمة الشبكة المحدودة Limited Network Service: عمليات الدفع التي تتم بحساب مغلق أو بحساب مقيد Restricted Loop Account. والمادة السابعة من اللائحة تعد من الخدمات التي لا تعد خدمات دفع تلك المبنية على وسائل دفع لا تستخدم إلا في شبكة محدودة، مع أن المادة نفسها تبدأ بعبارة «مع مراعاة الأنظمة الأخرى ذات العلاقة».
ولكن الاستثناء ليس فراغاً تنظيمياً. فالمادة 49 من اللائحة نفسها تنص على أن مقدم خدمة الشبكة المحدودة يجب أن يسجل لدى البنك المركزي بالطريقة التي يحددها، وأن يبلغه فوراً إذا تجاوزت عملياته، أو يتوقع أن تتجاوز، خمسة ملايين ريال في أي اثني عشر شهراً متتالية، أو مليوني ريال في أي شهر منها. أي أن الرصيد المغلق نفسه له تسجيل وحدود، والشركة التي تبدأ برصيد مغلق صغير يجب أن تراقب حجمه من اليوم الأول.
والنقطة التي يجب أن ينتبه لها الفريق: الحدود بين هذه التعريفات هي بالضبط الأسئلة التي سوف تطرحها الإدارة لاحقاً. هل يستطيع العميل أن يسترد رصيده نقداً؟ هل يستطيع أن يحوله لصديق؟ هل يقبله متجر لا ينتمي للمجموعة؟ كل «نعم» تنقل الرصيد من الشبكة المحدودة باتجاه النقود الإلكترونية، والنقود الإلكترونية لا يصدرها إلا مرخص. وقد شرحنا هذه الأسئلة بقصة كاملة، مع أنواع التراخيص وحدود الأرصدة وحماية أموال العملاء، في مقال المحافظ الرقمية، فلن نكررها هنا.
ماذا لو أصبح الرصيد نقوداً إلكترونية؟
نذكر من اللائحة ثلاث قواعد فقط، لأنها تجيب عن الرغبة الرابعة التي طرحت في الاجتماع، أن «يرتفع سعر العملة». فالمادة 69 تنص على أن مؤسسة النقود الإلكترونية تصدر النقود الإلكترونية بقيمتها الاسمية Par Value عند استلام الأموال، ولا تمنح فائدة أو أي ميزة مرتبطة بمدة الاحتفاظ بها، ولا تستخدم الأموال المحمية Safeguarded Funds في غير ما تسمح به اللائحة بما في ذلك الإقراض، وأنها تسترد قيمة النقود الإلكترونية بطلب المستخدم في أي وقت وبالقيمة الاسمية. ثم تنص المادة 93 على حماية الأموال فور استلامها، بوضعها في حساب منفصل لدى بنك مرخص أو بطريقة يقبلها البنك المركزي.
ولاحظ ما تعنيه هذه القواعد تقنياً: الريال الإلكتروني يساوي ريالاً عند الإصدار وعند الاسترداد، ولا يربح حامله شيئاً من الاحتفاظ به، والمال الذي يقابله محفوظ في حساب منفصل. أي أن «العملة» المنظمة لا يرتفع سعرها، والقيمة كلها في أنها تساوي المال الذي يقابلها دائماً. وهذا القيد نفسه هو ما سوف نكتبه في العقد في التجربة: إصدار مقابل إيداع، وحرق مقابل استرداد، والرموز في التداول تساوي المال في الحساب.
رمز التسوية بين الشركاء Settlement Token
الرغبة الثالثة، تسوية أسرع مع الموردين، هي الحالة التي قد يكون فيها للسجل المشترك معنى حقيقي. والفكرة أن الشركة والموردين المعروفين يتبادلون فواتير ودفعات كل يوم، وكل طرف يحتفظ بسجله الخاص، ثم يقضي فريق المالية في كل طرف ساعات في المطابقة، والتحويل البنكي لكل فاتورة له وقت ورسوم. فإذا كان هناك رمز يمثل ريالاً محفوظاً فعلاً في حساب بنكي، ويتنقل بين أطراف معروفين على سجل يراه الجميع، فقد تختفي المطابقة، وتصبح التسوية ذرية Atomic: الفاتورة تقفل والمال ينتقل في معاملة واحدة.
ولكن لاحظ ثلاثة شروط في هذه الجملة. الأول أن الرمز «يمثل ريالاً محفوظاً فعلاً»، أي أن هناك من يحتفظ بالمال ويلتزم برده، وهذا التزام مالي على جهة، وهو في الغالب نشاط يحتاج ترخيصاً، فالطريق الطبيعي له أن يصدره بنك أو مؤسسة مرخصة، لا أن تصدره الشركة لنفسها. ولهذا تتجه التجارب الدولية إلى ما يسمى الودائع المرمزة Tokenised Deposits، أي أن يكون الرمز وديعة في بنك مرخص بصيغة جديدة، كما في مشروع Agorá في مركز الابتكار لدى بنك التسويات الدولية، الذي يختبر تسجيل احتياطيات البنوك المركزية وودائع البنوك التجارية على منصة مشتركة للتسوية الذرية في المدفوعات الكبيرة عبر الحدود. والثاني أن الأطراف «معروفون»، أي أن الشبكة مصرح بها Permissioned، والثالث أنهم مستقلون ولا يقبل أحدهم أن يملك الآخر السجل، وإلا كانت قاعدة بيانات عند الشركة تكفي.
وقد رأينا الشكل التقني لهذا الخيار مرتين في هذا المسار: في تجربة PvP في مقال CBDC، وفي شبكة الشركات الأربع على Besu في مقال الشبكات الخاصة للمؤسسات، وفيه أيضاً حالات حقيقية لمشاريع مؤسسية أغلقت بعد سنوات لأن الشركاء لم ينضموا، ومنها TradeLens التي أعلنت Maersk إيقافها في نوفمبر 2022. فالتقنية تعمل، والمشكلة الصعبة دائماً هي إقناع الأطراف المستقلة بالانضمام وحوكمة السجل بينهم.
الأصل المرمز Tokenised Asset
خيار رابع يطرح كثيراً: أن «تقسم» الشركة أصلاً حقيقياً، مبنى أو مخزوناً أو حصة في مشروع، إلى رموز يشتريها المستثمرون. وهنا لم يعد الرمز وسيلة دفع، وإنما حصة في أصل، وهذا في الغالب ورقة مالية بالمعنى التنظيمي. وقد أعلنت هيئة السوق المالية في 2022 أنها تستقبل نماذج أعمال الأوراق المالية المشفرة Security Token ضمن مختبر التقنية المالية، على أن تنحصر التجربة في الأوراق المالية التي تمثل سمات الأوراق المالية التقليدية، مثل الأسهم ووحدات الصناديق، لدراسة المخاطر وتحديد التوجه التنظيمي. أي أن الطريق لهذا الخيار يمر بهيئة السوق المالية، لا بفريق التقنية.
ومن الناحية التقنية، هذه الرموز تحتاج قيوداً أشد من رمز الدفع: من يحق له أن يحمل، وكم، وفي أي دولة، ومتى. ولذلك ظهرت معايير مخصصة لها مثل ERC-3643، الذي يصف نفسه بأنه معيار رموز أوراق مالية للمؤسسات، فيه سجل هويات على السلسلة ومدقق امتثال يفحص كل تحويل قبل تنفيذه. وقائمة السماح التي سوف نبنيها في التجربة نسخة مبسطة جداً من نفس الفكرة.
الرمز العام Public Token: الرغبة الرابعة
بقيت الرغبة التي قالها أحد الحاضرين: «أن يرتفع سعرها ونربح منها». وهذا هو الرمز العام الذي يتداوله أي شخص على شبكة عامة، وقيمته تأتي من السوق فقط، أي من توقع الناس أن يشتريه غيرهم بسعر أعلى. والشركة التي تطلق رمزاً كهذا وتبيعه لعملائها تبيعهم في الحقيقة توقعاً، والصفحات الرسمية في المملكة واضحة في وصف هذا النوع: بيان اللجنة الدائمة للتوعية والتحذير في 2018 ذكر أن العملات الافتراضية لا تعد عملات معتمدة في المملكة وأن التعامل فيها خارج نطاق المظلة الرقابية. ونحن ننقل النص كما هو، ولا نحوله إلى حكم بالجواز أو المنع، فهذا للجهة المختصة وللمستشار النظامي. ولكن أي فريق تقني يجب أن يعرف أن هذا الخيار هو أكثر الخيارات بعداً عن حاجة الشركة، وأقربها إلى ما وصفناه في مقال الاحتيال في العملات الرقمية.
من أين تأتي قيمة العملة؟
بعد الخيارات سأل المدير المالي السؤال الأهم: «إذا أصدرنا الرمز، لماذا يقبله أحد؟». والإجابة أن قيمة أي عملة، رسمية أو خاصة، لا تأتي من الكود، وإنما من ثلاثة أعمدة تقف تحته:
- الاحتياطي Reserves: مال حقيقي يساوي ما في التداول، في حساب منفصل لا تستخدمه الشركة في عملها، ويطابق مع السجل كل يوم.
- الاسترداد Redemption: حق مكتوب لحامل الرمز أن يعيده ويأخذ ماله كاملاً، في وقت معروف، وبرسوم معروفة. فالرمز الذي لا يسترد قيمته ما يدفعه آخرون فيه، لا ما يقابله.
- الثقة والقبول Trust: مصدر خاضع للرقابة، وتدقيق مستقل للاحتياطي، وتجار وشركاء يقبلون الرمز فعلاً. وقبول الآخرين هو ما يجعل الرمز مفيداً أصلاً.
والصورة التالية تضع هذه الأعمدة تحت طبقة الكود، ومعها ثلاث حالات موثقة سقط فيها أحدها:

الحالة الأولى سقط فيها عمود الاحتياطي: عملة UST كانت تسمى مستقرة، ولكن ثباتها كان معتمداً على خوارزمية ورمز شقيق، لا على مال محفوظ، فلما فقدت ربطها بالدولار في مايو 2022 انهارت مع رمزها الشقيق، ووجهت وزارة العدل الأمريكية لمؤسس الشركة المطورة لها اتهامات بالاحتيال كما في بيانها الرسمي. وقد شرحنا آلية هذا الانهيار بمحاكاة في مقال العملات المستقرة.
والحالة الثانية تبين أن المصدر ليس وحده في القرار. فبحسب صفحة هيئة الخدمات المالية في نيويورك NYDFS، أمرت الهيئة شركة Paxos، وهي شركة ائتمانية خاضعة لرقابتها، بالتوقف عن إصدار رمز BUSD بسبب مسائل لم تحل في إشرافها على علاقتها مع Binance، وأعلنت Paxos لعملائها في 13 فبراير 2023 أنها ستنهي هذه العلاقة، وذكرت الهيئة أنها تتابع الشركة لتتحقق من قدرتها على تنفيذ الاستردادات بشكل منظم. وتذكر الصفحة نفسها أن كل العملات المستقرة التي تصدرها جهات تحت رقابتها يجب أن تكون مغطاة بالكامل بنسبة واحد إلى واحد بالنقد أو ما يعادله. والدرس للشركة: الرمز المنظم يتوقف إصداره بقرار من المنظم، ولكن الاسترداد يستمر، لأن الاحتياطي موجود، وهذا ما يجعل حامله مطمئناً حتى في أسوأ الأيام.
والحالة الثالثة من نفس الصفحة، وهي أدق: الهيئة رخصت لشركة Paxos أن تصدر BUSD على شبكة Ethereum، ولم ترخص ما يسمى Binance-Peg BUSD على أي شبكة، وهذه النسخة لم تصدرها Paxos. أي أن رمزاً يحمل نفس الاسم على شبكة أخرى قد لا يكون نفس الالتزام إطلاقاً، وإنما التزام على من أنشأ النسخة. وهذه نقطة يجب أن يتذكرها أي فريق يفكر في «نقل» رمزه بين شبكات عبر جسر Bridge، وسوف نعود إليها في قرار التوافق.
متى يكون الرمز قاعدة بيانات بتكلفة إضافية؟
بعد هذه الأعمدة يصبح سؤال الإدارة الأصلي، «هل للعملة قيمة؟»، سؤالين منفصلين. الأول: هل للالتزام نفسه قيمة؟ أي هل يحصل العميل أو الشريك على شيء حقيقي، خصم أو مال مسترد أو تسوية أسرع؟ والثاني: هل يضيف السجل الموزع شيئاً لا تعطيه قاعدة البيانات؟ والإجابة عن الثاني في أغلب حالات الشركات «لا»، لأن الشركة نفسها هي الجهة التي يقبل الجميع أن تشغل السجل، كما يقول السؤال الثاني في اختبارنا من خمسة أسئلة، ولأن العميل الذي يثق بالشركة ليحفظ ماله سيثق بقاعدة بياناتها ليحفظ رصيده. والحالة التي يتغير فيها الجواب هي التسوية بين أطراف مستقلين لا يقبل أحدهم سجل الآخر، وهي الحالة الأندر.
وتظهر التكلفة الإضافية في أماكن لا تظهر في العرض: كل حركة لها رسوم غاز إذا كانت على شبكة عامة، وكل رصيد وتحويل يراه الجميع، والخطأ في التحويل لا يصحح بتعديل سطر وإنما بمعاملة جديدة يوقعها صاحب صلاحية، ومفاتيح الإصدار تصبح هدفاً دائماً، والتدقيق يحتاج خبراء عقود ذكية. لذلك عندما يكون الجواب عن السؤال الثاني «لا»، فالرمز على البلوكتشين قاعدة بيانات أغلى وأبطأ وأقل خصوصية، والأفضل أن تبني الرصيد على دفتر قيد مزدوج Ledger كما تفعل المحافظ والبنوك.
الواقع التنظيمي: ما الذي تقوله الصفحات الرسمية؟
جمع الفريق ما وجده في الصفحات الرسمية في جدول واحد للإدارة، وكتب بجانب كل سطر مصدره، ولم يكتب في أي سطر كلمة «مسموح» أو «ممنوع»، لأن أياً منهما حكم نظامي على حالة محددة، والنصوص التي بين يديه تعريفات وتحذيرات وقواعد عامة:
| الموضوع | ما يقوله المصدر الرسمي | المصدر |
|---|---|---|
| إصدار العملة الوطنية | من مهمات البنك المركزي إصدار النقد وتنظيمه، بما في ذلك سك وطباعة وإصدار العملة الوطنية | نظام البنك المركزي السعودي، المادة الرابعة |
| استخدام اسم العملة أو شعار المملكة | من يستخدمه لتسويق عملات افتراضية أو رقمية قد يتعرض لإجراءات نظامية | بيان وزارة المالية 2019 |
| العملات الافتراضية | لا تعد عملات معتمدة في المملكة، والتعامل فيها خارج المظلة الرقابية | بيان اللجنة الدائمة 2018 |
| النقود الإلكترونية | تصدرها مؤسسة مرخصة، بالقيمة الاسمية، وتسترد بالقيمة الاسمية، دون فائدة، وأموالها محمية في حساب منفصل | اللائحة التنفيذية لنظام المدفوعات، المواد 69 و93 |
| الرصيد المغلق والشبكة المحدودة | تسجيل لدى البنك المركزي، وإبلاغ عند تجاوز 5 ملايين ريال في 12 شهراً أو مليونين في شهر | اللائحة التنفيذية، المادتان 7 و49 |
| الأوراق المالية المشفرة | نماذج أعمالها تستقبل في مختبر التقنية المالية لدراسة المخاطر | إعلان هيئة السوق المالية 2022 |
| العملة الرقمية للبنك المركزي | تجارب لعملة للبنوك، ولا قرار بالإصدار | خبر البنك المركزي السعودي عن استمرار التجارب |
والنتيجة التي خرجت بها الإدارة في قصتنا: نقاط الولاء تبقى كما هي في قاعدة البيانات مع تحسين تقاريرها المالية، والرصيد المسبق داخل المجموعة يدرس مع المستشار على أساس الشبكة المحدودة وحدودها، والتسوية مع الموردين تبحث مع البنك الذي تتعامل معه الشركة بدلاً من أن تصدر الشركة رمزاً بنفسها، والرمز العام يحذف من النقاش. ولكن الإدارة طلبت شيئاً واحداً من الفريق: نموذجاً يعمل، يفهم منه الجميع ما الذي يعنيه «رمز مدعوم بالكامل» في الكود، وما الذي لا يستطيع الكود أن يضمنه، حتى يكون النقاش مع البنك والمستشار مبنياً على فهم. وقبل النموذج نحتاج قرارات التصميم.
قرارات التصميم في أي عملة رسمية أو خاصة
سواءً كان المصدر بنكاً مركزياً يصمم عملة رقمية، أو بنكاً يصدر وديعة مرمزة، أو شركة تبني رصيداً مغلقاً، فإن نفس القرارات تتكرر، والفرق في من يتخذها ومن يحاسبه. وهذه أهمها:
من يصدر ومن يحرق؟ فصل الأدوار Roles
أخطر صلاحية في أي عملة هي الإصدار Mint، لأنها تخلق قيمة من لا شيء في السجل، والثانية الحرق Burn، لأن الخطأ فيها يمحو رصيد شخص. والقاعدة التي تعرفها البنوك منذ زمن، ونطبقها في العقد، هي الفصل بين المهام Segregation of Duties: من يرى الإيداع ويصدر ليس هو من يدفع الاسترداد ويحرق، وليس هو من يضيف العملاء إلى القائمة، وليس هو من يملك زر الإيقاف. ثم إن كل دور يجب أن يرتبط بحدث في العالم الحقيقي: الإصدار لا يتم إلا بمرجع إيداع بنكي، والحرق لا يتم إلا بمرجع دفعة خرجت فعلاً. وفوق كل الأدوار يوجد دور المدير Admin الذي يمنح الأدوار ويسحبها، وهو أخطر من الجميع، لذلك يوضع عادة خلف توقيع متعدد Multisig ومهلة زمنية، كما شرحنا نماذج حفظ المفاتيح للشركات في مقال المحافظ والمفاتيح، وفي الأنظمة المصرفية تحمى هذه المفاتيح في وحدات أمن العتاد HSM.
الاحتياطي والاسترداد
القرار هنا ليس تقنياً، ولكن له أثر تقني مباشر: أين يحفظ المال المقابل، وبأي نسبة، ومن يدققه، ومتى يحق للحامل أن يسترد، وكم يستغرق الاسترداد. والتصميم الذي يجيب عنه الكود هو أن يكون الإصدار والحرق مرتبطين بحركة المال الحقيقي، وأن يكون هناك ثابت Invariant يمكن فحصه كل يوم: مجموع الرموز في التداول يساوي رصيد حساب الاحتياطي، مطروحاً منه ما ينتظر الدفع. فإذا اختل هذا الثابت يوماً واحداً فهناك خطأ في الإصدار أو سرقة أو قيد ناقص، والفريق يجب أن يعرف قبل أن يعرف العملاء.
معرفة العميل وقائمة السماح Allowlist
الرمز العام يحمله أي عنوان، أما الرمز الذي يمثل مالاً منظماً فيحمله من عرفه المصدر فقط. وهذا يعني قائمة سماح في العقد نفسه: كل تحويل يفحص المرسل والمستلم، والعنوان الذي لم يمر بمعرفة العميل KYC لا يستلم شيئاً. وقد شرحنا ما تطلبه الأنظمة في معرفة العميل وفحص العقوبات في مقال معرفة العميل KYC، والذي يهمنا هنا أن القائمة في العقد هي نتيجة هذا الفحص، لا الفحص نفسه: الهوية والوثائق تبقى عند الشركة، والعقد يعرف فقط أن هذا العنوان مسموح.
التجميد والاستعادة Freeze and Recovery
ماذا يحدث إذا سرق مفتاح عميل؟ أو صدر أمر من جهة مختصة بتجميد حساب؟ أو أخطأ العميل في عنوان المستلم؟ في الشبكة العامة لا يوجد من يتدخل، وهذا ما يحبه البعض فيها. أما العملة المنظمة فتحتاج قدرة على التجميد، وقدرة على استعادة الرصيد بعد التحقق، وكلتاهما قوة كبيرة في يد المصدر، فتحتاج حوكمة مكتوبة: من يقرر، وبأي سبب، وبأي سجل. وسوف نرى في التجربة أثراً جانبياً لم نتوقعه: إخراج العميل من القائمة يجمده تماماً، فلا يستطيع حتى أن يسترد ماله، وهذا يعني أن إجراء الاسترداد للحسابات المجمدة يجب أن يصمم صراحة.
الخصوصية Privacy
كل ما يكتب في سجل Ethereum يقرؤه كل من يصل إلى السجل: الأرصدة، والتحويلات، ومن في قائمة السماح. وفي شبكة عامة يعني ذلك أن أي منافس يعرف حجم مبيعاتك وأي مورد يتقاضى منك كم. والعناوين لا تحمل أسماء، ولكن ربط عنوان بشخص يكفيه تحويل واحد معروف. وفي المملكة يضاف إلى ذلك نظام حماية البيانات الشخصية، وقد رأينا في مقال هل تحتاج بلوكتشين؟ لماذا لا يحذف ما كتب في السجل، ولماذا لا يحل نشر هاش البيانات المشكلة. لذلك يضع أي تصميم جاد البيانات الشخصية خارج السلسلة، ويختار شبكة مصرح بها أو قنوات خاصة كما فعل مشروع عابر، أو يقبل أن قاعدة البيانات أنسب.
العمل دون اتصال Offline
النقد يعمل في مكان بلا شبكة، والرصيد الرقمي لا يعمل إلا بسؤال السجل: هل صرف هذا المبلغ من قبل؟ وهذه مشكلة الإنفاق المزدوج التي بدأ بها تاريخ العملات الرقمية. والعملات الرقمية للبنوك المركزية التي تعد بالعمل دون اتصال تعتمد على أجهزة آمنة تحفظ الرصيد وتمنع صرفه مرتين، وهذه هندسة عتاد وليست عقداً ذكياً. أما شركة في قصتنا فالقرار أبسط: إذا انقطعت الشبكة عن متجر، فهل يقبل الكاشير الدفع ويسويه لاحقاً بحد أقصى صغير، أم يرفض؟ وهذا قرار مخاطرة يكتب في السياسة قبل الكود.
التوافق Interoperability
الرمز الذي لا يستخدم إلا في تطبيق الشركة قيمته محدودة، والرمز الذي ينتقل إلى أنظمة أخرى يحتاج معايير مشتركة. وفي عالم EVM المعيار هو ERC-20، وبه يعمل الرمز مع أي محفظة وأداة، ولكن التوافق له وجه آخر رأيناه في حالة BUSD: نسخ الرمز إلى شبكة أخرى عبر جسر يخلق التزاماً جديداً على من يشغل الجسر، لا على المصدر. لذلك قرار المصدر المنظم عادة أن يحدد الشبكات التي يصدر عليها بنفسه، وأن يعلن أن أي نسخة غيرها ليست التزامه.
أين يعيش السجل؟
آخر قرار هو التقنية، وله ثلاثة خيارات رئيسية، والصورة التالية تبين ما يكسبه كل منها وما يخسره:

والجدول التالي يجمع قرارات التصميم كلها مع الخيارات الثلاثة، وهو ما قدمه الفريق للإدارة قبل أن يكتب الكود:
| القرار | قاعدة بيانات ودفتر قيد | شبكة مصرح بها | شبكة عامة |
|---|---|---|---|
| الإصدار والحرق | صلاحيات في التطبيق وقيود محاسبية | أدوار في العقد، ومفاتيح عند المصدر | أدوار في العقد، ومفاتيح مكشوفة لكل مهاجم في العالم |
| الاحتياطي | مطابقة يومية مع البنك | نفس المطابقة، والسجل يراه الشركاء | نفس المطابقة، والسجل يراه الجميع |
| معرفة العميل | في التطبيق | قائمة سماح وعضوية في الشبكة | قائمة سماح في العقد فقط |
| التجميد والاستعادة | تعديل بقيد عكسي موثق | دالة في العقد بحوكمة بين الأعضاء | دالة في العقد، ومن يملكها مركز قوة |
| الخصوصية | كاملة داخل الشركة | بين الأعضاء، وتحتاج قنوات أو بيانات خاصة | لا خصوصية للأرصدة والتحويلات |
| العمل دون اتصال | تسوية لاحقة بحدود | تسوية لاحقة بحدود | تسوية لاحقة بحدود |
| التوافق | واجهات API | معيار ERC-20 بين الأعضاء | كل المحافظ والأدوات، ومعها الجسور ونسخها |
| التكلفة | الأقل | تشغيل عقد لكل عضو | غاز لكل حركة |
| يناسب | نقاط الولاء والرصيد المغلق | التسوية بين شركاء مستقلين | نادراً ما يناسب عملة شركة |
ولاحظ أن صف العمل دون اتصال واحد في الأعمدة الثلاثة، لأن المشكلة خارج السجل أصلاً، وأن الصف الذي يتغير أكثر هو الخصوصية، وهو غالباً ما يحسم القرار في الشركات.
تجربة: رمز شركة بأدوار وقائمة سماح على Anvil
الآن نبني النموذج الذي طلبته الإدارة: رمز اسمه Company Credit بالاختصار CCR، كل وحدة منه تمثل وحدة مال في حساب الشركة البنكي، بخانتين عشريتين مثل الهللات، مبني على مكتبة OpenZeppelin Contracts الإصدار 5.7.0، ويعمل كما يلي:
- أربعة أدوار بنظام AccessControl: المصدر
MINTER_ROLE، والحارقBURNER_ROLE، وصاحب الإيقافPAUSER_ROLE، ومسؤول القائمةKYC_ROLE. - قائمة سماح: لا يحمل الرمز ولا يرسله إلا عنوان مر بمعرفة العميل.
- إصدار مقابل إيداع: كل إصدار يحمل مرجع إيداع بنكي، ولا يتكرر نفس المرجع.
- استرداد بخطوتين: الحامل يطلب فيحجز الرمز داخل العقد، ثم يحرق بعد أن تدفع المالية المال فعلاً.
- إيقاف مؤقت يوقف كل الحركة.
والصورة التالية تبين دورة الحياة التي سوف نكتبها:

وكل ما في التجربة يعمل على جهازك داخل Docker، بشبكة Anvil المحلية من أدوات Foundry، والمفاتيح المستخدمة هي مفاتيح الاختبار المعروفة التي يطبعها Anvil عند تشغيله، وهي منشورة للجميع، فلا تستخدمها على أي شبكة حقيقية أبداً. وإذا لم تجرب Foundry من قبل فقد شرحنا المشروع والاختبارات بالتفصيل في مقال تطوير العقود الذكية.
العقد: CompanyToken.sol
أنشئ مجلداً للمشروع، وفيه الملف foundry.toml:
[profile.default]
src = "src"
out = "out"
test = "test"
solc = "0.8.30"
remappings = ["@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/", "forge-std/=lib/forge-std/src/"]ثم العقد في الملف src/CompanyToken.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Pausable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Pausable.sol";
import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";
// A teaching "company token": 1 unit = 1 unit of money the company holds in its bank account.
// Only KYC'd (allowlisted) addresses can hold it; minting needs a deposit reference, redemption burns.
contract CompanyToken is ERC20, ERC20Pausable, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
bytes32 public constant KYC_ROLE = keccak256("KYC_ROLE");
error NotAllowlisted(address account);
error DepositAlreadyUsed(bytes32 depositRef);
error NotPending(uint256 id);
struct Redemption { address holder; uint256 amount; bool pending; }
mapping(address => bool) public allowlisted;
mapping(bytes32 => bool) public usedDeposit; // one bank deposit can be minted once
mapping(uint256 => Redemption) public redemptions;
uint256 public nextRedemptionId;
event AllowlistSet(address indexed account, bool allowed, address indexed by);
event MintedForDeposit(address indexed to, uint256 amount, bytes32 indexed depositRef);
event RedemptionRequested(uint256 indexed id, address indexed holder, uint256 amount);
event RedemptionPaid(uint256 indexed id, uint256 amount, bytes32 indexed payoutRef);
constructor(address admin) ERC20("Company Credit", "CCR") {
_grantRole(DEFAULT_ADMIN_ROLE, admin);
allowlisted[address(this)] = true; // the contract holds tokens while a redemption waits
}
function decimals() public pure override returns (uint8) { return 2; }
function setAllowlisted(address account, bool allowed) external onlyRole(KYC_ROLE) {
allowlisted[account] = allowed;
emit AllowlistSet(account, allowed, msg.sender);
}
// money arrived in the company's bank account: mint the same amount, once per deposit
function mintForDeposit(address to, uint256 amount, bytes32 depositRef) external onlyRole(MINTER_ROLE) {
if (usedDeposit[depositRef]) revert DepositAlreadyUsed(depositRef);
usedDeposit[depositRef] = true;
_mint(to, amount);
emit MintedForDeposit(to, amount, depositRef);
}
// the holder asks for money back: tokens move into escrow inside the contract
function requestRedemption(uint256 amount) external returns (uint256 id) {
id = nextRedemptionId++;
redemptions[id] = Redemption(msg.sender, amount, true);
_transfer(msg.sender, address(this), amount);
emit RedemptionRequested(id, msg.sender, amount);
}
// finance paid the holder from the bank account: burn the escrowed tokens
function completeRedemption(uint256 id, bytes32 payoutRef) external onlyRole(BURNER_ROLE) {
Redemption storage r = redemptions[id];
if (!r.pending) revert NotPending(id);
r.pending = false;
_burn(address(this), r.amount);
emit RedemptionPaid(id, r.amount, payoutRef);
}
function pause() external onlyRole(PAUSER_ROLE) { _pause(); }
function unpause() external onlyRole(PAUSER_ROLE) { _unpause(); }
// every balance change passes here: mint (from = 0), burn (to = 0) and transfer
function _update(address from, address to, uint256 value) internal override(ERC20, ERC20Pausable) {
if (from != address(0) && !allowlisted[from]) revert NotAllowlisted(from);
if (to != address(0) && !allowlisted[to]) revert NotAllowlisted(to);
super._update(from, to, value);
}
}في الكود أعلاه لاحظ التالي:
- الدالة
_updateهي المكان الوحيد الذي تمر به كل حركة رصيد في OpenZeppelin 5: الإصدار يمر بها وfromيساوي الصفر، والحرق وtoيساوي الصفر، والتحويل العادي. لذلك وضعنا قائمة السماح فيها، فلا توجد طريقة لتحريك الرصيد تتجاوز الفحص، حتى لو أضاف أحد لاحقاً دالة جديدة. ثم نستدعيsuper._updateفتمر الحركة علىERC20Pausableالذي يرفضها إذا كان العقد موقوفاً، ثم علىERC20الذي يعدل الأرصدة. - كل دالة حساسة عليها
onlyRole، والأدوار هاش لاسمها بـkeccak256، والمديرDEFAULT_ADMIN_ROLEوحده يمنح الأدوار ويسحبها، والعقد عند نشره يعطي المدير هذا الدور فقط، ولا يعطيه حتى صلاحية الإصدار، فهو يمنحها لغيره صراحة. - الخريطة
usedDepositتحفظ كل مرجع إيداع استخدم، فإذا أعاد نظام الخزينة إرسال نفس الإيداع بسبب خطأ في الشبكة أو إعادة محاولة، يرفض العقد الإصدار الثاني. وهذه هي فكرة مفتاح منع التكرار Idempotency Key التي شرحناها في دفتر القيد المزدوج، منقولة إلى العقد. - الاسترداد بخطوتين لأن الحرق لا يجوز أن يسبق الدفع: لو حرقنا الرمز عند الطلب ثم فشل التحويل البنكي، لخسر العميل رمزه وماله معاً. لذلك يحجز الرمز داخل العقد، ولهذا السبب أضفنا العقد نفسه إلى قائمة السماح في الـ
constructor، ثم يحرق بعد الدفع بمرجع الدفعة. - الأخطاء مخصصة Custom Errors تحمل البيانات، مثل العنوان المرفوض أو المرجع المكرر، فيرى من يقرأ الرفض سببه مباشرة، وهي أرخص في الغاز من رسائل النص.
- الأحداث
MintedForDepositوRedemptionPaidوAllowlistSetهي سجل التدقيق: كل إصدار ومرجع إيداعه، وكل حرق ومرجع دفعه، وكل تغيير في القائمة ومن قام به.
الاختبارات: ما الذي يجب أن ينجح وما الذي يجب أن يفشل؟
قبل أن ننشر العقد على أي شبكة، نكتب اختبارات بلغة Solidity تمثل قواعد العمل، وأغلبها اختبارات رفض: أن يفشل ما يجب أن يفشل، لأن الرمز الذي يقبل كل شيء أخطر من الذي يرفض كل شيء. احفظ الملف test/CompanyToken.t.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test, console2} from "forge-std/Test.sol";
import {IAccessControl} from "@openzeppelin/contracts/access/IAccessControl.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {CompanyToken} from "../src/CompanyToken.sol";
contract CompanyTokenTest is Test {
address admin = makeAddr("admin");
address minter = makeAddr("minter"); // treasury system that sees bank deposits
address burner = makeAddr("burner"); // finance, after paying out
address pauser = makeAddr("pauser");
address kyc = makeAddr("kyc");
address customer = makeAddr("customer");
address merchant = makeAddr("merchant");
address stranger = makeAddr("stranger"); // never passed KYC
CompanyToken t;
function setUp() public {
t = new CompanyToken(admin);
vm.startPrank(admin);
t.grantRole(t.MINTER_ROLE(), minter);
t.grantRole(t.BURNER_ROLE(), burner);
t.grantRole(t.PAUSER_ROLE(), pauser);
t.grantRole(t.KYC_ROLE(), kyc);
vm.stopPrank();
vm.startPrank(kyc);
t.setAllowlisted(customer, true);
t.setAllowlisted(merchant, true);
vm.stopPrank();
}
function test_1_lifecycle() public {
vm.prank(minter); t.mintForDeposit(customer, 100_000, "DEP-0001"); // 1000.00 deposited
vm.prank(customer); t.transfer(merchant, 25_000); // pays 250.00
vm.prank(merchant); uint256 id = t.requestRedemption(25_000);
console2.log("after request: merchant", t.balanceOf(merchant), "escrow", t.balanceOf(address(t)));
vm.prank(burner); t.completeRedemption(id, "PAY-0001");
console2.log("customer", t.balanceOf(customer), "merchant", t.balanceOf(merchant));
console2.log("totalSupply", t.totalSupply());
}
function test_2_transferToStranger() public {
vm.prank(minter); t.mintForDeposit(customer, 100_000, "DEP-0001");
vm.prank(customer);
vm.expectRevert(abi.encodeWithSelector(CompanyToken.NotAllowlisted.selector, stranger));
t.transfer(stranger, 1_000);
console2.log("transfer to a non-KYC address: reverted");
}
function test_3_mintWithoutRole() public {
vm.prank(customer);
vm.expectRevert(abi.encodeWithSelector(
IAccessControl.AccessControlUnauthorizedAccount.selector, customer, t.MINTER_ROLE()));
t.mintForDeposit(customer, 1_000_000, "FAKE");
console2.log("mint without MINTER_ROLE: reverted, supply", t.totalSupply());
}
function test_4_sameDepositTwice() public {
vm.startPrank(minter);
t.mintForDeposit(customer, 100_000, "DEP-0001");
vm.expectRevert(abi.encodeWithSelector(CompanyToken.DepositAlreadyUsed.selector, bytes32("DEP-0001")));
t.mintForDeposit(customer, 100_000, "DEP-0001");
vm.stopPrank();
console2.log("second mint for the same deposit: reverted, supply", t.totalSupply());
}
function test_5_pause() public {
vm.prank(minter); t.mintForDeposit(customer, 100_000, "DEP-0001");
vm.prank(pauser); t.pause();
vm.prank(customer);
vm.expectRevert(Pausable.EnforcedPause.selector);
t.transfer(merchant, 1_000);
vm.prank(customer);
vm.expectRevert(Pausable.EnforcedPause.selector);
t.requestRedemption(1_000);
console2.log("paused: transfers and redemptions both stop");
}
function test_6_removedFromAllowlist() public {
vm.prank(minter); t.mintForDeposit(customer, 100_000, "DEP-0001");
vm.prank(kyc); t.setAllowlisted(customer, false);
vm.prank(customer);
vm.expectRevert(abi.encodeWithSelector(CompanyToken.NotAllowlisted.selector, customer));
t.requestRedemption(100_000);
console2.log("de-listed holder cannot even redeem; balance stuck:", t.balanceOf(customer));
}
// fuzz: whatever amounts we pick, tokens in circulation == money the company should hold
function testFuzz_supplyEqualsReserve(uint64 deposit, uint64 redeem) public {
vm.assume(deposit > 0 && redeem <= deposit);
uint256 bank = deposit; // the company's bank balance
vm.prank(minter); t.mintForDeposit(customer, deposit, "DEP");
vm.prank(customer); uint256 id = t.requestRedemption(redeem);
vm.prank(burner); t.completeRedemption(id, "PAY");
bank -= redeem; // paid out of the bank account
assertEq(t.totalSupply(), bank);
}
}في الاختبارات أعلاه لاحظ التالي:
- كل شخص في القصة عنوان باسم واضح من
makeAddr، والأداةvm.prankتجعل الاستدعاء التالي يأتي من هذا العنوان، فنختبر الصلاحيات كما تحدث فعلاً. vm.expectRevertمع الخطأ المخصص وبياناته يتحقق أن الرفض حدث للسبب الصحيح بالضبط، لا لأي سبب آخر، فاختبار يفشل بسبب خطأ مختلف لا يعد نجاحاً.- الاختبار الأخير اختبار عشوائي Fuzz، تعطيه Foundry أرقاماً عشوائية للإيداع والاسترداد، ويتحقق في كل مرة من الثابت الذي ذكرناه: الرموز في التداول تساوي ما يجب أن يكون في حساب الشركة.
ثم نشغل الاختبارات داخل Docker، بعد أن نثبت المكتبتين بإصدارين محددين:
docker run --rm --name bocoin-forge -v "$PWD:/src" ghcr.io/foundry-rs/foundry:v1.8.6 \
"cp -r /src /tmp/boc && cd /tmp/boc && forge install OpenZeppelin/[email protected] foundry-rs/[email protected] --no-git >/dev/null 2>&1; forge test -vv"أول تشغيل: اختبار فشل، والعقد سليم
في أول تشغيل نجحت ستة اختبارات وفشل اختبار الإصدار دون صلاحية بهذا المخرج:
[FAIL: Error != expected error: AccessControlUnauthorizedAccount(0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496, 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6) != AccessControlUnauthorizedAccount(0x213556C3644e55cA21039D6034Fc14A13f197a6b, 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6)] test_3_mintWithoutRole() (gas: 47691)العقد رفض الإصدار فعلاً، ولكن العنوان المرفوض لم يكن عنوان العميل، وإنما 0x7FA9…1496، وهو عنوان عقد الاختبار نفسه. والسبب في سطر الاختبار وليس في العقد: كتبنا vm.prank(customer) ثم مررنا t.MINTER_ROLE() داخل معاملات expectRevert، وقراءة MINTER_ROLE() استدعاء للعقد، وتوثيق prank يقول إنه يغير المرسل «للاستدعاء التالي»، وإن ذلك يشمل الاستدعاءات الساكنة Static Calls. فاستهلك استدعاء القراءة الـ prank، ووصل الإصدار من عقد الاختبار. والحل أن نقرأ الدور قبل prank:
function test_3_mintWithoutRole() public {
bytes32 role = t.MINTER_ROLE(); // read it before prank: prank applies to the next call only
vm.prank(customer);
vm.expectRevert(abi.encodeWithSelector(
IAccessControl.AccessControlUnauthorizedAccount.selector, customer, role));
t.mintForDeposit(customer, 1_000_000, "FAKE");
console2.log("mint without MINTER_ROLE: reverted, supply", t.totalSupply());
}وهذا الخطأ يستحق أن تتذكره، لأن اختبار صلاحيات يمر بالخطأ أخطر من اختبار يفشل: لو كتبنا expectRevert() دون تحديد الخطأ لنجح الاختبار، ونحن نظن أننا تحققنا من رفض العميل، بينما الذي رفض عنوان آخر تماماً. وبعد التعديل كان المخرج كما يلي:
Ran 7 tests for test/CompanyToken.t.sol:CompanyTokenTest
[PASS] testFuzz_supplyEqualsReserve(uint64,uint64) (runs: 256, μ: 309703, ~: 313727)
[PASS] test_1_lifecycle() (gas: 421937)
Logs:
after request: merchant 0 escrow 25000
customer 75000 merchant 0
totalSupply 75000
[PASS] test_2_transferToStranger() (gas: 150356)
Logs:
transfer to a non-KYC address: reverted
[PASS] test_3_mintWithoutRole() (gas: 49736)
Logs:
mint without MINTER_ROLE: reverted, supply 0
[PASS] test_4_sameDepositTwice() (gas: 150321)
Logs:
second mint for the same deposit: reverted, supply 100000
[PASS] test_5_pause() (gas: 323071)
Logs:
paused: transfers and redemptions both stop
[PASS] test_6_removedFromAllowlist() (gas: 268585)
Logs:
de-listed holder cannot even redeem; balance stuck: 100000
Suite result: ok. 7 passed; 0 failed; 0 skipped; finished in 70.57ms (111.70ms CPU time)في المخرج أعلاه لاحظ التالي:
- اختبار الـ Fuzz نفذ 256 مرة بأرقام مختلفة، وفي كل مرة كان إجمالي الرموز يساوي رصيد البنك بعد الاسترداد. والرمزان
μو~متوسط الغاز ووسيطه عبر هذه المرات. - في دورة الحياة: العميل أودع 1000.00 فصار يملك
100000وحدة، أي بالخانتين العشريتين 1000.00، ودفع 250.00 للتاجر، فطلب التاجر استردادها فصار رصيده صفراً والمحجوز في العقد25000، ثم بعد الدفع والحرق بقي في التداول75000، وهو بالضبط ما بقي من المال في حساب الشركة. - الإصدار المكرر لنفس الإيداع رفض، والإجمالي بقي
100000، أي أن إعادة إرسال الإيداع لا تخلق مالاً. - الإيقاف أوقف التحويل والاسترداد معاً، وهذا قرار يجب أن تكون واعياً به: في حادثة أمنية تريد أن يتوقف كل شيء، ولكن الإيقاف الطويل يعني أن العملاء لا يستطيعون استرداد مالهم، فيجب أن يكون له حد زمني وإعلان واضح.
- الاختبار السادس كشف ما لم نخطط له: العميل الذي أخرج من القائمة لم يعد يستطيع حتى أن يطلب الاسترداد، لأن طلب الاسترداد تحويل من عنوانه إلى العقد، والعنوان لم يعد مسموحاً. فرصيده محبوس، وهذا قد يكون مطلوباً في حالة تجميد بأمر جهة مختصة، وقد يكون ظلماً لعميل انتهت صلاحية وثيقته فقط. والحل التصميمي دالة استرداد إدارية بدور خاص وسجل، أو حالة وسطى في القائمة تسمح بالاسترداد دون التحويل.
النشر على شبكة Anvil: نفس القواعد من خارج العقد
الاختبارات تعمل داخل آلة EVM في الذاكرة، والخطوة التالية أن نرى نفس العقد على شبكة تشبه الحقيقية: عقدة Anvil تستقبل معاملات موقعة وتنتج كتلاً، ونتعامل معها بأداة cast كما يتعامل نظام الخزينة أو تطبيق المحفظة. نشغل Anvil في Container على شبكة Docker خاصة:
docker network create bocoin-net
docker run -d --name bocoin-anvil --network bocoin-net ghcr.io/foundry-rs/foundry:v1.8.6 "anvil --host 0.0.0.0"
docker logs bocoin-anvil 2>&1 | sed -n '/Available Accounts/,/(4)/p'والمخرج سوف يكون كما يلي:
Available Accounts
==================
(0) 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 (10000.000000000000000000 ETH)
(1) 0x70997970C51812dc3A010C7d01b50e0d17dc79C8 (10000.000000000000000000 ETH)
(2) 0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC (10000.000000000000000000 ETH)
(3) 0x90F79bf6EB2c4f870365E785982E1f101E93b906 (10000.000000000000000000 ETH)
(4) 0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65 (10000.000000000000000000 ETH)وهذه حسابات الاختبار المعروفة، وسوف نوزعها على أدوار القصة: الحساب 0 مدير العقد، ويملك أيضاً دوري الامتثال والإيقاف لتبسيط التجربة، والحساب 1 نظام الخزينة الذي يصدر ويحرق، والحساب 2 العميل، والحساب 3 التاجر، والحساب 4 شخص غريب لم يمر بمعرفة العميل. ثم نكتب الخطوات في سكربت lab.sh نشغله من Container آخر على نفس الشبكة، وأوله المتغيرات والنشر:
#!/bin/sh
set -u
export ETH_RPC_URL=http://bocoin-anvil:8545
# anvil's well-known test keys (public, never use them on a real network)
ADMIN_KEY=0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80
TREASURY_KEY=0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d
CUSTOMER_KEY=0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a
ADMIN=$(cast wallet address $ADMIN_KEY)
TREASURY=$(cast wallet address $TREASURY_KEY)
CUSTOMER=$(cast wallet address $CUSTOMER_KEY)
MERCHANT=0x90F79bf6EB2c4f870365E785982E1f101E93b906
STRANGER=0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65
echo "== deploy"
TOKEN=$(forge create src/CompanyToken.sol:CompanyToken --private-key $ADMIN_KEY --broadcast \
--constructor-args $ADMIN | awk '/Deployed to:/ {print $3}')
echo "token: $TOKEN"ثم منح الأدوار، والتحقق منها بقراءة hasRole، ثم إدخال العميل والتاجر في القائمة:
for R in MINTER_ROLE BURNER_ROLE; do
cast send $TOKEN "grantRole(bytes32,address)" $(cast keccak $R) $TREASURY --private-key $ADMIN_KEY > /dev/null
done
for R in KYC_ROLE PAUSER_ROLE; do
cast send $TOKEN "grantRole(bytes32,address)" $(cast keccak $R) $ADMIN --private-key $ADMIN_KEY > /dev/null
done
echo "treasury is minter: $(cast call $TOKEN 'hasRole(bytes32,address)(bool)' $(cast keccak MINTER_ROLE) $TREASURY)"
echo "customer is minter: $(cast call $TOKEN 'hasRole(bytes32,address)(bool)' $(cast keccak MINTER_ROLE) $CUSTOMER)"
cast send $TOKEN "setAllowlisted(address,bool)" $CUSTOMER true --private-key $ADMIN_KEY > /dev/null
cast send $TOKEN "setAllowlisted(address,bool)" $MERCHANT true --private-key $ADMIN_KEY > /dev/nullثم الإصدار مقابل الإيداع DEP-0001، والدفع للتاجر:
cast send $TOKEN "mintForDeposit(address,uint256,bytes32)" $CUSTOMER 100000 $(cast format-bytes32-string DEP-0001) \
--private-key $TREASURY_KEY | grep -E '^(blockNumber|gasUsed|status) '
echo "customer balance: $(cast call $TOKEN 'balanceOf(address)(uint256)' $CUSTOMER)"
cast send $TOKEN "transfer(address,uint256)" $MERCHANT 25000 --private-key $CUSTOMER_KEY | grep -E '^(gasUsed|status) 'ثم شغلنا السكربت:
docker run --rm --name bocoin-cli --network bocoin-net -v "$PWD:/src" ghcr.io/foundry-rs/foundry:v1.8.6 \
"cp -r /src /tmp/boc && cd /tmp/boc && sh lab.sh"وفي أول تشغيل توقف جزء من المخرج برسالة jq: not found، لأننا كتبنا السكربت أولاً بأداة jq لقراءة إيصال المعاملة بصيغة JSON، وصورة Foundry لا تحتوي هذه الأداة. فاستبدلناها بالمخرج النصي لـ cast send مع grep كما في الأسطر أعلاه، وهذا درس صغير في أن أي أداة في سكربت تشغيل يجب أن تكون موجودة في الصورة التي يعمل فيها. وبعد التعديل كان أول المخرج كما يلي:
== deploy
token: 0x5FbDB2315678afecb367f032d93F642f64180aa3
== roles
treasury is minter: true
customer is minter: false
== KYC customer and merchant
== mint 1000.00 for bank deposit DEP-0001
blockNumber 8
gasUsed 100839
status 1 (success)
customer balance: 100000 [1e5]
== customer pays merchant 250.00
gasUsed 59047
status 1 (success)في المخرج أعلاه لاحظ التالي:
- عنوان العقد
0x5FbD…0aa3هو أول عنوان ينشره الحساب 0 على أي شبكة Anvil جديدة، لأن عنوان العقد يحسب من عنوان الناشر وعداده nonce، كما شرحنا في مقال كيف يعمل Ethereum. - الإصدار جاء في الكتلة 8، لأن Anvil ينتج كتلة لكل معاملة: النشر، ثم أربع معاملات لمنح الأدوار، ثم معاملتان للقائمة، ثم الإصدار.
- الإصدار كلف
100839من الغاز والتحويل59047، وأغلب كلفة الإصدار كتابة قيم في خانات تخزين كانت فارغة: رصيد العميل ومرجع الإيداع المستخدم والإجمالي. وعلى شبكة عامة كل وحدة غاز تدفع بعملة الشبكة، فعملة الشركة على شبكة عامة تحتاج من يدفع ثمن كل حركة. - القيمة
100000 [1e5]هي الرصيد بأصغر وحدة، وcastيضيف بين القوسين صيغتها العلمية. والعقد لا يعرف الفاصلة العشرية، فالدالةdecimalsتخبر المحافظ فقط أن تعرض الرقم 1000.00.
حالات الرفض على الشبكة
الآن نجرب الحالات الأربع التي يجب أن يرفضها العقد، بنفس الأدوات التي سوف يستخدمها مهاجم أو موظف أخطأ:
echo "== fail 1: transfer to a stranger"
cast send $TOKEN "transfer(address,uint256)" $STRANGER 100 --private-key $CUSTOMER_KEY 2>&1 | tail -1
echo "== fail 2: customer tries to mint"
cast send $TOKEN "mintForDeposit(address,uint256,bytes32)" $CUSTOMER 99999999 $(cast format-bytes32-string FAKE) \
--private-key $CUSTOMER_KEY 2>&1 | tail -1
echo "== fail 3: same deposit minted twice"
cast send $TOKEN "mintForDeposit(address,uint256,bytes32)" $CUSTOMER 100000 $(cast format-bytes32-string DEP-0001) \
--private-key $TREASURY_KEY 2>&1 | tail -1
echo "== pause, then try to pay"
cast send $TOKEN "pause()" --private-key $ADMIN_KEY > /dev/null
cast send $TOKEN "transfer(address,uint256)" $MERCHANT 100 --private-key $CUSTOMER_KEY 2>&1 | tail -1
cast send $TOKEN "unpause()" --private-key $ADMIN_KEY > /dev/nullوالمخرج سوف يكون كما يلي:
== fail 1: transfer to a stranger
Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0x6f7a5275: 00000000000000000000000015d34aaf54267db7d7c367839aaf71a00a2c6a65, data: "0x6f7a527500000000000000000000000015d34aaf54267db7d7c367839aaf71a00a2c6a65": NotAllowlisted(0x15d34AAf54267DB7D7c367839AAf71A00a2C6A65)
== fail 2: customer tries to mint
Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0xe2517d3f: 0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6, data: "0xe2517d3f0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6": AccessControlUnauthorizedAccount(0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC, 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6)
== fail 3: same deposit minted twice
Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0x43a95064: DEP-0001, data: "0x43a950644445502d30303031000000000000000000000000000000000000000000000000": DepositAlreadyUsed(0x4445502d30303031000000000000000000000000000000000000000000000000)
== pause, then try to pay
Error: Failed to estimate gas: server returned an error response: error code 3: execution reverted: custom error 0xd93c0665, data: "0xd93c0665": EnforcedPauseفي المخرج أعلاه لاحظ التالي:
- كل الرفض حدث في مرحلة تقدير الغاز Gas Estimation، أي أن
castسأل العقدة أولاً «كم ستكلف هذه المعاملة؟»، فنفذتها العقدة تجريبياً وأعادت الرفض، فلم توقع المعاملة ولم تبث أصلاً. والمحافظ تفعل نفس الشيء، ولذلك يرى المستخدم الخطأ قبل أن يدفع شيئاً. - كل خطأ يبدأ بأربعة بايتات هي معرف الخطأ Selector، مثل
0x6f7a5275، ثم بياناته، وcastفكها إلى اسم الخطأ وقيمه لأنه يعرف واجهة العقد من المشروع. أما تطبيق لا يعرف الواجهة فيرى الأرقام فقط، ولذلك يجب أن تنشر واجهة العقد ABI لكل من يتكامل معه. - في الرفض الثاني القيمة الثانية
0x9f2d…56a6هي هاش اسمMINTER_ROLE، وهي نفس القيمة التي رأيناها في الاختبار الفاشل، أي أن العميل حاول دوراً لا يملكه. - في الرفض الثالث فك
castالمرجع المكرر إلى النصDEP-0001، لأن البايتات4445502d30303031هي حروف هذا النص بترميز ASCII. وهذا الرفض أهم الأربعة من جهة المحاسبة، لأنه يمنع خلق مال من إيداع واحد مرتين. EnforcedPauseبلا بيانات، لأنه خطأ من OpenZeppelin لا يحتاج سياقاً.
الاسترداد، وسجل التدقيق
بقي أن يعيد العميل 500.00 ويستلم ماله، ثم نقرأ الأحداث التي تركها العقد:
cast send $TOKEN "requestRedemption(uint256)" 50000 --private-key $CUSTOMER_KEY > /dev/null
echo "escrow in contract: $(cast call $TOKEN 'balanceOf(address)(uint256)' $TOKEN)"
cast send $TOKEN "completeRedemption(uint256,bytes32)" 0 $(cast format-bytes32-string PAY-0001) --private-key $TREASURY_KEY > /dev/null
echo "customer: $(cast call $TOKEN 'balanceOf(address)(uint256)' $CUSTOMER) merchant: $(cast call $TOKEN 'balanceOf(address)(uint256)' $MERCHANT) supply: $(cast call $TOKEN 'totalSupply()(uint256)')"
cast logs --address $TOKEN "MintedForDeposit(address indexed,uint256,bytes32 indexed)" --from-block 0 | grep -E 'blockNumber|data|0x0000000000000000000000003c44|0x4445'
cast logs --address $TOKEN "RedemptionPaid(uint256 indexed,uint256,bytes32 indexed)" --from-block 0 | grep -E 'blockNumber|data|0x5041'والمخرج سوف يكون كما يلي:
escrow in contract: 50000 [5e4]
customer: 25000 [2.5e4] merchant: 25000 [2.5e4] supply: 50000 [5e4]
blockNumber: 8
data: 0x00000000000000000000000000000000000000000000000000000000000186a0
0x0000000000000000000000003c44cdddb6a900fa2b585dd299e03d12fa4293bc
0x4445502d30303031000000000000000000000000000000000000000000000000
param2: 0x4445502d30303031000000000000000000000000000000000000000000000000
blockNumber: 13
data: 0x000000000000000000000000000000000000000000000000000000000000c350
0x5041592d30303031000000000000000000000000000000000000000000000000
param2: 0x5041592d30303031000000000000000000000000000000000000000000000000في المخرج أعلاه لاحظ التالي:
- بعد طلب الاسترداد صار في العقد
50000محجوزة، ثم بعد الدفع والحرق: العميل بقي له 250.00 بعد أن دفع 250.00 للتاجر واسترد 500.00، والتاجر يملك 250.00، والإجمالي في التداول 500.00. وإذا كان حساب الشركة استلم 1000.00 ودفع 500.00 فالثابت صحيح. - حدث الإصدار في الكتلة 8، وقيمته في
dataهي0x186a0، أي 100000 بالنظام العشري، والحقلان المفهرسان Indexed هما عنوان العميل ومرجع الإيداعDEP-0001، فيستطيع المدقق أن يبحث بالمرجع مباشرة ويطابقه مع كشف البنك. - حدث الدفع في الكتلة 13 بقيمة
0xc350، أي 50000، ومرجع الدفعةPAY-0001بنفس الطريقة. فكل ريال دخل وكل ريال خرج له أثر على السلسلة يقابله أثر في البنك، وهذا ما شرحنا أهميته في مقال سجل التدقيق.
والصورة التالية تجمع حالات الرفض كلها من الشبكة ومن الاختبارات:

ماذا يحدث إذا تجاوزنا تقدير الغاز؟ وماذا يرى الغرباء؟
رأينا أن الرفض حدث قبل البث، فماذا لو أرسل أحدهم المعاملة الخاطئة دون أن يسأل العقدة أولاً، كما قد يفعل نظام مكتوب بشكل خاطئ يحدد الغاز يدوياً؟ وماذا يستطيع أي شخص أن يقرأ من العقد دون أي مفتاح؟ كتبنا سكربتاً ثانياً على نفس الشبكة:
cast send $TOKEN "transfer(address,uint256)" $STRANGER 100 --private-key $CUSTOMER_KEY --gas-limit 100000 2>&1 | grep -E '^(blockNumber|gasUsed|status) |Error'
echo "customer balance after: $(cast call $TOKEN 'balanceOf(address)(uint256)' $CUSTOMER)"
echo "is customer KYC'd? $(cast call $TOKEN 'allowlisted(address)(bool)' $CUSTOMER)"
echo "customer balance: $(cast call $TOKEN 'balanceOf(address)(uint256)' $CUSTOMER)"
cast logs --address $TOKEN "Transfer(address indexed,address indexed,uint256)" --from-block 0 | grep -cE 'transactionHash'والمخرج سوف يكون كما يلي:
blockNumber 14
gasUsed 27276
status 0 (failed)
customer balance after: 25000 [2.5e4]
is customer KYC'd? true
customer balance: 25000 [2.5e4]
4في المخرج أعلاه لاحظ التالي:
- مع
--gas-limitلم يقدرcastالغاز، فوقع المعاملة وبثها، فدخلت الكتلة 14 وفشلت بالحالة0، واستهلكت27276من الغاز دفعها العميل، والرصيد لم يتغير. أي أن العقد حمى القاعدة حتى عندما وصلت المعاملة للسلسلة، ولكن الفشل نفسه كلف مالاً وترك أثراً دائماً، وعلى شبكة عامة يدفع ثمنه بعملة الشبكة. - السطران التاليان قرأهما من لا يملك أي مفتاح: هل هذا العنوان مر بمعرفة العميل، وكم رصيده. والسطر الأخير أن في السجل 4 أحداث تحويل: الإصدار، والدفع للتاجر، والحجز في العقد، والحرق. وكل ذلك يقرؤه أي شخص يصل إلى العقدة، وهذا على شبكة عامة يعني أي شخص في العالم، وهو بالضبط قرار الخصوصية الذي ناقشناه.
ما الذي تعلمناه من النموذج؟
النموذج يعمل، وكل قاعدة كتبناها رفضت ما يجب أن ترفضه. ولكن أهم ما فيه ما لا يستطيع الكود أن يفعله. فالعقد لا يعرف هل وصل الإيداع DEP-0001 إلى البنك فعلاً، وإنما يصدق نظام الخزينة الذي يملك دور الإصدار، فإذا سرق مفتاح هذا النظام أو أخطأ موظف في المبلغ صدرت رموز بلا مال. ولا يعرف هل دفعت المالية PAY-0001 فعلاً قبل الحرق. ولا يعرف من هو العميل خلف العنوان. فكل ضمان في العقد مبني على ضمان خارجه: مطابقة يومية بين الأحداث وكشف البنك، ومفاتيح محمية، وموافقات من أكثر من شخص، وتدقيق مستقل. وهذا ما يجعل «العملة» نظاماً تشغيلياً ومالياً كاملاً، والعقد جزءاً صغيراً منه.
ولاحظ أيضاً أن كل ما فعله العقد كان يمكن أن تفعله قاعدة بيانات بنفس القواعد: جدول أرصدة، وقيود، ومفتاح منع تكرار، وصلاحيات، وسجل أحداث. والذي يضيفه السجل الموزع هو أن يرى الشركاء نفس السجل دون أن يثقوا بقاعدة بيانات الشركة، وهذا لا يفيد إلا إذا وجد شركاء مستقلون يحتاجون ذلك، وإذا قبلوا أن يروا أرصدة بعضهم، أو صممت الشبكة لتخفيها.
التشغيل: ما الذي لا يظهر في العرض؟
إذا قررت الشركة بعد الاستشارة أن تمضي في أي خيار فيه رصيد مقابل مال، فهذه الأعمال اليومية التي يجب أن تكون مخططة قبل الإطلاق:
- المطابقة اليومية Reconciliation: سكربت يجمع أحداث الإصدار والحرق ويطابقها مع كشف الحساب البنكي، ويطابق الإجمالي في التداول مع رصيد الاحتياطي، وينبه عند أي فرق. وقد بنينا هذه المطابقة على دفتر قيد في مقال المحفظة الرقمية، والفكرة نفسها هنا.
- دورة حياة المفاتيح: من يملك كل دور، وأين يحفظ مفتاحه، وكيف يسحب الدور إذا ترك الموظف الشركة أو سرق المفتاح. والدالة
revokeRoleفي AccessControl تسحب الدور فوراً، ولكن يجب أن يملكها مفتاح آخر غير المسروق. - الحوادث: متى يضغط زر الإيقاف ومن يقرر، وكيف يبلغ العملاء، وكم يستمر الإيقاف قبل أن يصبح منعاً للعملاء من مالهم.
- الترقية: العقد في التجربة لا يتغير بعد نشره، وأي خطأ فيه يحتاج عقداً جديداً وترحيل الأرصدة، والعقود القابلة للترقية Proxy تحل ذلك وتضيف خطراً جديداً، كما رأينا في مقال العقود الذكية.
- التدقيق الأمني: أي عقد يحمل مالاً حقيقياً يحتاج مراجعة متخصصة قبل الإطلاق، وأصناف الثغرات المعروفة في مقال أمن العقود الذكية، وأخطرها في رمز كهذا صنف التحكم في الصلاحيات.
أخطاء شائعة وكيف تلاحظها
- تسمية الرمز «ريالاً» أو ربطه باسم المملكة: الإصدار الرسمي للبنك المركزي بالنص، وبيان وزارة المالية 2019 صريح في استخدام اسم العملة والشعار. وتلاحظه في أول شريحة من العرض التسويقي.
- البدء بالتقنية: فريق يقارن الشبكات قبل أن يعرف هل المنتج نقاط ولاء أم نقود إلكترونية. وتلاحظه عندما لا يستطيع أحد أن يجيب: هل يسترد العميل ماله؟
- الوعد بأن «العملة» سيرتفع سعرها: النقود الإلكترونية المنظمة تصدر وتسترد بالقيمة الاسمية ودون فائدة، والرمز الذي يباع على أنه استثمار شيء آخر تماماً بمتطلبات أخرى.
- إصدار بلا مرجع: دالة
mintتقبل أي مبلغ دون ربطه بإيداع، فلا يستطيع أحد أن يطابق الإجمالي مع البنك. وتلاحظه عندما يسألك المدقق «ما الإيداع الذي يقابل هذا الإصدار؟». - الحرق قبل الدفع: العميل يخسر رمزه إذا فشل التحويل البنكي، والحل الحجز ثم الحرق كما في التجربة.
- مفتاح واحد يملك كل الأدوار: في التجربة جمعنا دورين في حساب واحد للتبسيط، وفي الإنتاج كل دور عند جهة مختلفة، والمدير خلف توقيع متعدد.
- اختبار رفض لا يحدد الخطأ: كما رأينا في أول تشغيل،
expectRevertدون الخطأ المحدد قد ينجح لسبب خاطئ. - نسيان أثر التجميد على الاسترداد: إخراج عنوان من القائمة يحبس رصيده، فيجب أن يوجد مسار استرداد محكوم للحسابات المجمدة.
- اعتبار السلسلة العامة سجلاً خاصاً: كل رصيد وكل عضوية في القائمة يقرؤها أي شخص، كما رأينا في سؤال «من مر بمعرفة العميل؟».
جدول القرار وقائمة التحقق
الجدول التالي يلخص الإجابة عن سؤال المقال بحسب الحالة:
| إذا كنت تريد | فالخيار | السجل | الجهة التي تسألها أولاً |
|---|---|---|---|
| عملة وطنية | لا يصدرها إلا البنك المركزي، ودورك المشاركة في تجاربه إذا دعيت | يقرره البنك المركزي | البنك المركزي السعودي |
| أن يعود العميل للشراء | نقاط ولاء | قاعدة بيانات الشركة | المحاسب، لتسجيل الالتزام |
| أن يدفع العميل مسبقاً ويستخدم رصيده داخل المجموعة | رصيد مغلق | دفتر قيد مزدوج | مستشار نظامي، والبنك المركزي للتسجيل والحدود |
| أن يستخدم الرصيد عند الغير أو يحول أو يسترد | نقود إلكترونية عبر جهة مرخصة أو شريك مرخص | دفتر الجهة المرخصة | البنك المركزي السعودي |
| تسوية أسرع مع شركاء مستقلين | ودائع مرمزة أو حل تسوية من بنكك | شبكة مصرح بها أو منصة البنك | بنكك، ثم المستشار |
| تمويل بحصص في أصل | أصل مرمز | شبكة مصرح بها بقواعد تحويل | هيئة السوق المالية |
| رمز يرتفع سعره | لا يحقق حاجة للشركة، وعليه تحذيرات رسمية صريحة | لا ينصح به | اقرأ بيانات التحذير الرسمية أولاً |
وقبل أي خطوة في مشروع «عملة الشركة»، راجع هذه القائمة:
- كتبنا ما نريده بجملة واحدة: خصم، أم رصيد مسبق، أم تسوية، أم حصة في أصل.
- أجبنا كتابياً: هل يدفع العميل مالاً؟ هل يستخدم عند غير الشركة؟ هل يحول أو يسترد؟
- عرضنا الإجابات على مستشار نظامي وعلى الجهة المختصة قبل الكود.
- لا يحمل المنتج اسم العملة الوطنية ولا شعار المملكة، ولا يسوق على أنه استثمار.
- طبقنا اختبار الأسئلة الخمسة، ولدينا سبب مكتوب لأي سجل غير قاعدة البيانات.
- كل إصدار مرتبط بإيداع، وكل حرق مرتبط بدفعة، ولكل منهما مرجع لا يتكرر.
- الأموال المقابلة في حساب منفصل، ومطابقة يومية آلية بين السجل والبنك مع تنبيه.
- الأدوار مفصولة، ومفتاح المدير خلف توقيع متعدد، وإجراء مكتوب لسحب الأدوار.
- سياسة مكتوبة للتجميد والاستعادة والاسترداد للحسابات المجمدة.
- البيانات الشخصية خارج السجل، وقرار الخصوصية موثق.
- اختبارات رفض لكل قاعدة بخطئها المحدد، واختبار للثابت بين الإجمالي والاحتياطي.
- مراجعة أمنية مستقلة قبل أي مال حقيقي.
حذف التجربة
docker rm -f bocoin-anvil
docker network rm bocoin-net
docker image rm ghcr.io/foundry-rs/foundry:v1.8.6تدريب عملي
- أضف دالة
forceRedeemبدور جديدRECOVERY_ROLEتسمح باسترداد رصيد عنوان أخرج من القائمة إلى الحجز، واكتب اختباراً يبين أن الرصيد المحبوس في الاختبار السادس يمكن أن يسترد بها، وأن أحداً غير صاحب الدور لا يستطيع ذلك. - أضف حداً أقصى لرصيد أي عنوان، كما في حدود المحافظ، واكتب اختباراً يرفض التحويل الذي يتجاوزه.
- اكتب سكربت مطابقة يقرأ أحداث
MintedForDepositوRedemptionPaidمن Anvil، ويطابقها مع ملف CSV يمثل كشف البنك، ويطبع أي مرجع موجود في جهة دون الأخرى. - ابن نفس القواعد على جدول في PostgreSQL بقيود ومفتاح منع تكرار، وقارن بين الحلين في الخصوصية والتكلفة وسهولة تصحيح الخطأ.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- العملة الوطنية يصدرها البنك المركزي وحده بنص نظامه، وصورتها الرقمية هي العملة الرقمية للبنك المركزي، والبنك المركزي السعودي أعلن تجارب ولم يتخذ قراراً بالإصدار، ودور المهندس المشاركة في بناء أنظمتها لا إصدارها.
- الكود لا يصنع مالاً رسمياً، لأن الوحدة والمرونة والنزاهة تأتي من مؤسسات وأنظمة، لا من عقد.
- تحت اسم «عملة الشركة» خمسة أشياء مختلفة، وأقربها لحاجة الشركات نقاط الولاء والرصيد المغلق، ورمز التسوية بين الشركاء الأنسب أن يصدره بنك أو جهة مرخصة، والأصل المرمز يمر بهيئة السوق المالية، والرمز العام أبعدها عن الحاجة.
- قيمة أي عملة من الاحتياطي والاسترداد والثقة، وحالات UST وBUSD تبين ما يحدث عندما يسقط أحدها أو يتدخل المنظم أو تنسخ العملة على شبكة أخرى.
- اللائحة التنفيذية لنظام المدفوعات تعرف النقود الإلكترونية والحساب المغلق والشبكة المحدودة، وتطلب الإصدار والاسترداد بالقيمة الاسمية وحماية الأموال، والتسجيل والإبلاغ عند حدود معينة للشبكة المحدودة.
- قرارات التصميم واحدة في كل عملة: فصل أدوار الإصدار والحرق، والاحتياطي والاسترداد، ومعرفة العميل، والتجميد، والخصوصية، والعمل دون اتصال، والتوافق، ثم السجل.
- في التجربة رفض العقد التحويل لغير المسموح والإصدار دون دور والإيداع المكرر والحركة أثناء الإيقاف، وكشف أن الإخراج من القائمة يحبس الرصيد، وأن كل ضمان فيه يعتمد على ضمان خارجه.
دليلالعملات الرقمية للبنوك المركزية CBDC: من مشروع عابر إلى اليومما هي العملة الرقمية للبنك المركزي، وما الذي جربه مشروع عابر بين المملكة والإمارات، وما الذي قاله البنك المركزي السعودي رسمياً.
دليلالعملات المستقرة Stablecoins: كيف تثبت ومتى تنهار؟ثلاث طرق لتثبيت القيمة عند دولار، نرى التصفية بأرقامها، ونحاكي دوامة الموت التي أسقطت UST.
دليلما هي الرموز Tokens؟ معيار ERC-20 ورموز NFTما الذي تملكه فعلاً عندما تقول المحفظة إن لديك رمزاً، ولماذا يحتاج العقد إذناً، وكيف تعلق الرموز داخل العقود إلى الأبد.
دليلهل تحتاج بلوكتشين أم قاعدة بيانات؟ اختبار من خمسة أسئلةخمسة أسئلة تحدد هل يحتاج مشروعك بلوكتشين، مع تجربة تبني البديل الأرخص في PostgreSQL وتقيس تكلفة العقد الذكي.
دليلشبكة بلوكتشين خاصة للمؤسسات: Besu أم Fabric؟كيف تبني شبكة خاصة من أربع شركات بإجماع QBFT، وماذا يحدث عندما يتوقف أعضاؤها، ومتى تختار Fabric أو قاعدة بيانات.
دليلالمحافظ الرقمية Digital Wallets: الأنواع والترخيص والبناءأنواع المحافظ الرقمية وتراخيص النقود الإلكترونية في المملكة وحدودها، وحماية أموال العملاء، مع مختبر للحدود والمطابقة اليومية.سجل التحديثات
- أكتوبر 2026: كتابة المقال، واختبار التجربة على Docker Engine 29.8.0 مع Foundry v1.8.6 (forge وanvil وcast) وSolc 0.8.30 وOpenZeppelin Contracts 5.7.0 وforge-std v1.17.0.