تخطي إلى المحتوى الرئيسي

السماح لمستخدميك بدون غاز بالاحتفاظ بالرموز المميزة واستدعاء العقود

بدون غاز
⁦ERC-20⁩
تجريد الحساب
متوسط
أوري بوميرانتس
1 أبريل 2026
16 دقيقة للقراءة

مقدمة

ناقش مقال سابق استخدام الوصول بدون غاز إلى تطبيقك الخاص باستخدام توقيعات EIP-712، ولكنه يقتصر على عقودك الذكية الخاصة. باستخدام تجريد الحساب، يمكننا إنشاء محافظ عقود ذكية تقبل نوعين من المعاملات وترحيلها إلى الوجهة المطلوبة:

  • المعاملات المرسلة بواسطة حساب مملوك خارجيًا (EOA) محدد (والتي تتطلب أن يمتلك هذا الحساب ETH)
  • المعاملات المرسلة من أي مكان، ولكنها موقعة بواسطة نفس الحساب المملوك خارجيًا (EOA).

بهذه الطريقة، يمكننا توفير طريقة بدون غاز لحساب ما للاحتفاظ بالأصول (الرموز المميزة، وما إلى ذلك) وأداء جميع الوظائف التي يمكن لحساب مملوك خارجيًا (EOA) يمتلك غازًا القيام بها.

لماذا لا يمكننا ببساطة ترحيل الطلب؟

في معايير ERC-20 والمعايير ذات الصلة، مالك الحساب هو msg.sender (opens in a new tab)، وهو العنوان الذي استدعى عقد الرمز المميز، والذي ليس بالضرورة منشئ المعاملة، tx.origin (opens in a new tab). هذا مطلوب لأسباب أمنية (opens in a new tab). هذا يعني أنه إذا قمنا بترحيل طلبات تحويل الرموز المميزة، فستحاول تحويل الرموز المميزة من عنوان المُرحّل بدلاً من عنوان يتحكم فيه المستخدم.

هناك حل يتيح لك استخدام عنوان الحساب المملوك خارجيًا (EOA) عبر EIP-7702 (opens in a new tab)، ولكنه يتطلب توقيع تفويض قد يكون خطيرًا، لذلك لا يمكنك استخدامه إلا للتفويض إلى عقد ذكي يوافق عليه مزود المحفظة. في هذا البرنامج التعليمي، أفضل الطريقة الأبسط بكثير المتمثلة في إنشاء عقد ذكي كوكيل للمستخدم.

رؤية ذلك عمليًا

  1. تأكد من أن لديك كل من Node (opens in a new tab) و Foundry (opens in a new tab).

  2. استنسخ التطبيق وقم بتثبيت البرامج اللازمة.

    git clone https://github.com/qbzzt/260315-gasless-tokens.git
    cd 260315-gasless-tokens
    forge build
    cd server
    npm install
    
  3. قم بتحرير .env لتعيين SEPOLIA_PRIVATE_KEY إلى محفظة تحتوي على ETH على شبكة Sepolia. إذا كنت بحاجة إلى ETH على Sepolia، استخدم صنبورًا للحصول عليه. من الناحية المثالية، يجب أن يكون هذا المفتاح الخاص مختلفًا عن المفتاح الموجود في محفظة متصفحك.

  4. ابدأ تشغيل الخادم.

    npm run dev
    
  5. تصفح التطبيق على الرابط http://localhost:5173 (opens in a new tab).

  6. انقر على Connect with Injected للاتصال بمحفظة. وافق في المحفظة، ووافق على التغيير إلى شبكة Sepolia إذا لزم الأمر.

  7. قم بالتمرير لأسفل وانقر على Deploy UserProxy (slow process).

  8. يمكنك معرفة متى يتم نشر وكيل المستخدم لوجود عنوان بجوار UserProxy access. إذا انتظرت 24 ثانية (كتلتين) ولم يحدث ذلك بعد، فقد تكون هناك مشكلة في اكتشاف التغييرات.

    إذا كان الأمر كذلك، فانتقل إلى مستكشف الكتل لشبكة Sepolia (opens in a new tab) وأدخل تجزئة المعاملة الخاصة بالنشر التي تراها في مخرجات الخادم عند npm run dev. انقر على العقد الذي تم إنشاؤه لعرض عنوانه، ثم انسخه. الصق العنوان في حقل Or enter existing proxy address، ثم انقر على Set proxy address.

  9. انقر على Request more tokens for proxy لإرسال استدعاء إلى دالة faucet (opens in a new tab) الخاصة بعقد ERC-20 للحصول على الرموز المميزة. أكد التوقيع في المحفظة. بالطبع، تصل الرموز المميزة إلى عنوان الوكيل، وليس عنوان المستخدم.

  10. قم بالتمرير لأسفل وانقر على الرابط الموجود أسفل Last transaction:. سيؤدي هذا إلى فتح المتصفح ليعرض لك معاملة faucet.

  11. في حقل amount to transfer، أدخل رقمًا بين واحد وألف. انقر على Transfer لتحويل الرموز المميزة إلى عنوانك الخاص. قبل النقر على تأكيد للطلب، لاحظ أن البيانات التي يتم توقيعها مبهمة. سيواجه المستخدمون صعوبة في فهم ما يوقعون عليه. تذكر أننا سنناقش ذلك أدناه.

  12. بعد تأكيد المعاملة، انتظر لرؤية التغيير في كل من your balance و proxy balance. لاحظ أن هذا سيستغرق أيضًا بعض الوقت، لأن شبكة Sepolia لديها وقت الكتلة يبلغ 12 ثانية.

