رعاية رسوم الغاز: كيفية تغطية تكاليف المعاملات لمستخدميك
المقدمة
إذا أردنا أن تخدم إيثيريوم مليار شخص إضافي (opens in a new tab)، فنحن بحاجة إلى إزالة العقبات وجعلها سهلة الاستخدام قدر الإمكان. أحد مصادر هذه العقبات هو الحاجة إلى ETH لدفع رسوم الغاز.
إذا كان لديك تطبيق لامركزي (dapp) يدر أرباحًا من المستخدمين، فقد يكون من المنطقي السماح للمستخدمين بإرسال المعاملات من خلال خادمك ودفع رسوم المعاملة بنفسك. نظرًا لأن المستخدمين لا يزالون يوقعون على رسالة تفويض EIP-712 (opens in a new tab) في محافظهم، فإنهم يحتفظون بضمانات النزاهة الخاصة بإيثيريوم. يعتمد التوافر على الخادم الذي ينقل المعاملات، لذا فهو محدود أكثر. ومع ذلك، يمكنك إعداد الأمور بحيث يمكن للمستخدمين أيضًا الوصول إلى العقد الذكي مباشرة (إذا حصلوا على ETH)، والسماح للآخرين بإعداد خوادمهم الخاصة إذا أرادوا رعاية المعاملات.
التقنية الموضحة في هذا البرنامج التعليمي تعمل فقط عندما تتحكم في العقد الذكي. هناك تقنيات أخرى، بما في ذلك تجريد الحساب (opens in a new tab) التي تتيح لك رعاية المعاملات لعقود ذكية أخرى، والتي آمل أن أغطيها في برنامج تعليمي مستقبلي.
ملاحظة: هذا ليس كودًا جاهزًا للإنتاج. إنه عرضة لهجمات كبيرة ويفتقر إلى ميزات رئيسية. تعرف على المزيد في قسم نقاط الضعف في هذا الدليل.
المتطلبات الأساسية
لفهم هذا البرنامج التعليمي، يجب أن تكون على دراية مسبقة بما يلي:
- Solidity
- JavaScript
- React و WAGMI. إذا لم تكن على دراية بأدوات واجهة المستخدم هذه، فلدينا برنامج تعليمي لذلك.
التطبيق النموذجي
التطبيق النموذجي هنا هو متغير من عقد Greeter الخاص بـ Hardhat. يمكنك رؤيته على GitHub (opens in a new tab). تم نشر العقد الذكي بالفعل على Sepolia (opens in a new tab)، على العنوان 0xC87506C66c7896366b9E988FE0aA5B6dDE77CFfA (opens in a new tab).
لرؤيته قيد العمل، اتبع هذه الخطوات.
-
استنسخ المستودع وقم بتثبيت البرامج اللازمة.
1git clone https://github.com/qbzzt/260301-gasless.git2cd 260301-gasless/server3npm install -
قم بتحرير
.envلتعيينPRIVATE_KEYإلى محفظة تحتوي على ETH على Sepolia. إذا كنت بحاجة إلى Sepolia ETH، استخدم صنبورًا. من الناحية المثالية، يجب أن يكون هذا المفتاح الخاص مختلفًا عن المفتاح الموجود في محفظة متصفحك. -
ابدأ تشغيل الخادم.
1npm run dev -
تصفح التطبيق على الرابط
http://localhost:5173(opens in a new tab). -
انقر على Connect with Injected للاتصال بمحفظة. قم بالموافقة في المحفظة، ووافق على التغيير إلى Sepolia إذا لزم الأمر.
-
اكتب تحية جديدة وانقر على Update greeting via sponsor.
-
وقع الرسالة.
-
انتظر حوالي 12 ثانية (وقت الكتلة على Sepolia). أثناء الانتظار، يمكنك إلقاء نظرة على الرابط في وحدة تحكم الخادم لرؤية المعاملة.
-
لاحظ أن التحية قد تغيرت، وأن قيمة العنوان الذي قام بآخر تحديث أصبحت الآن عنوان محفظة متصفحك.
لفهم كيفية عمل ذلك، نحتاج إلى إلقاء نظرة على كيفية إنشاء الرسالة في واجهة المستخدم، وكيف يتم نقلها بواسطة الخادم، وكيف يعالجها العقد الذكي.
واجهة المستخدم
تعتمد واجهة المستخدم على WAGMI (opens in a new tab)؛ يمكنك القراءة عنها في هذا البرنامج التعليمي.
إليك كيفية توقيع الرسالة:
1const signGreeting = useCallback(يتيح لنا خطاف React (React hook) useCallback (opens in a new tab) تحسين الأداء عن طريق إعادة استخدام نفس الدالة عند إعادة رسم المكون.
1 async (greeting) => {2 if (!account) throw new Error("Wallet not connected")إذا لم يكن هناك حساب، قم بإثارة خطأ. لا ينبغي أن يحدث هذا أبدًا لأن زر واجهة المستخدم الذي يبدأ العملية التي تستدعي signGreeting يكون معطلاً في هذه الحالة. ومع ذلك، قد يقوم المبرمجون المستقبليون بإزالة هذا الإجراء الوقائي، لذا من الجيد التحقق من هذا الشرط هنا أيضًا.
1 const domain = {2 name: "Greeter",3 version: "1",4 chainId,5 verifyingContract: contractAddr,6 }معلمات فاصل النطاق (domain separator) (opens in a new tab). هذه القيمة ثابتة، لذا في تنفيذ محسّن بشكل أفضل، قد نحسبها مرة واحدة بدلاً من إعادة حسابها في كل مرة يتم فيها استدعاء الدالة.
nameهو اسم يمكن للمستخدم قراءته، مثل اسم التطبيق اللامركزي (dapp) الذي ننتج التوقيعات من أجله.versionهو الإصدار. الإصدارات المختلفة غير متوافقة.chainIdهي السلسلة التي نستخدمها، كما توفرها WAGMI (opens in a new tab).verifyingContractهو عنوان العقد الذي سيتحقق من هذا التوقيع. لا نريد أن ينطبق نفس التوقيع على عقود متعددة، في حال كان هناك عدة عقودGreeterونريد أن يكون لها تحيات مختلفة.
1
2 const types = {3 GreetingRequest: [4 { name: "greeting", type: "string" },5 ],6 }نوع البيانات الذي نوقعه. هنا، لدينا معلمة واحدة، greeting، ولكن الأنظمة في الحياة الواقعية عادة ما تحتوي على المزيد.
1 const message = { greeting }الرسالة الفعلية التي نريد توقيعها وإرسالها. greeting هو اسم الحقل واسم المتغير الذي يملأه في نفس الوقت.
1 const signature = await signTypedDataAsync({2 domain,3 types,4 primaryType: "GreetingRequest",5 message,6 })الحصول على التوقيع فعليًا. هذه الدالة غير متزامنة (asynchronous) لأن المستخدمين يستغرقون وقتًا طويلاً (من منظور الكمبيوتر) لتوقيع البيانات.
1 const r = `0x${signature.slice(2, 66)}`2 const s = `0x${signature.slice(66, 130)}`3 const v = parseInt(signature.slice(130, 132), 16)4
5 return {6 req: { greeting },7 v,8 r,9 s,10 }11 },تُرجع الدالة قيمة سداسية عشرية واحدة. هنا نقسمها إلى حقول.
1 [account, chainId, contractAddr, signTypedDataAsync],2)إذا تغير أي من هذه المتغيرات، قم بإنشاء مثيل جديد للدالة. يمكن للمستخدم تغيير المعلمتين account و chainId في المحفظة. contractAddr هي دالة لمعرف السلسلة (chain Id). لا ينبغي أن يتغير signTypedDataAsync، لكننا نستورده من خطاف (hook) (opens in a new tab)، لذلك لا يمكننا التأكد، ومن الأفضل إضافته هنا.
الآن بعد أن تم توقيع التحية الجديدة، نحتاج إلى إرسالها إلى الخادم.
1 const sponsoredGreeting = async () => {2 try {تأخذ هذه الدالة توقيعًا وترسله إلى الخادم.
1 const signedMessage = await signGreeting(newGreeting)2 const response = await fetch("/server/sponsor", {أرسل إلى المسار /server/sponsor في الخادم الذي أتينا منه.
1 method: "POST",2 headers: { "Content-Type": "application/json" },3 body: JSON.stringify(signedMessage),4 })استخدم POST لإرسال المعلومات مشفرة بتنسيق JSON.
1 const data = await response.json()2 console.log("Server response:", data)3 } catch (err) {4 console.error("Error:", err)5 }6 }إخراج الاستجابة. في نظام الإنتاج، سنعرض الاستجابة للمستخدم أيضًا.
الخادم
أحب استخدام Vite (opens in a new tab) كواجهة أمامية. فهو يخدم مكتبات React تلقائيًا ويحدث المتصفح عندما يتغير كود الواجهة الأمامية. ومع ذلك، لا يتضمن Vite أدوات الواجهة الخلفية.
الحل موجود في index.js (opens in a new tab).
1 app.post("/server/sponsor", async (req, res) => {2 ...3 })4
5 // دع Vite يتعامل مع كل شيء آخر6 const vite = await createViteServer({7 server: { middlewareMode: true }8 })9
10 app.use(vite.middlewares)أولاً، نقوم بتسجيل معالج للطلبات التي نتعامل معها بأنفسنا (POST إلى /server/sponsor). ثم نقوم بإنشاء واستخدام خادم Vite للتعامل مع جميع الروابط الأخرى.
1 app.post("/server/sponsor", async (req, res) => {2 try {3 const signed = req.body4
5 const txHash = await sepoliaClient.writeContract({6 address: greeterAddr,7 abi: greeterABI,8 functionName: 'sponsoredSetGreeting',9 args: [signed.req, signed.v, signed.r, signed.s],10 })11 } ...12 })هذا مجرد استدعاء قياسي لسلسلة الكتل باستخدام viem (opens in a new tab).
العقد الذكي
أخيرًا، يحتاج Greeter.sol (opens in a new tab) إلى التحقق من التوقيع.
1 constructor(string memory _greeting) {2 greeting = _greeting;3
4 DOMAIN_SEPARATOR = keccak256(5 abi.encode(6 keccak256(7 "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"8 ),9 keccak256(bytes("Greeter")),10 keccak256(bytes("1")),11 block.chainid,12 address(this)13 )14 );15 }يقوم المُنشئ بإنشاء فاصل النطاق (opens in a new tab)، على غرار كود واجهة المستخدم أعلاه. التنفيذ على سلسلة الكتل أكثر تكلفة بكثير، لذا نحسبه مرة واحدة فقط.
1 struct GreetingRequest {2 string greeting;3 }هذا هو الهيكل الذي يتم توقيعه. هنا لدينا حقل واحد فقط.
1 bytes32 private constant GREETING_TYPEHASH =2 keccak256("GreetingRequest(string greeting)");هذا هو معرف الهيكل (opens in a new tab). يتم حسابه في كل مرة في واجهة المستخدم.
1 function sponsoredSetGreeting(2 GreetingRequest calldata req,3 uint8 v,4 bytes32 r,5 bytes32 s6 ) external {تتلقى هذه الدالة طلبًا موقعًا وتقوم بتحديث التحية.
1 // حساب ملخص EIP-7122 bytes32 digest = keccak256(3 abi.encodePacked(4 "\x19\x01",5 DOMAIN_SEPARATOR,6 keccak256(7 abi.encode(8 GREETING_TYPEHASH,9 keccak256(bytes(req.greeting))10 )11 )12 )13 );قم بإنشاء الملخص (digest) وفقًا لـ EIP 712 (opens in a new tab).
1 // استرداد المُوقّع2 address signer = ecrecover(digest, v, r, s);3 require(signer != address(0), "Invalid signature");استخدم ecrecover (opens in a new tab) للحصول على عنوان المُوقّع. لاحظ أن التوقيع السيئ يمكن أن يؤدي إلى عنوان صالح، ولكنه سيكون عنوانًا عشوائيًا.
1 // تطبيق التحية كما لو أن المُوقّع استدعاها2 greeting = req.greeting;3 emit SetGreeting(signer, req.greeting);4 }تحديث التحية.
نقاط الضعف
هذا ليس كودًا جاهزًا للإنتاج. إنه عرضة لهجمات كبيرة ويفتقر إلى ميزات رئيسية. إليك بعضها، إلى جانب كيفية حلها.
لرؤية بعض هذه الهجمات، انقر على الأزرار الموجودة أسفل عنوان الهجمات (Attacks) وشاهد ما يحدث. بالنسبة لزر توقيع غير صالح (Invalid signature)، تحقق من وحدة تحكم الخادم لرؤية استجابة المعاملة.
حجب الخدمة على الخادم
أسهل هجوم هو هجوم حجب الخدمة (denial-of-service) (opens in a new tab) على الخادم. يتلقى الخادم طلبات من أي مكان على الإنترنت وبناءً على تلك الطلبات يرسل المعاملات. لا يوجد أي شيء يمنع المهاجم من إصدار مجموعة من التوقيعات، سواء كانت صالحة أو غير صالحة. كل منها سيتسبب في معاملة. في النهاية، سينفد ETH من الخادم لدفع ثمن الغاز.
أحد الحلول لهذه المشكلة هو الحد من المعدل إلى معاملة واحدة لكل كتلة. إذا كان الغرض هو إظهار التحيات إلى الحسابات المملوكة خارجيًا، فلا يهم ما هي التحية في منتصف الكتلة على أي حال.
حل آخر هو تتبع العناوين والسماح فقط بالتوقيعات من العملاء الصالحين.
توقيعات التحية الخاطئة
عندما تنقر على توقيع لتحية خاطئة (Signature for wrong greeting)، فإنك ترسل توقيعًا صالحًا لعنوان معين (0xaA92c5d426430D4769c9E878C1333BDe3d689b3e) وتحية (Hello). لكنه يرسله مع تحية مختلفة. هذا يربك ecrecover، والذي يغير التحية ولكن بعنوان خاطئ.
لحل هذه المشكلة، أضف العنوان إلى الهيكل الموقع (opens in a new tab). بهذه الطريقة، لن يتطابق العنوان العشوائي لـ ecrecover مع العنوان الموجود في التوقيع، وسيرفض العقد الذكي الرسالة.
هجمات إعادة الإرسال
عندما تنقر على هجوم إعادة الإرسال (Replay attack)، فإنك ترسل نفس التوقيع "أنا 0xaA92c5d426430D4769c9E878C1333BDe3d689b3e، وأود أن تكون التحية Hello"، ولكن مع التحية الصحيحة. نتيجة لذلك، يعتقد العقد الذكي أن العنوان (الذي ليس لك) قد أعاد تغيير التحية إلى Hello. المعلومات اللازمة للقيام بذلك متاحة للجمهور في معلومات المعاملة (opens in a new tab).
إذا كانت هذه مشكلة، فإن أحد الحلول هو إضافة رقم فريد (opens in a new tab). قم بإنشاء تخطيط (mapping) (opens in a new tab) بين العناوين والأرقام، وأضف حقل رقم فريد إلى التوقيع. إذا كان حقل الرقم الفريد يتطابق مع التخطيط للعنوان، فاقبل التوقيع وقم بزيادة التخطيط للمرة القادمة. إذا لم يتطابق، ارفض المعاملة.
حل آخر هو إضافة طابع زمني إلى البيانات الموقعة وقبول التوقيع كصالح فقط لبضع ثوانٍ بعد ذلك الطابع الزمني. هذا أبسط وأرخص، لكننا نخاطر بهجمات إعادة الإرسال ضمن النافذة الزمنية، وفشل المعاملات المشروعة إذا تم تجاوز النافذة الزمنية.
ميزات أخرى مفقودة
هناك ميزات إضافية سنضيفها في بيئة الإنتاج.
الوصول من خوادم أخرى
حاليًا، نسمح لأي عنوان بإرسال sponsorSetGreeting. قد يكون هذا بالضبط ما نريده، من أجل اللامركزية. أو ربما نريد التأكد من أن المعاملات المدعومة تمر عبر خادمنا نحن، وفي هذه الحالة سنتحقق من msg.sender في العقد الذكي.
في كلتا الحالتين، يجب أن يكون هذا قرار تصميم واعيًا، وليس مجرد نتيجة لعدم التفكير في المشكلة.
معالجة الأخطاء
يرسل المستخدم تحية. ربما يتم تحديثها في الكتلة التالية. ربما لا. الأخطاء غير مرئية. في نظام الإنتاج، يجب أن يكون المستخدم قادرًا على التمييز بين هذه الحالات:
- لم يتم إرسال التحية الجديدة بعد
- تم إرسال التحية الجديدة، وهي قيد المعالجة
- تم رفض التحية الجديدة
الخاتمة
في هذه المرحلة، يجب أن تكون قادرًا على إنشاء تجربة بدون غاز لمستخدمي تطبيقك اللامركزي (dapp)، على حساب بعض المركزية.
ومع ذلك، يعمل هذا فقط مع العقود الذكية التي تدعم ERC-712. لتحويل رمز مميز ERC-20، على سبيل المثال، من الضروري أن يتم توقيع المعاملة من قبل المالك بدلاً من مجرد رسالة. الحل هو تجريد الحساب (ERC-4337) (opens in a new tab). آمل أن أكتب برنامجًا تعليميًا مستقبليًا حول هذا الموضوع.
انظر هنا للمزيد من أعمالي (opens in a new tab).
آخر تحديث للصفحة: 3 مارس 2026