كيف يعمل

للحصول على تجربة بدون غاز، نحتاج إلى واجهة مستخدم للمستخدم، وخادم لتوجيه الرسائل من واجهة المستخدم إلى السلسلة، وعقد ذكي لاستلامها والتحقق منها.

العقد الذكي للمحفظة

هذا هو العقد الذكي (opens in a new tab). الغرض منه هو القيام بكل ما يطلبه المالك الحقيقي، بغض النظر عن القناة المستخدمة لطلبه، وتجاهل كل شيء آخر. للقيام بذلك، تتلقى دواله عنوانًا مستهدفًا لاستدعائه والبيانات التي سيتم استخدامها لاستدعائه.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.21;

contract UserProxy {
    address immutable OWNER;
    uint public nonce = 0;

هوية المالك ورقم فريد (opens in a new tab) لمنع تكرار الرسائل. نظرًا لأن الرقم الفريد هو متغير public، فإن مترجم Solidity ينشئ أيضًا دالة عرض، nonce() (opens in a new tab)، والتي تسمح للتعليمات البرمجية خارج السلسلة بقراءة قيمته.

    bytes32 private constant SIGNED_ACCESS_TYPEHASH =
        keccak256("SignedAccess(address target,bytes data,uint256 nonce)");

    bytes32 private constant SIGNED_ACCESS_PAYABLE_TYPEHASH =
        keccak256("SignedAccessPayable(address target,bytes data,uint256 nonce,uint256 value)");

    bytes32 immutable DOMAIN_SEPARATOR;

المعلومات المطلوبة للتحقق من توقيعات EIP-712 (opens in a new tab).

    constructor(address owner_) {
        OWNER = owner_;

يرتبط UserProxy بعنوان مالك واحد. هذا ضروري لأنه يمكن أن يمتلك أصولًا (رموز ERC-20 المميزة، الرموز غير القابلة للاستبدال، وما إلى ذلك). لا نريد خلط الأصول التي تنتمي إلى مالكين مختلفين.

فاصل النطاق (opens in a new tab). لا يمكن حسابه في وقت الترجمة، لأنه يعتمد على معرف السلسلة وعنوان العقد. هذا يجعل من المستحيل خداع UserProxy برسالة مُعدة لآخر.

    event CallResult(address target, bytes returnData);

سجل نتائج الاستدعاء.

    function directAccess(address target, bytes calldata data)
            external returns (bytes memory) {

يمكن استدعاء هذه الدالة مباشرة من قبل المالك. إذا لم تكن هناك مُرحّلات متاحة، فلا يزال بإمكان المالك الوصول إلى الأصول مباشرة على سلسلة الكتل (إذا كان المستخدم يمتلك ETH).

        require(msg.sender == OWNER, "Only owner can call");
        (bool success, bytes memory returnData) = target.call(data);
        require(success, "Call failed");

        emit CallResult(target, returnData);

        return returnData;
    }

إذا تم استدعاؤنا مباشرة من قبل المالك، فاستدعِ الهدف باستخدام بيانات الاستدعاء المقدمة.

    function signedAccess(
        address target,
        bytes calldata data,
        uint8 v,
        bytes32 r,
        bytes32 s)

هذه هي الدالة الرئيسية لـ UserProxy. إنها تحصل على target و data، بالإضافة إلى توقيع.

يتضمن الملخص أيضًا الرقم الفريد، لكننا لا نحتاج إلى استلامه من المعاملة؛ نحن نعرف بالفعل القيمة الصحيحة. سيتم رفض التوقيع الذي يحتوي على رقم فريد خاطئ.


    // استرداد المُوقِّع
    address signer = ecrecover(digest, v, r, s);
    require(signer == OWNER, "Signature invalid or not by owner");

إذا كان التوقيع غير صالح، فعادةً ما تُرجع ecrecover عنوانًا مختلفًا، ولن يتم قبوله.

    (bool success, bytes memory returnData) = target.call(data);
    require(success, "Call failed");

استدعِ العقد الذي أخبرنا المستخدم باستدعائه، وتراجع إذا لم ينجح الأمر.

    emit CallResult(target, returnData);

    nonce++; // زيادة الرقم الفريد لمنع إعادة الإرسال

    return returnData;
}

إذا نجح الأمر، فأصدر حدث سجل وقم بزيادة الرقم الفريد.

هذه متغيرات متطابقة تقريبًا تتيح لك أيضًا تحويل ETH خارج العقد.

المُرحّل

المُرحّل هو مكون خادم. إنه مكتوب بلغة JavaScript؛ يمكنك رؤية الكود المصدري هنا (opens in a new tab).

import express from "express";
import { createServer as createViteServer } from "vite";
import { createWalletClient, createPublicClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import dotenv from 'dotenv'

المكتبات التي نحتاجها. هذا خادم Express (opens in a new tab)، والذي يستخدم Vite (opens in a new tab) لتقديم كود واجهة المستخدم. نستخدم Viem (opens in a new tab) للتواصل مع سلسلة الكتل، و dotenv (opens in a new tab) لقراءة المفتاح الخاص للعنوان الذي يرسل المعاملة.

import { createRequire } from 'module'
const require = createRequire(import.meta.url)
const UserProxy = require('../contracts/out/UserProxy.sol/UserProxy.json')

هذه طريقة بسيطة لقراءة UserProxy المترجم. نحتاج إلى واجهة التطبيق الثنائية (ABI) لنتمكن من استدعاء UserProxy، والكود المترجم لنتمكن من نشره للمستخدم.

dotenv.config()
const sepoliaAccount = privateKeyToAccount(process.env.SEPOLIA_PRIVATE_KEY)
console.log("Using account:", sepoliaAccount.address)

اقرأ ملف .env، واستخرج العنوان، واطبعه على وحدة التحكم.

عملاء Viem الذين يتحدثون إلى سلسلة الكتل.

const start = async () => {
  const app = express()

قم بتشغيل خادم Express.

  app.use(express.json())

أخبر Express بقراءة نص الطلب، وإذا كان بتنسيق JSON فقم بتحليله.

  app.post("/server/deploy", async (req, res) => {

هذا هو الكود الذي يعالج طلبات نشر الوكيل. لاحظ أننا عرضة لهجمات حجب الخدمة (opens in a new tab) هنا لأن المهاجم يمكنه إغراقنا بطلبات لنشر الوكيل حتى يتم استنفاد ETH الخاص بنا. في نظام الإنتاج، من المحتمل أن نطلب أن يكون طلب نشر الوكيل موقعًا وأن يكون الموقع عميلاً حاليًا.

    try {
      const ownerAddress = req.body.ownerAddress

احصل على عنوان المالك من الطلب.

انشر العقد (opens in a new tab) وانتظر حتى يتم نشره (opens in a new tab).

      res.json({ contractAddress: receipt.contractAddress })

إذا كان كل شيء على ما يرام، فأرجع عنوان الوكيل إلى واجهة المستخدم.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

إذا كانت هناك مشكلة، فأبلغ عنها.

  app.post("/server/message", async (req, res) => {

هذا هو الكود الذي يعالج رسائل المستخدم لعقد UserProxy. هذه نقطة أخرى عرضة لهجوم حجب الخدمة.

احصل على بيانات الطلب واستخدمها لاستدعاء signedAccess على الوكيل.

      console.log("Message transaction hash:", txHash)

      res.json({ txHash })

أبلغ عن تجزئة المعاملة. يتيح هذا لواجهة المستخدم عرض عنوان URL للمستخدم للتحقق من المعاملة.

    } catch (err) {
      console.error(err)
      res.status(500).json({ error: err.message })
    }
  })

مرة أخرى، إذا كانت هناك مشكلة، فأبلغ عنها.

لكل شيء آخر، استخدم Vite، الذي يتولى تقديم واجهة المستخدم لنا.

واجهة المستخدم

هذا هو كود واجهة المستخدم (opens in a new tab). معظم الكود متطابق تقريبًا مع ذلك الموثق في هذا المقال، باستثناء Token.jsx (opens in a new tab).

أجزاء من Token.jsx (opens in a new tab) مشابهة لـ Greeter.jsx (opens in a new tab) في هذا المقال. إليك الأجزاء الجديدة.

import {
   encodeFunctionData
       } from 'viem'

هذه الدالة (opens in a new tab) تنشئ بيانات الاستدعاء لاستدعاء دالة آلة إيثيريوم الافتراضية (EVM). هذا ضروري حتى يتمكن المستخدم من توقيع بيانات الاستدعاء.

import UserProxy from '../../contracts/out/UserProxy.sol/UserProxy.json'

UserProxy، الموضح أعلاه.

import Erc20 from '../../contracts/out/Faucet.sol/FaucetToken.json'

هذا العقد (opens in a new tab) هو في الغالب عقد ERC-20 عادي، مع إضافة دالة واحدة مهمة، faucet(). تمنح هذه الدالة الرموز المميزة لأي شخص يطلبها لأغراض الاختبار.

const erc20Addrs = {
  // Sepolia
    11155111: '0x4cBedDEDA88fDd9e116618a5cD71BB0E440C2A78'
}

عنوان FaucetToken.

const Address = ({ address }) => {
   if (!address) return null
   return (
      <a href={`https://eth-sepolia.blockscout.com/address/${address}?tab=read_write_contract`} target="_blank">{address}</a>
   )
}

يُخرج هذا المكون عنوانًا مع رابط للعقد على مستكشف الكتل.

const Token = () => {
    ...

هذا هو المكون الرئيسي الذي يقوم بمعظم العمل.

  const [ balanceAmount, setBalanceAmount ] = useState("Loading...")

رصيد الرموز المميزة لعنوان المستخدم.

  const [ proxyAddr, setProxyAddr ] = useState(null)

عنوان الوكيل المملوك للمستخدم.

  const [ proxyBalanceAmount, setProxyBalanceAmount ] = useState("Loading...")

رصيد الرموز المميزة للوكيل.

  const [ newProxyAddr, setNewProxyAddr ] = useState("")

يُستخدم هذا الحقل عندما يقوم المستخدم بتعيين عنوان الوكيل يدويًا. يتيح امتلاك القدرة على تعيين عنوان الوكيل يدويًا للمستخدم استخدام وكيل موجود بدلاً من نشر وكيل جديد في كل مرة (وفقدان جميع الرموز المميزة المملوكة للوكيل القديم).

  const [ txHash, setTxHash ] = useState(null)

تجزئة المعاملة الأخيرة، تُستخدم لإظهار رابط للمستكشف حتى يتمكن المستخدم من التحقق من تلك المعاملة.

  const [ transferToken, setTransferToken ] = useState("")
  const [ transferAmount, setTransferAmount ] = useState("")
  const [ transferTo, setTransferTo ] = useState("")

تُستخدم جميع هذه الحقول لإرسال أوامر تحويل الرموز المميزة إلى عقد ERC-20. قد يكون هذا FaucetToken، لكن ليس بالضرورة أن يكون كذلك. دالة transfer هي جزء من معيار ERC-20.

  const balance = useReadContract({
    ...
  })


  const proxyBalance = useReadContract({
    ...
  })

اقرأ رصيدي الرموز المميزة اللذين نهتم بهما، مقدار ما يمتلكه المستخدم، ومقدار ما يمتلكه الوكيل.

  const nonce = useReadContract({
      address: proxyAddr,
      abi: UserProxy.abi,
      functionName: 'nonce',
      args: [],
  })

لمنع هجمات إعادة التشغيل (على سبيل المثال، بائع يعيد تشغيل معاملة تمنحه المال)، نستخدم رقمًا فريدًا (opens in a new tab). نحتاج إلى معرفة القيمة الحالية لإضافتها إلى البيانات التي نوقعها.

استخدم useEffect (opens in a new tab) لتحديث الرصيد المعروض للمستخدم عندما تتغير المعلومات المقروءة من سلسلة الكتل.

  useEffect(() => {
    setTransferToken(faucetAddr)
  }, [faucetAddr])

  useEffect(() => {
    setTransferTo(account.address)
  }, [account.address])

الافتراضي هو تحويل رموز FaucetToken المميزة إلى حساب المستخدم الخاص. هنا نقوم بتعيين هذه القيم عندما نتلقاها من Viem.

  const proxyAddressChange = (evt) => setNewProxyAddr(evt.target.value)
  const transferTokenChange = (evt) => setTransferToken(evt.target.value)
  const transferToChange = (evt) => setTransferTo(evt.target.value)
  const transferAmountChange = (evt) => setTransferAmount(evt.target.value)

معالجات الأحداث عندما تتغير الحقول النصية.

اطلب من الخادم نشر وكيل لهذا المستخدم.

  const signMessage = async(proxyAddr, target, calldata) => {

قم بتوقيع رسالة قبل إرسالها إلى الخادم لإرسالها إلى UserProxy على السلسلة. هذا موضح هنا. نحتاج إلى توقيع رسالة بكل من العنوان المستهدف (عنوان الرمز المميز الذي نستدعيه) وبيانات الاستدعاء التي سيتم إرسالها.

    const domain = {
      .
      .
      .
    return {v, r, s}
  }

  const messageUserProxy = async (proxy, target, data, v, r, s) => {

أرسل رسالة موقعة إلى UserProxy، والذي سيتحقق من التوقيع ثم يرسله إلى target.

أرسل طلبًا إلى الخادم، وعندما تتلقى الاستجابة، احصل على تجزئة المعاملة.

  const faucetSimulation = useSimulateContract({
    address: faucetAddr,
    abi: Erc20.abi,
    functionName: 'faucet',
    account: account.address
  })

قم بمحاكاة استدعاء دالة faucet. نقوم بتمكين زر الصنبور فقط إذا كان هذا ناجحًا.

لاستدعاء دالة من خلال الخادم و UserProxy، نتبع ثلاث خطوات:

  1. قم بإنشاء بيانات الاستدعاء لتوقيعها وإرسالها باستخدام encodeFunctionData (opens in a new tab).

  2. قم بتوقيع الرسالة (العنوان المستهدف، وبيانات الاستدعاء، والرقم الفريد).

  3. أرسل الرسالة إلى الخادم.

يتيح لك هذا الجزء من المكون استخدام FaucetToken مباشرة من المتصفح. الغرض الرئيسي منه هو تسهيل تصحيح الأخطاء.

         <h4>UserProxy access <Address address={proxyAddr} /></h4>
         <button onClick={deployUserProxy}>
         Deploy UserProxy (slow process)
         </button>

دع المستخدم ينشر UserProxy جديدًا.

اسمح للمستخدمين فقط بالنقر على Set proxy address عندما يدخلون عنوانًا شرعيًا. لاحظ أن هذا لا يضمن أن العنوان المعني هو بالفعل عقد UserProxy. من الممكن إضافة مثل هذا التحقق، لكنه سيكون أبطأ بكثير (تجربة مستخدم أسوأ) ولن يحسن الأمان (يمكن للمهاجمين دائمًا استخدام الكود الخاص بهم لواجهة المستخدم).

         <br /><br />
         { proxyAddr && (

أظهر الباقي فقط إذا كان هناك عنوان وكيل شرعي.

            <>
               Proxy balance: {proxyBalanceAmount}
               <br />
               Proxy nonce: {nonce?.data?.toString() ?? "Loading..."}

لا يحتاج المستخدم إلى معرفة الرقم الفريد؛ هذا فقط لأغراض تصحيح الأخطاء.

               <br />
               <button disabled={!proxyAddr || proxyAddr === "Loading..." || nonce?.status !== 'success'}
                  onClick={proxyFaucet}
               >
                  Request more tokens for proxy
               </button>

لا يمكننا محاكاة استدعاء لـ faucet() من خلال الوكيل. ومع ذلك، يمكننا على الأقل التأكد من أن لدينا وكيلًا وأن الوكيل أبلغنا برقم فريد.

دع المستخدم يصدر معاملات تحويل ERC-20.

إذا كانت هناك تجزئة معاملة أخيرة، فأظهر رابطًا حتى يتمكن المستخدم من عرضها في مستكشف الكتل.

 
</div>
    </>
  )
}

export {Token}

هذا مجرد كود React أساسي.

نقاط الضعف

خادمنا عرضة لهجمات حجب الخدمة. تم شرح هذا الهجوم في المقال السابق من السلسلة.

بالإضافة إلى ذلك، نحن نشجع سلوك المستخدم السيئ. هذا ما نطلب من المستخدم توقيعه:

Screen capture with opaque calldata

نحن نعلم أن هذا تحويل ERC-20 شرعي للرمز المميز والمبلغ وعنوان الوجهة الذي يريد المستخدم تحويله. لكن معظم المستخدمين لا يعرفون كيفية تفسير بيانات الاستدعاء، وليس لديهم أي فكرة عما يوقعون عليه. هذا تصميم سيئ، لسببين:

  • لن يستخدمنا بعض المستخدمين لأنهم لا يثقون في البيانات التي نطلب منهم توقيعها.
  • سيثق بنا مستخدمون آخرون وسيتعلمون أنه يجب عليهم فقط توقيع بيانات الاستدعاء دون فهم ماهيتها. هذا يعني أنه إذا تمكن المهاجم آدم من إعادة توجيههم إلى موقعه على الويب، فيمكنه جعلهم يوقعون معاملة تمنحه كل USDC (أو DAI، أو أي ERC-20 آخر) يمتلكه المستخدم.

الحل هو وجود دوال منفصلة في UserProxy للدوال شائعة الاستخدام، مثل التحويل. ثم يمكن للمستخدمين توقيع شيء يفهمونه.

Screen capture with transfer details

ملاحظة: بينما يمكن للمستخدمين استخدام أي محفظة يريدونها، يوصى بشدة أن تشجعهم التطبيقات التي تستخدم EIP-712 على استخدام محفظة تُظهر بيانات التوقيع بالكامل (opens in a new tab). تقوم بعض المحافظ باقتطاع العنوان، وهو أمر غير آمن. يمكن للمهاجم إنشاء عنوان له نفس أحرف البداية والنهاية، ولكنه يختلف في المنتصف.

Screen capture with truncated addresses

الخاتمة

بالإضافة إلى نقاط الضعف المذكورة أعلاه، فإن الحل في هذا البرنامج التعليمي له العديد من العيوب التي يمكن أن تساعدنا إيثيريوم في معالجتها.

  • مقاومة الرقابة. حاليًا، يمكن للمستخدمين استخدام خادمك، أو خادم منافس أعده شخص آخر، أو الاتصال بشبكة إيثيريوم مباشرة، مما يتكبد تكاليف الغاز. يتيح استخدام ERC-4337 (opens in a new tab) للمستخدمين عرض معاملاتهم على مجموعة كبيرة من الخوادم، مما يقلل من احتمالية تعرض معاملاتهم للرقابة.
  • الأصول المملوكة للحساب المملوك خارجيًا (EOA). كما لوحظ أعلاه، يمكن استخدام EIP-7702 (opens in a new tab) لإدارة الأصول المملوكة بالفعل لعنوان حساب مملوك خارجيًا (EOA). هذا له صعوباته، ولكنه ضروري في بعض الأحيان.

آمل أن أنشر برامج تعليمية حول إضافة هذه الميزات في المستقبل القريب.

انظر هنا للمزيد من أعمالي (opens in a new tab).