تخطى إلى المحتوى
·29 دقيقة قراءة

Ethereum لمطوري الويب: العقود الذكية بدون الضجة

مفاهيم Ethereum التي يحتاجها كل مطور ويب: الحسابات، والمعاملات، والعقود الذكية، وترميز ABI، وethers.js، وWAGMI، وقراءة البيانات على السلسلة بدون تشغيل عقدة خاصة.

مشاركة:X / TwitterLinkedIn

معظم محتوى "Ethereum للمطورين" يقع في فئتين: تشبيهات مبسطة جداً لا تساعدك في بناء أي شيء، أو مواصفات بروتوكول عميقة تفترض أنك تعرف بالفعل ما هو Merkle Patricia Trie. لا أي منهما مفيد إذا كنت مطور ويب يريد قراءة رصيد رمز، أو السماح لمستخدم بتوقيع معاملة، أو عرض بيانات NFT الوصفية في تطبيق React.

هذا المقال هو الأرضية الوسطى العملية. سأشرح بالضبط ما يحدث عندما يتحدث الواجهة الأمامية مع Ethereum، وما هي الأجزاء المتحركة، وكيف تتوافق الأدوات الحديثة (ethers.js، viem، WAGMI) مع مفاهيم تفهمها بالفعل من بناء تطبيقات الويب.

لا استعارات عن آلات البيع. لا "تخيل عالماً حيث..." فقط النموذج التقني والكود.

النموذج الذهني#

Ethereum هو آلة حالة منسوخة. كل عقدة في الشبكة تحتفظ بنسخة مطابقة من الحالة — مخزن مفتاح-قيمة ضخم يربط العناوين ببيانات الحسابات. عندما "ترسل معاملة"، أنت تقترح انتقال حالة. إذا وافق عدد كافٍ من المُصادقين أنه صالح، تُحدَّث الحالة. هذا كل شيء.

الحالة نفسها مباشرة. إنها تعيين من عناوين 20 بايت إلى كائنات حسابات. كل حساب لديه أربعة حقول:

  • nonce: عدد المعاملات التي أرسلها هذا الحساب (لحسابات EOA) أو عدد العقود التي أنشأها (لحسابات العقود). هذا يمنع هجمات الإعادة.
  • balance: كمية ETH، مقوّمة بالـ wei (1 ETH = 10^18 wei). دائماً عدد صحيح كبير.
  • codeHash: تجزئة bytecode لـ EVM. للمحافظ العادية (EOAs)، هذا تجزئة البايتات الفارغة. للعقود، هذا تجزئة الكود المنشور.
  • storageRoot: تجزئة جذر شجرة التخزين للحساب. فقط العقود لديها تخزين ذو معنى.

هناك نوعان من الحسابات، والتمييز مهم لكل ما يلي:

الحسابات المملوكة خارجياً (EOAs) يتحكم فيها مفتاح خاص. هي ما يديره MetaMask. يمكنها بدء المعاملات. ليس لديها كود. عندما يقول شخص "محفظة"، يقصد EOA.

حسابات العقود يتحكم فيها كودها. لا يمكنها بدء المعاملات — يمكنها فقط التنفيذ استجابةً للاستدعاء. لديها كود وتخزين. عندما يقول شخص "عقد ذكي"، يقصد هذا. الكود غير قابل للتغيير بمجرد النشر (مع بعض الاستثناءات عبر أنماط البروكسي، وهذا نقاش آخر بالكامل).

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

Gas: الحوسبة لها ثمن#

كل عملية في EVM تكلف gas. جمع رقمين يكلف 3 gas. تخزين كلمة 32 بايت يكلف 20,000 gas (أول مرة) أو 5,000 gas (تحديث). قراءة التخزين تكلف 2,100 gas (بارد) أو 100 gas (دافئ، تم الوصول إليه بالفعل في هذه المعاملة).

أنت لا تدفع gas بـ "وحدات gas". تدفع بـ ETH. التكلفة الإجمالية هي:

totalCost = gasUsed * gasPrice

بعد EIP-1559 (ترقية لندن)، أصبح تسعير gas نظام من جزأين:

totalCost = gasUsed * (baseFee + priorityFee)
  • baseFee: يُحدد بواسطة البروتوكول بناءً على ازدحام الشبكة. يُحرق (يُدمر).
  • priorityFee (إكرامية): يذهب إلى المُصادق. إكرامية أعلى = إدراج أسرع.
  • maxFeePerGas: الحد الأقصى الذي ترغب في دفعه لكل وحدة gas.
  • maxPriorityFeePerGas: الحد الأقصى للإكرامية لكل وحدة gas.

إذا كان baseFee + priorityFee > maxFeePerGas، معاملتك تنتظر حتى ينخفض baseFee. هذا هو سبب "توقف" المعاملات أثناء الازدحام الشديد.

الاستنتاج العملي لمطوري الويب: قراءة البيانات مجانية. كتابة البيانات تكلف مالاً. هذا هو أهم فرق معماري بين Web2 وWeb3. كل SELECT مجاني. كل INSERT، UPDATE، DELETE يكلف مالاً حقيقياً. صمم تطبيقاتك اللامركزية وفقاً لذلك.

المعاملات#

المعاملة هي بنية بيانات موقّعة. إليك الحقول المهمة:

typescript
interface Transaction {
  // من يستقبل هذه المعاملة — عنوان EOA أو عنوان عقد
  to: string;      // عنوان hex من 20 بايت، أو null لنشر العقود
  // كمية ETH للإرسال (بالـ wei)
  value: bigint;   // يمكن أن تكون 0n لاستدعاءات العقود الصرفة
  // بيانات استدعاء الدالة المرمّزة، أو فارغة لتحويلات ETH البسيطة
  data: string;    // بايتات مرمّزة بـ hex، "0x" للتحويلات البسيطة
  // عدّاد تسلسلي، يمنع هجمات الإعادة
  nonce: number;   // يجب أن يساوي بالضبط nonce المرسل الحالي
  // حد Gas — الحد الأقصى لـ gas يمكن أن تستهلكه هذه المعاملة
  gasLimit: bigint;
  // معاملات رسوم EIP-1559
  maxFeePerGas: bigint;
  maxPriorityFeePerGas: bigint;
  // معرّف السلسلة (1 = الشبكة الرئيسية، 11155111 = Sepolia، 137 = Polygon)
  chainId: number;
}

دورة حياة المعاملة#

  1. البناء: تطبيقك يبني كائن المعاملة. إذا كنت تستدعي دالة عقد، حقل data يحتوي على استدعاء الدالة المرمّز بـ ABI (المزيد عن هذا أدناه).

  2. التوقيع: المفتاح الخاص يوقع المعاملة المرمّزة بـ RLP، منتجاً مكونات التوقيع v، r، s. هذا يثبت أن المرسل أذن بهذه المعاملة المحددة. عنوان المرسل يُشتق من التوقيع — ليس موجوداً صراحة في المعاملة.

  3. البث: المعاملة الموقعة تُرسل إلى عقدة RPC عبر eth_sendRawTransaction. العقدة تتحقق منها (nonce صحيح، رصيد كافٍ، توقيع صالح) وتضيفها إلى mempool.

  4. Mempool: المعاملة تجلس في مجمّع المعاملات المعلقة. المُصادقون يختارون المعاملات لإدراجها في الكتلة التالية، عموماً يفضلون الإكراميات الأعلى. هنا يحدث التقدم على الآخرين — فاعلون آخرون يمكنهم رؤية معاملتك المعلقة وإرسال معاملتهم بإكرامية أعلى للتنفيذ قبلك.

  5. الإدراج: مُصادق يدرج معاملتك في كتلة. EVM ينفذها. إذا نجحت، تُطبق تغييرات الحالة. إذا عادت، تُلغى تغييرات الحالة — لكنك لا تزال تدفع ثمن الـ gas المستهلك حتى نقطة الإعادة.

  6. النهائية: على Ethereum بإثبات الحصة، الكتلة تصبح "نهائية" بعد حقبتين (~12.8 دقيقة). قبل النهائية، إعادة تنظيم السلسلة ممكنة نظرياً (رغم ندرتها). معظم التطبيقات تعامل تأكيد 1-2 كتلة كـ "كافٍ" للعمليات غير الحرجة.

إليك كيف يبدو إرسال تحويل ETH بسيط مع ethers.js v6:

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
const wallet = new ethers.Wallet("0xYOUR_PRIVATE_KEY", provider);
 
const tx = await wallet.sendTransaction({
  to: "0xRecipientAddress...",
  value: ethers.parseEther("0.1"), // يحوّل "0.1" إلى wei (100000000000000000n)
});
 
console.log("Tx hash:", tx.hash);
 
// انتظر الإدراج في كتلة
const receipt = await tx.wait();
console.log("Block number:", receipt.blockNumber);
console.log("Gas used:", receipt.gasUsed.toString());
console.log("Status:", receipt.status); // 1 = نجاح، 0 = إعادة

ونفسه مع viem:

typescript
import { createWalletClient, http, parseEther } from "viem";
import { mainnet } from "viem/chains";
import { privateKeyToAccount } from "viem/accounts";
 
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
 
const client = createWalletClient({
  account,
  chain: mainnet,
  transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
 
const hash = await client.sendTransaction({
  to: "0xRecipientAddress...",
  value: parseEther("0.1"),
});
 
console.log("Tx hash:", hash);

لاحظ الفرق: ethers يُرجع كائن TransactionResponse مع طريقة .wait(). viem يُرجع فقط التجزئة — تستخدم استدعاء منفصل publicClient.waitForTransactionReceipt({ hash }) لانتظار التأكيد. هذا الفصل في المسؤوليات مقصود في تصميم viem.

العقود الذكية#

العقد الذكي هو bytecode منشور بالإضافة إلى تخزين دائم على عنوان محدد. عندما "تستدعي" عقداً، أنت ترسل معاملة (أو تقوم باستدعاء قراءة فقط) مع حقل data مُعيّن لاستدعاء دالة مرمّزة.

Bytecode و ABI#

الـ bytecode هو كود EVM المُجمّع. لا تتفاعل معه مباشرة. إنه ما ينفذه EVM.

الـ ABI (واجهة التطبيق الثنائية) هو وصف JSON لواجهة العقد. يخبر مكتبة العميل كيف ترمّز استدعاءات الدوال وتفك رموز القيم المُرجعة. فكر فيه كمواصفات OpenAPI لعقد.

إليك جزء من ABI لرمز ERC-20:

typescript
const ERC20_ABI = [
  // دوال للقراءة فقط (view/pure — بدون تكلفة gas عند الاستدعاء الخارجي)
  "function name() view returns (string)",
  "function symbol() view returns (string)",
  "function decimals() view returns (uint8)",
  "function totalSupply() view returns (uint256)",
  "function balanceOf(address owner) view returns (uint256)",
  "function allowance(address owner, address spender) view returns (uint256)",
 
  // دوال تغيير الحالة (تتطلب معاملة، تكلف gas)
  "function transfer(address to, uint256 amount) returns (bool)",
  "function approve(address spender, uint256 amount) returns (bool)",
  "function transferFrom(address from, address to, uint256 amount) returns (bool)",
 
  // الأحداث (تُصدر أثناء التنفيذ، تُخزن في سجلات المعاملات)
  "event Transfer(address indexed from, address indexed to, uint256 value)",
  "event Approval(address indexed owner, address indexed spender, uint256 value)",
] as const;

ethers.js يقبل صيغة "ABI المقروءة بشرياً" هذه. viem يمكنه استخدامها أيضاً، لكن غالباً ستعمل مع ABI كامل بصيغة JSON مُنشأ بواسطة مُجمّع Solidity. كلاهما متكافئ — الصيغة المقروءة بشرياً فقط أكثر راحة للواجهات الشائعة.

كيف تُرمَّز استدعاءات الدوال#

هذا هو الجزء الذي تتخطاه معظم الدروس، وهو الجزء الذي سيوفر لك ساعات من التصحيح.

عندما تستدعي transfer("0xBob...", 1000000)، حقل data في المعاملة يُعيَّن إلى:

0xa9059cbb                                                         // محدد الدالة
0000000000000000000000000xBob...000000000000000000000000             // العنوان، مبطن إلى 32 بايت
00000000000000000000000000000000000000000000000000000000000f4240     // uint256 المبلغ (1000000 بالست عشري)

محدد الدالة هو أول 4 بايت من تجزئة Keccak-256 لتوقيع الدالة:

keccak256("transfer(address,uint256)") = 0xa9059cbb...
selector = أول 4 بايت = 0xa9059cbb

البايتات المتبقية هي المعاملات المرمّزة بـ ABI، كل منها مبطن إلى 32 بايت. مخطط الترميز هذا حتمي — نفس استدعاء الدالة ينتج دائماً نفس calldata.

لماذا هذا مهم؟ لأنه عندما ترى بيانات معاملة خام على Etherscan وتبدأ بـ 0xa9059cbb، تعرف أنه استدعاء transfer. عندما تعود معاملتك ورسالة الخطأ مجرد كتلة hex، يمكنك فك رموزها باستخدام ABI. وعندما تبني دفعات معاملات أو تتفاعل مع عقود multicall، ستُرمّز calldata يدوياً.

إليك كيفية الترميز وفك الرموز يدوياً مع ethers.js:

typescript
import { ethers } from "ethers";
 
const iface = new ethers.Interface(ERC20_ABI);
 
// ترميز استدعاء دالة
const calldata = iface.encodeFunctionData("transfer", [
  "0xBobAddress...",
  1000000n,
]);
console.log(calldata);
// 0xa9059cbb000000000000000000000000bob...000000000000000000000000000f4240
 
// فك رموز calldata إلى اسم الدالة والمعاملات
const decoded = iface.parseTransaction({ data: calldata });
console.log(decoded.name);       // "transfer"
console.log(decoded.args[0]);    // "0xBobAddress..."
console.log(decoded.args[1]);    // 1000000n (BigInt)
 
// فك رموز بيانات إرجاع الدالة
const returnData = "0x0000000000000000000000000000000000000000000000000000000000000001";
const result = iface.decodeFunctionResult("transfer", returnData);
console.log(result[0]); // true

فتحات التخزين#

تخزين العقد هو مخزن مفتاح-قيمة حيث المفاتيح والقيم كلاهما 32 بايت. Solidity يعيّن فتحات التخزين تسلسلياً بدءاً من 0. أول متغير حالة مُعلن يذهب في الفتحة 0، التالي في الفتحة 1، وهكذا. التعيينات والمصفوفات الديناميكية تستخدم مخططاً مبنياً على التجزئة.

يمكنك قراءة تخزين أي عقد مباشرة، حتى لو كان المتغير مُعلَّم private في Solidity. "خاص" يعني فقط أن العقود الأخرى لا تستطيع قراءته — أي شخص يمكنه قراءته عبر eth_getStorageAt:

typescript
// قراءة فتحة التخزين 0 لعقد
const slot0 = await provider.getStorage(
  "0xContractAddress...",
  0
);
console.log(slot0); // قيمة hex خام من 32 بايت

هكذا تُظهر متصفحات الكتل حالة العقد "الداخلية". لا يوجد تحكم في الوصول على قراءات التخزين. الخصوصية على blockchain عام محدودة بشكل أساسي.

الأحداث والسجلات#

الأحداث هي طريقة العقد في إصدار بيانات منظمة تُخزن في سجلات المعاملات وليس في تخزين العقد. هي أرخص من كتابات التخزين (375 gas للموضوع الأول + 8 gas لكل بايت من البيانات، مقابل 20,000 gas لكتابة تخزين) وهي مصممة للاستعلام بكفاءة.

الحدث يمكن أن يحتوي حتى 3 معاملات indexed (تُخزن كـ "مواضيع") وأي عدد من المعاملات غير المفهرسة (تُخزن كـ "بيانات"). المعاملات المفهرسة يمكن التصفية عليها — يمكنك أن تسأل "أعطني كل أحداث Transfer حيث to هو هذا العنوان." المعاملات غير المفهرسة لا يمكن التصفية عليها؛ عليك جلب كل الأحداث المطابقة والتصفية من جانب العميل.

typescript
// الاستماع لأحداث Transfer في الوقت الحقيقي مع ethers.js
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
 
contract.on("Transfer", (from, to, value, event) => {
  console.log(`${from} -> ${to}: ${ethers.formatUnits(value, 18)} tokens`);
  console.log("Block:", event.log.blockNumber);
  console.log("Tx hash:", event.log.transactionHash);
});
 
// الاستعلام عن الأحداث التاريخية
const filter = contract.filters.Transfer(null, "0xMyAddress..."); // من=أي، إلى=محدد
const events = await contract.queryFilter(filter, 19000000, 19100000); // نطاق الكتل
 
for (const event of events) {
  console.log("From:", event.args.from);
  console.log("Value:", event.args.value.toString());
}

نفسه مع viem:

typescript
import { createPublicClient, http, parseAbiItem } from "viem";
import { mainnet } from "viem/chains";
 
const client = createPublicClient({
  chain: mainnet,
  transport: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
});
 
// السجلات التاريخية
const logs = await client.getLogs({
  address: "0xTokenAddress...",
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 value)"
  ),
  args: {
    to: "0xMyAddress...",
  },
  fromBlock: 19000000n,
  toBlock: 19100000n,
});
 
for (const log of logs) {
  console.log("From:", log.args.from);
  console.log("To:", log.args.to);
  console.log("Value:", log.args.value);
}
 
// المراقبة في الوقت الحقيقي
const unwatch = client.watchEvent({
  address: "0xTokenAddress...",
  event: parseAbiItem(
    "event Transfer(address indexed from, address indexed to, uint256 value)"
  ),
  onLogs: (logs) => {
    for (const log of logs) {
      console.log(`Transfer: ${log.args.from} -> ${log.args.to}`);
    }
  },
});
 
// استدعِ unwatch() لإيقاف الاستماع

قراءة البيانات على السلسلة#

هنا يصبح Ethereum عملياً لمطوري الويب. لا تحتاج لتشغيل عقدة. لا تحتاج للتعدين. لا تحتاج حتى محفظة. قراءة البيانات من Ethereum مجانية، بدون إذن، وتعمل عبر واجهة JSON-RPC بسيطة.

JSON-RPC: واجهة HTTP لـ Ethereum#

كل عقدة Ethereum تكشف واجهة JSON-RPC. إنها حرفياً HTTP POST مع أجسام JSON. لا يوجد شيء خاص بالـ blockchain في طبقة النقل.

typescript
// هذا ما تفعله مكتبتك خلف الكواليس
const response = await fetch("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    jsonrpc: "2.0",
    id: 1,
    method: "eth_call",
    params: [
      {
        to: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
        data: "0x70a08231000000000000000000000000d8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
        // balanceOf(vitalik.eth)
      },
      "latest",
    ],
  }),
});
 
const result = await response.json();
console.log(result);
// { jsonrpc: "2.0", id: 1, result: "0x000000000000000000000000000000000000000000000000000000174876e800" }

هذا eth_call خام. يحاكي تنفيذ معاملة بدون إرسالها فعلاً. لا تكلفة gas. لا تغيير حالة. فقط يقرأ القيمة المُرجعة. هكذا تعمل دوال view و pure من الخارج — تستخدم eth_call بدلاً من eth_sendRawTransaction.

طريقتا RPC الحاسمتان#

eth_call: يحاكي التنفيذ. مجاني. لا تغيير حالة. يُستخدم لكل عمليات القراءة — فحص الأرصدة، قراءة الأسعار، استدعاء دوال view. يمكن استدعاؤه على أي كتلة تاريخية بتحديد رقم كتلة بدلاً من "latest".

eth_sendRawTransaction: يُرسل معاملة موقّعة للإدراج في كتلة. يكلف gas. يغيّر الحالة (إذا نجح). يُستخدم لكل عمليات الكتابة — التحويلات، الموافقات، المبادلات، السك.

كل شيء آخر في واجهة JSON-RPC إما متغير من هاتين أو طريقة مساعدة (eth_blockNumber، eth_getTransactionReceipt، eth_getLogs، إلخ.).

المزودون: بوابتك إلى السلسلة#

أنت لا تشغل عقدتك الخاصة. تقريباً لا أحد يفعل لتطوير التطبيقات. بدلاً من ذلك، تستخدم خدمة مزود:

  • Alchemy: الأكثر شعبية. لوحة تحكم رائعة، دعم webhook، واجهات محسّنة لـ NFT وبيانات الرموز الوصفية. الطبقة المجانية: ~300 مليون وحدة حوسبة/شهر.
  • Infura: الأصلي. مملوك لـ ConsenSys. موثوق. الطبقة المجانية: 100 ألف طلب/يوم.
  • QuickNode: جيد للسلاسل المتعددة. نموذج تسعير مختلف قليلاً.
  • نقاط نهاية RPC العامة: https://rpc.ankr.com/eth، https://cloudflare-eth.com. مجانية لكن محدودة المعدل وأحياناً غير موثوقة. جيدة للتطوير، خطيرة للإنتاج.
  • Tenderly: ممتاز للمحاكاة والتصحيح. RPC الخاص بهم يتضمن محاكي معاملات مدمج.

للإنتاج، اضبط دائماً مزودين على الأقل كاحتياط. انقطاع RPC حقيقي وسيحدث في أسوأ وقت ممكن.

typescript
import { ethers } from "ethers";
 
// مزود احتياطي ethers.js v6
const provider = new ethers.FallbackProvider([
  {
    provider: new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/KEY1"),
    priority: 1,
    stallTimeout: 2000,
    weight: 2,
  },
  {
    provider: new ethers.JsonRpcProvider("https://mainnet.infura.io/v3/KEY2"),
    priority: 2,
    stallTimeout: 2000,
    weight: 1,
  },
]);

قراءة حالة العقد مجاناً#

هذه الحركة القوية التي لا يدركها معظم مطوري Web2: يمكنك قراءة أي بيانات عامة من أي عقد على Ethereum بدون دفع أي شيء، بدون محفظة، وبدون أي مصادقة سوى مفتاح API لمزود RPC الخاص بك.

typescript
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
// واجهة ERC-20 — فقط دوال القراءة
const erc20 = new ethers.Contract(
  "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
  [
    "function name() view returns (string)",
    "function symbol() view returns (string)",
    "function decimals() view returns (uint8)",
    "function totalSupply() view returns (uint256)",
    "function balanceOf(address) view returns (uint256)",
  ],
  provider // ملاحظة: provider، وليس signer. قراءة فقط.
);
 
const [name, symbol, decimals, totalSupply] = await Promise.all([
  erc20.name(),
  erc20.symbol(),
  erc20.decimals(),
  erc20.totalSupply(),
]);
 
console.log(`${name} (${symbol})`);           // "USD Coin (USDC)"
console.log(`Decimals: ${decimals}`);          // 6 (ليس 18!)
console.log(`Total supply: ${ethers.formatUnits(totalSupply, decimals)}`);
 
// التحقق من رصيد عنوان محدد
const balance = await erc20.balanceOf("0xSomeAddress...");
console.log(`Balance: ${ethers.formatUnits(balance, decimals)} USDC`);

لا محفظة. لا gas. لا معاملة. فقط eth_call عبر JSON-RPC خلف الكواليس. هذا مطابق من حيث المفهوم لإرسال طلب GET إلى REST API. الـ blockchain هو قاعدة البيانات، العقد هو API، و eth_call هو استعلام SELECT الخاص بك.

ethers.js v6#

ethers.js هو jQuery لـ Web3 — كان أول مكتبة يتعلمها معظم المطورين، ولا يزال الأكثر استخداماً. النسخة 6 تحسن كبير عن v5، مع دعم BigInt أصلي (أخيراً)، وحدات ESM، وواجهة أنظف.

الثلاثة تجريدات الأساسية#

Provider: اتصال للقراءة فقط بالـ blockchain. يمكنه استدعاء دوال view، جلب الكتل، قراءة السجلات. لا يمكنه التوقيع أو إرسال المعاملات.

typescript
import { ethers } from "ethers";
 
// الاتصال بعقدة
const provider = new ethers.JsonRpcProvider("https://...");
 
// استعلامات أساسية
const blockNumber = await provider.getBlockNumber();
const balance = await provider.getBalance("0xAddress...");
const block = await provider.getBlock(blockNumber);
const txCount = await provider.getTransactionCount("0xAddress...");

Signer: تجريد فوق مفتاح خاص. يمكنه توقيع المعاملات والرسائل. Signer دائماً متصل بـ Provider.

typescript
// من مفتاح خاص (جانب الخادم، السكربتات)
const wallet = new ethers.Wallet("0xPrivateKey...", provider);
 
// من محفظة المتصفح (جانب العميل)
const browserProvider = new ethers.BrowserProvider(window.ethereum);
const signer = await browserProvider.getSigner();
 
// الحصول على العنوان
const address = await signer.getAddress();

Contract: بروكسي JavaScript لعقد منشور. الطرق على كائن Contract تتوافق مع الدوال في ABI. دوال View تُرجع قيماً. دوال تغيير الحالة تُرجع TransactionResponse.

typescript
const usdc = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, provider);
 
// قراءة (مجانية، تُرجع القيمة مباشرة)
const balance = await usdc.balanceOf("0xSomeAddress...");
// balance هو bigint: 1000000000n (1000 USDC مع 6 أعشار)
 
// للكتابة، اتصل بـ signer
const usdcWithSigner = usdc.connect(signer);
 
// كتابة (تكلف gas، تُرجع TransactionResponse)
const tx = await usdcWithSigner.transfer("0xRecipient...", 1000000n);
const receipt = await tx.wait(); // انتظر الإدراج في كتلة
 
if (receipt.status === 0) {
  throw new Error("المعاملة عادت");
}

TypeChain لأمان الأنواع#

تفاعلات ABI الخام هي من نوع النصوص. يمكنك كتابة اسم دالة بشكل خاطئ، تمرير أنواع معاملات خاطئة، أو إساءة تفسير القيم المُرجعة. TypeChain يُنشئ أنواع TypeScript من ملفات ABI الخاصة بك:

typescript
// بدون TypeChain — لا فحص أنواع
const balance = await contract.balanceOf("0x...");
// balance هو 'any'. لا إكمال تلقائي. سهل الاستخدام الخاطئ.
 
// مع TypeChain — أمان أنواع كامل
import { USDC__factory } from "./typechain";
 
const usdc = USDC__factory.connect(USDC_ADDRESS, provider);
const balance = await usdc.balanceOf("0x...");
// balance هو BigNumber. الإكمال التلقائي يعمل. أخطاء الأنواع تُلتقط وقت التجميع.

للمشاريع الجديدة، فكر في استخدام استنتاج الأنواع المدمج في viem من ABIs بدلاً. يحقق نفس النتيجة بدون خطوة توليد كود منفصلة.

الاستماع للأحداث#

بث الأحداث في الوقت الحقيقي حاسم لتطبيقات لامركزية متجاوبة. ethers.js يستخدم مزودات WebSocket لهذا:

typescript
// WebSocket للأحداث في الوقت الحقيقي
const wsProvider = new ethers.WebSocketProvider("wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY");
 
const contract = new ethers.Contract(USDC_ADDRESS, ERC20_ABI, wsProvider);
 
// الاستماع لكل أحداث Transfer
contract.on("Transfer", (from, to, value, event) => {
  console.log(`Transfer: ${from} -> ${to}`);
  console.log(`Amount: ${ethers.formatUnits(value, 6)} USDC`);
});
 
// الاستماع للتحويلات إلى عنوان محدد
const filter = contract.filters.Transfer(null, "0xMyAddress...");
contract.on(filter, (from, to, value) => {
  console.log(`تحويل وارد: ${ethers.formatUnits(value, 6)} USDC من ${from}`);
});
 
// التنظيف عند الانتهاء
contract.removeAllListeners();

WAGMI + Viem: الحزمة الحديثة#

WAGMI هي مكتبة خطاطيف React لـ Ethereum. viem هو عميل TypeScript الأساسي الذي تستخدمه. معاً، حلّا بشكل كبير محل ethers.js + web3-react كالحزمة القياسية لتطوير واجهات التطبيقات اللامركزية.

لماذا التحول؟ ثلاثة أسباب: استنتاج TypeScript كامل من ABIs (لا حاجة لتوليد كود)، حجم حزمة أصغر، وخطاطيف React التي تتعامل مع إدارة الحالة غير المتزامنة الفوضوية لتفاعلات المحفظة.

الإعداد#

typescript
// wagmi.config.ts
import { createConfig, http } from "wagmi";
import { mainnet, sepolia } from "wagmi/chains";
import { injected, walletConnect } from "wagmi/connectors";
 
export const config = createConfig({
  chains: [mainnet, sepolia],
  connectors: [
    injected(),
    walletConnect({ projectId: "YOUR_WALLETCONNECT_PROJECT_ID" }),
  ],
  transports: {
    [mainnet.id]: http("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY"),
    [sepolia.id]: http("https://eth-sepolia.g.alchemy.com/v2/YOUR_KEY"),
  },
});
typescript
// app/providers.tsx
"use client";
 
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { config } from "./wagmi.config";
 
const queryClient = new QueryClient();
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        {children}
      </QueryClientProvider>
    </WagmiProvider>
  );
}

قراءة بيانات العقود#

useReadContract هو الخطاف الذي ستستخدمه أكثر. يغلف eth_call مع تخزين مؤقت React Query، وإعادة الجلب، وحالات التحميل/الخطأ:

typescript
"use client";
 
import { useReadContract } from "wagmi";
import { formatUnits } from "viem";
 
const ERC20_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "owner", type: "address" }],
    outputs: [{ name: "balance", type: "uint256" }],
  },
] as const;
 
function TokenBalance({ address }: { address: `0x${string}` }) {
  const { data: balance, isLoading, error } = useReadContract({
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", // USDC
    abi: ERC20_ABI,
    functionName: "balanceOf",
    args: [address],
  });
 
  if (isLoading) return <span>جاري التحميل...</span>;
  if (error) return <span>خطأ: {error.message}</span>;
 
  // balance مُنمّط كـ bigint لأن ABI يقول uint256
  return <span>{formatUnits(balance ?? 0n, 6)} USDC</span>;
}

لاحظ as const على ABI. هذا حاسم. بدونه، TypeScript يفقد الأنواع الحرفية و balance يصبح unknown بدلاً من bigint. نظام استنتاج الأنواع بالكامل يعتمد على تأكيدات const.

الكتابة إلى العقود#

useWriteContract يتعامل مع دورة الحياة الكاملة: طلب المحفظة، التوقيع، البث، وتتبع التأكيد.

typescript
"use client";
 
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { parseUnits } from "viem";
 
function SendTokens() {
  const { writeContract, data: hash, isPending, error } = useWriteContract();
 
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash,
  });
 
  function handleSend() {
    writeContract({
      address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      abi: ERC20_ABI,
      functionName: "transfer",
      args: [
        "0xRecipientAddress...",
        parseUnits("100", 6), // 100 USDC
      ],
    });
  }
 
  return (
    <div>
      <button onClick={handleSend} disabled={isPending}>
        {isPending ? "تأكيد في المحفظة..." : "إرسال 100 USDC"}
      </button>
 
      {hash && <p>المعاملة: {hash}</p>}
      {isConfirming && <p>في انتظار التأكيد...</p>}
      {isSuccess && <p>تم تأكيد التحويل!</p>}
      {error && <p>خطأ: {error.message}</p>}
    </div>
  );
}

مراقبة الأحداث#

useWatchContractEvent يُعد اشتراك WebSocket لمراقبة الأحداث في الوقت الحقيقي:

typescript
"use client";
 
import { useWatchContractEvent } from "wagmi";
import { useState } from "react";
import { formatUnits } from "viem";
 
interface TransferEvent {
  from: string;
  to: string;
  value: string;
}
 
function LiveTransfers() {
  const [transfers, setTransfers] = useState<TransferEvent[]>([]);
 
  useWatchContractEvent({
    address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
    abi: ERC20_ABI,
    eventName: "Transfer",
    onLogs(logs) {
      const newTransfers = logs.map((log) => ({
        from: log.args.from as string,
        to: log.args.to as string,
        value: formatUnits(log.args.value as bigint, 6),
      }));
      setTransfers((prev) => [...newTransfers, ...prev].slice(0, 50));
    },
  });
 
  return (
    <ul>
      {transfers.map((t, i) => (
        <li key={i}>
          {t.from.slice(0, 8)}... → {t.to.slice(0, 8)}...: {t.value} USDC
        </li>
      ))}
    </ul>
  );
}

أنماط ربط المحفظة#

ربط محفظة المستخدم هو "تسجيل الدخول" في Web3. إلا أنه ليس تسجيل دخول. لا توجد جلسة، لا كعكة، لا حالة من جانب الخادم. ربط المحفظة يعطي تطبيقك إذن قراءة عنوان المستخدم وطلب توقيعات المعاملات. هذا كل شيء.

واجهة مزود EIP-1193#

كل محفظة تكشف واجهة قياسية مُعرّفة بـ EIP-1193. إنه كائن بطريقة request:

typescript
interface EIP1193Provider {
  request(args: { method: string; params?: unknown[] }): Promise<unknown>;
  on(event: string, handler: (...args: unknown[]) => void): void;
  removeListener(event: string, handler: (...args: unknown[]) => void): void;
}

MetaMask يحقنها كـ window.ethereum. المحافظ الأخرى إما تحقن خاصيتها الخاصة أو تستخدم أيضاً window.ethereum (مما يسبب تضارباً — مشكلة "حروب المحافظ"، حُلت جزئياً بـ EIP-6963).

typescript
// تفاعل محفظة منخفض المستوى (لا يجب فعل هذا مباشرة، لكنه مفيد للفهم)
 
// طلب الوصول للحساب
const accounts = await window.ethereum.request({
  method: "eth_requestAccounts",
});
console.log("العنوان المتصل:", accounts[0]);
 
// الحصول على السلسلة الحالية
const chainId = await window.ethereum.request({
  method: "eth_chainId",
});
console.log("Chain ID:", parseInt(chainId, 16)); // "0x1" -> 1 (الشبكة الرئيسية)
 
// الاستماع لتغييرات الحساب (المستخدم يبدل الحسابات في MetaMask)
window.ethereum.on("accountsChanged", (accounts: string[]) => {
  if (accounts.length === 0) {
    console.log("المحفظة منقطعة");
  } else {
    console.log("تبديل إلى:", accounts[0]);
  }
});
 
// الاستماع لتغييرات السلسلة (المستخدم يبدل الشبكات)
window.ethereum.on("chainChanged", (chainId: string) => {
  // النهج الموصى به هو إعادة تحميل الصفحة
  window.location.reload();
});

EIP-6963: اكتشاف المحافظ المتعددة#

نهج window.ethereum القديم ينكسر عندما يكون لدى المستخدمين محافظ متعددة مثبتة. أيها تحصل على window.ethereum؟ آخر واحدة تحقن؟ الأولى؟ إنها حالة سباق.

EIP-6963 يصلح هذا ببروتوكول اكتشاف مبني على أحداث المتصفح:

typescript
// اكتشاف كل المحافظ المتاحة
interface EIP6963ProviderDetail {
  info: {
    uuid: string;
    name: string;
    icon: string;
    rdns: string;  // اسم النطاق العكسي، مثل "io.metamask"
  };
  provider: EIP1193Provider;
}
 
const wallets: EIP6963ProviderDetail[] = [];
 
window.addEventListener("eip6963:announceProvider", (event: CustomEvent) => {
  wallets.push(event.detail);
});
 
// طلب كل المحافظ للإعلان عن نفسها
window.dispatchEvent(new Event("eip6963:requestProvider"));
 
// الآن 'wallets' يحتوي كل المحافظ المثبتة بأسمائها وأيقوناتها
// يمكنك عرض واجهة اختيار محفظة

WAGMI يتعامل مع كل هذا نيابةً عنك. عندما تستخدم موصل injected()، يستخدم تلقائياً EIP-6963 إذا كان متاحاً ويعود إلى window.ethereum.

WalletConnect#

WalletConnect هو بروتوكول يربط المحافظ المحمولة بتطبيقات سطح المكتب اللامركزية عبر خادم ترحيل. المستخدم يمسح رمز QR بمحفظته المحمولة، مؤسساً اتصالاً مشفراً. طلبات المعاملات تُرحَّل من تطبيقك اللامركزي إلى هاتفهم.

مع WAGMI، إنه مجرد موصل آخر:

typescript
import { walletConnect } from "wagmi/connectors";
 
const connector = walletConnect({
  projectId: "YOUR_PROJECT_ID", // احصل عليه من cloud.walletconnect.com
  showQrModal: true,
});

التعامل مع تبديل السلسلة#

المستخدمون غالباً على الشبكة الخاطئة. تطبيقك اللامركزي على الشبكة الرئيسية، هم متصلون بـ Sepolia. أو هم على Polygon وتحتاج الشبكة الرئيسية. WAGMI يوفر useSwitchChain:

typescript
"use client";
 
import { useAccount, useSwitchChain } from "wagmi";
import { mainnet } from "wagmi/chains";
 
function NetworkGuard({ children }: { children: React.ReactNode }) {
  const { chain } = useAccount();
  const { switchChain, isPending } = useSwitchChain();
 
  if (!chain) return <p>يرجى ربط محفظتك</p>;
 
  if (chain.id !== mainnet.id) {
    return (
      <div>
        <p>يرجى التبديل إلى Ethereum Mainnet</p>
        <button
          onClick={() => switchChain({ chainId: mainnet.id })}
          disabled={isPending}
        >
          {isPending ? "جاري التبديل..." : "تبديل الشبكة"}
        </button>
      </div>
    );
  }
 
  return <>{children}</>;
}

IPFS والبيانات الوصفية#

NFTs لا تخزن الصور على السلسلة. الـ blockchain يخزن URI يشير إلى ملف JSON للبيانات الوصفية، والذي بدوره يحتوي URL للصورة. النمط القياسي، المُعرّف بدالة tokenURI لـ ERC-721:

Contract.tokenURI(42) → "ipfs://QmXyz.../42.json"

ملف JSON هذا يتبع مخططاً قياسياً:

json
{
  "name": "Cool NFT #42",
  "description": "A very cool NFT",
  "image": "ipfs://QmImageHash...",
  "attributes": [
    { "trait_type": "Background", "value": "Blue" },
    { "trait_type": "Rarity", "value": "Legendary" }
  ]
}

IPFS CID مقابل URL#

عناوين IPFS تستخدم معرّفات المحتوى (CIDs) — تجزئات المحتوى نفسه. ipfs://QmXyz... تعني "المحتوى الذي تجزئته QmXyz...". هذا تخزين مُعنون بالمحتوى: URI يُشتق من المحتوى، لذا المحتوى لا يمكن أن يتغير أبداً بدون تغيير URI. هذا هو ضمان عدم القابلية للتغيير الذي تعتمد عليه NFTs (عندما تستخدم فعلاً IPFS — كثير تستخدم URLs مركزية بدلاً، وهذا علامة تحذير).

لعرض محتوى IPFS في المتصفح، تحتاج بوابة تترجم URIs لـ IPFS إلى HTTP:

typescript
function ipfsToHttp(uri: string): string {
  if (uri.startsWith("ipfs://")) {
    const cid = uri.replace("ipfs://", "");
    return `https://ipfs.io/ipfs/${cid}`;
    // أو استخدم بوابة مخصصة:
    // return `https://YOUR_PROJECT.mypinata.cloud/ipfs/${cid}`;
  }
  return uri;
}
 
// جلب بيانات NFT الوصفية
async function getNftMetadata(
  contractAddress: string,
  tokenId: bigint,
  provider: ethers.Provider
) {
  const contract = new ethers.Contract(
    contractAddress,
    ["function tokenURI(uint256 tokenId) view returns (string)"],
    provider
  );
 
  const tokenUri = await contract.tokenURI(tokenId);
  const httpUri = ipfsToHttp(tokenUri);
 
  const response = await fetch(httpUri);
  const metadata = await response.json();
 
  return {
    name: metadata.name,
    description: metadata.description,
    image: ipfsToHttp(metadata.image),
    attributes: metadata.attributes,
  };
}

خدمات التثبيت#

IPFS شبكة نظير لنظير. المحتوى يبقى متاحاً فقط طالما شخص ما يستضيفه ("يثبّته"). إذا رفعت صورة NFT إلى IPFS ثم أوقفت عقدتك، المحتوى يختفي.

خدمات التثبيت تُبقي محتواك متاحاً:

  • Pinata: الأكثر شعبية. واجهة بسيطة. طبقة مجانية سخية (1 جيجابايت). بوابات مخصصة لتحميل أسرع.
  • NFT.Storage: مجاني، مدعوم من Protocol Labs (مبتكرو IPFS). مصمم خصيصاً لبيانات NFT الوصفية. يستخدم Filecoin للاستمرارية طويلة المدى.
  • Web3.Storage: مشابه لـ NFT.Storage، أكثر عمومية.
typescript
// الرفع إلى Pinata
async function pinToIpfs(file: File): Promise<string> {
  const formData = new FormData();
  formData.append("file", file);
 
  const response = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${PINATA_JWT}`,
    },
    body: formData,
  });
 
  const result = await response.json();
  return `ipfs://${result.IpfsHash}`; // يُرجع CID
}

مشكلة الفهرسة#

إليك السر القذر لتطوير blockchain: لا يمكنك الاستعلام بكفاءة عن البيانات التاريخية من عقدة RPC.

تريد كل أحداث Transfer لرمز في السنة الماضية؟ ستحتاج لمسح ملايين الكتل مع eth_getLogs، مع تقسيم بقطع من 2,000-10,000 كتلة (الحد الأقصى يختلف حسب المزود). هذه آلاف استدعاءات RPC. سيستغرق دقائق إلى ساعات ويستنفد حصة API الخاصة بك.

تريد كل الرموز المملوكة لعنوان محدد؟ لا يوجد استدعاء RPC واحد لهذا. ستحتاج لمسح كل حدث Transfer لكل عقد ERC-20، تتبع الأرصدة. هذا غير ممكن عملياً.

تريد كل NFTs في محفظة؟ نفس المشكلة. تحتاج لمسح كل حدث Transfer لـ ERC-721 عبر كل عقد NFT.

الـ blockchain هو بنية بيانات محسّنة للكتابة. ممتاز في معالجة المعاملات الجديدة. سيء في الإجابة على الاستعلامات التاريخية. هذا هو عدم التوافق الأساسي بين ما تحتاجه واجهات التطبيقات اللامركزية وما توفره السلسلة أصلياً.

بروتوكول The Graph#

The Graph هو بروتوكول فهرسة لامركزي. تكتب "subgraph" — مخطط ومجموعة من معالجات الأحداث — وThe Graph يفهرس السلسلة ويقدم البيانات عبر واجهة GraphQL.

graphql
# مخطط Subgraph (schema.graphql)
type Transfer @entity {
  id: Bytes!
  from: Bytes!
  to: Bytes!
  value: BigInt!
  blockNumber: BigInt!
  timestamp: BigInt!
}
 
type Account @entity {
  id: Bytes!
  balance: BigInt!
  transfersFrom: [Transfer!]! @derivedFrom(field: "from")
  transfersTo: [Transfer!]! @derivedFrom(field: "to")
}
typescript
// الاستعلام من subgraph في الواجهة الأمامية
const SUBGRAPH_URL =
  "https://api.studio.thegraph.com/query/YOUR_ID/YOUR_SUBGRAPH/v0.0.1";
 
async function getRecentTransfers(address: string) {
  const query = `
    query GetTransfers($address: Bytes!) {
      transfers(
        where: { from: $address }
        orderBy: blockNumber
        orderDirection: desc
        first: 100
      ) {
        id
        from
        to
        value
        blockNumber
        timestamp
      }
    }
  `;
 
  const response = await fetch(SUBGRAPH_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query, variables: { address } }),
  });
 
  const { data } = await response.json();
  return data.transfers;
}

المقايضة: The Graph يضيف زمن استجابة (عادة 1-2 كتلة خلف رأس السلسلة) وتبعية أخرى. الشبكة اللامركزية لديها أيضاً تكاليف فهرسة (تدفع برموز GRT). للمشاريع الأصغر، الخدمة المستضافة (Subgraph Studio) مجانية.

واجهات Alchemy و Moralis المحسّنة#

إذا كنت لا تريد صيانة subgraph، كلا من Alchemy و Moralis يقدمان واجهات مفهرسة مسبقاً تجيب على الاستعلامات الشائعة مباشرة:

typescript
// Alchemy: الحصول على كل أرصدة رموز ERC-20 لعنوان
const response = await fetch(
  `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY`,
  {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: 1,
      method: "alchemy_getTokenBalances",
      params: ["0xAddress...", "erc20"],
    }),
  }
);
 
// يُرجع كل أرصدة رموز ERC-20 في استدعاء واحد
// مقابل مسح balanceOf() لكل عقد ERC-20 ممكن
typescript
// Alchemy: الحصول على كل NFTs المملوكة لعنوان
const response = await fetch(
  `https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY/getNFTs?owner=0xAddress...`
);
 
const { ownedNfts } = await response.json();
for (const nft of ownedNfts) {
  console.log(`${nft.title} - ${nft.contract.address}#${nft.tokenId}`);
}

هذه الواجهات مملوكة ومركزية. أنت تُقايض اللامركزية بتجربة المطور. لمعظم التطبيقات اللامركزية، هذه مقايضة تستحق. مستخدموك لا يهتمون إذا كانت عرض محفظتهم يأتي من subgraph أو من قاعدة بيانات Alchemy. يهتمون أن يُحمّل في 200 ميلي ثانية بدلاً من 30 ثانية.

الأخطاء الشائعة#

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

BigInt في كل مكان#

Ethereum يتعامل بأرقام كبيرة جداً. أرصدة ETH بالـ wei (10^18). إمدادات الرموز يمكن أن تكون 10^27 أو أعلى. Number في JavaScript يمكنه فقط تمثيل الأعداد الصحيحة بأمان حتى 2^53 - 1 (حوالي 9 * 10^15). هذا لا يكفي لمبالغ wei.

typescript
// خطأ — فقدان دقة صامت
const balance = 1000000000000000000; // 1 ETH بالـ wei
const double = balance * 2;
console.log(double); // 2000000000000000000 — يبدو صحيحاً، لكن...
 
const largeBalance = 99999999999999999999; // ~100 ETH
console.log(largeBalance);          // 100000000000000000000 — خطأ! مُقرّب لأعلى.
console.log(largeBalance === 100000000000000000000); // true — فساد بيانات
 
// صحيح — استخدم BigInt
const balance = 1000000000000000000n;
const double = balance * 2n;
console.log(double.toString()); // "2000000000000000000" — صحيح
 
const largeBalance = 99999999999999999999n;
console.log(largeBalance.toString()); // "99999999999999999999" — صحيح

قواعد BigInt في كود التطبيقات اللامركزية:

  1. لا تحوّل أبداً مبالغ wei إلى Number. استخدم BigInt في كل مكان، حوّل إلى نصوص مقروءة للعرض فقط.
  2. لا تستخدم أبداً Math.floor، Math.round، إلخ. على BigInts. لا تعمل. استخدم القسمة الصحيحة: amount / 10n ** 6n.
  3. JSON لا يدعم BigInt. إذا سلسلت حالة تتضمن BigInts، تحتاج مُسلسل مخصص: JSON.stringify(data, (_, v) => typeof v === "bigint" ? v.toString() : v).
  4. استخدم دوال التنسيق من المكتبات. ethers.formatEther()، ethers.formatUnits()، formatEther() و formatUnits() من viem. تتعامل مع التحويل بشكل صحيح.
typescript
import { formatUnits, parseUnits } from "viem";
 
// العرض: BigInt → نص مقروء
const weiAmount = 1500000000000000000n; // 1.5 ETH
const display = formatUnits(weiAmount, 18); // "1.5"
 
// الإدخال: نص مقروء → BigInt
const userInput = "1.5";
const wei = parseUnits(userInput, 18); // 1500000000000000000n
 
// USDC لديها 6 أعشار، وليس 18
const usdcAmount = 100000000n; // 100 USDC
const usdcDisplay = formatUnits(usdcAmount, 6); // "100.0"

عمليات المحفظة غير المتزامنة#

كل تفاعل محفظة غير متزامن ويمكن أن يفشل بطرق يحتاج تطبيقك للتعامل معها بأناقة:

typescript
// المستخدم يمكنه رفض أي طلب محفظة
try {
  const tx = await writeContract({
    address: contractAddress,
    abi: ERC20_ABI,
    functionName: "approve",
    args: [spenderAddress, amount],
  });
} catch (error) {
  if (error.code === 4001) {
    // المستخدم رفض المعاملة في محفظته
    // هذا طبيعي — ليس خطأ يجب الإبلاغ عنه
    showToast("المعاملة ملغاة");
  } else if (error.code === -32603) {
    // خطأ JSON-RPC داخلي — غالباً يعني أن المعاملة ستعود
    showToast("المعاملة ستفشل. تحقق من رصيدك.");
  } else {
    // خطأ غير متوقع
    console.error("خطأ معاملة:", error);
    showToast("حدث خطأ. يرجى المحاولة مرة أخرى.");
  }
}

مزالق غير المتزامن الرئيسية:

  • طلبات المحفظة معطلة من جانب المستخدم. await في كودك يمكن أن تأخذ 30 ثانية بينما المستخدم يقرأ تفاصيل المعاملة في MetaMask. لا تعرض مؤشر تحميل يجعلهم يظنون أن شيئاً معطل.
  • المستخدم يمكنه تبديل الحسابات أثناء التفاعل. تطلب موافقة من الحساب أ، المستخدم يبدل إلى الحساب ب، ثم يوافق. الآن الحساب ب وافق لكنك على وشك إرسال معاملة من الحساب أ. تحقق دائماً من الحساب المتصل قبل العمليات الحرجة.
  • أنماط الكتابة ذات الخطوتين شائعة. كثير من عمليات DeFi تتطلب approve + execute. المستخدم يحتاج لتوقيع معاملتين. إذا وافق لكن لم ينفذ، تحتاج لفحص حالة الإذن وتخطي خطوة الموافقة في المرة التالية.

أخطاء عدم تطابق الشبكة#

هذه تُهدر وقت تصحيح أكثر من أي مشكلة أخرى. عقدك على الشبكة الرئيسية. محفظتك على Sepolia. مزود RPC يشير إلى Polygon. ثلاث شبكات مختلفة، ثلاث حالات مختلفة، ثلاث blockchains غير مترابطة تماماً. ورسالة الخطأ عادة غير مفيدة — "التنفيذ عاد" أو "العقد غير موجود".

typescript
// فحص دفاعي للسلسلة
import { useAccount, useChainId } from "wagmi";
 
function useRequireChain(requiredChainId: number) {
  const chainId = useChainId();
  const { isConnected } = useAccount();
 
  if (!isConnected) {
    return { ready: false, error: "يرجى ربط محفظتك" };
  }
 
  if (chainId !== requiredChainId) {
    return {
      ready: false,
      error: `يرجى التبديل إلى ${getChainName(requiredChainId)}. أنت على ${getChainName(chainId)}.`,
    };
  }
 
  return { ready: true, error: null };
}

التقدم على الآخرين في DeFi#

عندما ترسل مبادلة على DEX، معاملتك المعلقة مرئية في mempool. بوت يمكنه رؤية تداولك، التقدم عليك بدفع السعر لأعلى، ترك تداولك ينفذ بسعر أسوأ، ثم البيع فوراً بعد ذلك لجني ربح. هذا يُسمى "هجوم الساندويتش".

كمطور واجهة أمامية، لا يمكنك منع هذا بالكامل، لكن يمكنك التخفيف منه:

typescript
// ضبط تسامح الانزلاق على مبادلة بنمط Uniswap
const amountOutMin = expectedOutput * 995n / 1000n; // 0.5% تسامح انزلاق
 
// استخدام موعد نهائي لمنع المعاملات المعلقة طويلة العمر
const deadline = BigInt(Math.floor(Date.now() / 1000) + 60 * 20); // 20 دقيقة
 
await router.swapExactTokensForTokens(
  amountIn,
  amountOutMin,  // الحد الأدنى المقبول للمخرجات — يعود إذا حصلنا على أقل
  [tokenA, tokenB],
  userAddress,
  deadline,       // يعود إذا لم يُنفذ خلال 20 دقيقة
);

للمعاملات عالية القيمة، فكر في استخدام Flashbots Protect RPC، الذي يرسل المعاملات مباشرة إلى بناة الكتل بدلاً من mempool العام. هذا يمنع هجمات الساندويتش بالكامل لأن الروبوتات لا ترى أبداً معاملتك المعلقة:

typescript
// استخدام Flashbots Protect كنقطة نهاية RPC
const provider = new ethers.JsonRpcProvider("https://rpc.flashbots.net");

ارتباك الأعشار#

ليس كل الرموز لديها 18 عشرية. USDC و USDT لديهما 6. WBTC لديه 8. بعض الرموز لديها 0، 2، أو أعشار عشوائية. اقرأ دائماً decimals() من العقد قبل تنسيق المبالغ:

typescript
async function formatTokenAmount(
  tokenAddress: string,
  rawAmount: bigint,
  provider: ethers.Provider
): Promise<string> {
  const contract = new ethers.Contract(
    tokenAddress,
    ["function decimals() view returns (uint8)", "function symbol() view returns (string)"],
    provider
  );
 
  const [decimals, symbol] = await Promise.all([
    contract.decimals(),
    contract.symbol(),
  ]);
 
  return `${ethers.formatUnits(rawAmount, decimals)} ${symbol}`;
}
 
// formatTokenAmount(USDC, 1000000n, provider) → "1.0 USDC"
// formatTokenAmount(WETH, 1000000000000000000n, provider) → "1.0 WETH"
// formatTokenAmount(WBTC, 100000000n, provider) → "1.0 WBTC"

فشل تقدير Gas#

عندما يفشل estimateGas، عادةً يعني أن المعاملة ستعود. لكن رسالة الخطأ غالباً مجرد "لا يمكن تقدير gas" بدون إشارة للسبب. استخدم eth_call لمحاكاة المعاملة والحصول على سبب الإعادة الفعلي:

typescript
import { createPublicClient, http, decodeFunctionResult } from "viem";
 
async function simulateAndGetError(client: ReturnType<typeof createPublicClient>, tx: object) {
  try {
    await client.call({
      account: tx.from,
      to: tx.to,
      data: tx.data,
      value: tx.value,
    });
    return null; // لا خطأ — المعاملة ستنجح
  } catch (error) {
    // فك رموز سبب الإعادة
    if (error.data) {
      // نصوص الإعادة الشائعة
      if (error.data.startsWith("0x08c379a0")) {
        // Error(string) — إعادة قياسية مع رسالة
        const reason = decodeAbiParameters(
          [{ type: "string" }],
          `0x${error.data.slice(10)}`
        );
        return `إعادة: ${reason[0]}`;
      }
    }
    return error.message;
  }
}

تجميع كل شيء معاً#

إليك مكون React كامل ومبسط يربط محفظة، يقرأ رصيد رمز، ويرسل تحويلاً. هذا هو الهيكل العظمي لكل تطبيق لامركزي:

typescript
"use client";
 
import { useAccount, useConnect, useDisconnect, useReadContract, useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { injected } from "wagmi/connectors";
import { formatUnits, parseUnits } from "viem";
import { useState } from "react";
 
const USDC_ADDRESS = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
 
const USDC_ABI = [
  {
    name: "balanceOf",
    type: "function",
    stateMutability: "view",
    inputs: [{ name: "account", type: "address" }],
    outputs: [{ name: "", type: "uint256" }],
  },
  {
    name: "transfer",
    type: "function",
    stateMutability: "nonpayable",
    inputs: [
      { name: "to", type: "address" },
      { name: "amount", type: "uint256" },
    ],
    outputs: [{ name: "", type: "bool" }],
  },
] as const;
 
export function TokenDashboard() {
  const { address, isConnected } = useAccount();
  const { connect } = useConnect();
  const { disconnect } = useDisconnect();
 
  const [recipient, setRecipient] = useState("");
  const [amount, setAmount] = useState("");
 
  // قراءة الرصيد — يعمل فقط عند تعريف العنوان
  const { data: balance, refetch: refetchBalance } = useReadContract({
    address: USDC_ADDRESS,
    abi: USDC_ABI,
    functionName: "balanceOf",
    args: address ? [address] : undefined,
    query: { enabled: !!address },
  });
 
  // كتابة: تحويل الرموز
  const {
    writeContract,
    data: txHash,
    isPending: isSigning,
    error: writeError,
  } = useWriteContract();
 
  // انتظار التأكيد
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash: txHash,
  });
 
  // إعادة جلب الرصيد بعد التأكيد
  if (isSuccess) {
    refetchBalance();
  }
 
  if (!isConnected) {
    return (
      <button onClick={() => connect({ connector: injected() })}>
        ربط المحفظة
      </button>
    );
  }
 
  return (
    <div>
      <p>متصل: {address}</p>
      <p>
        رصيد USDC:{" "}
        {balance !== undefined ? formatUnits(balance, 6) : "جاري التحميل..."}
      </p>
 
      <div>
        <input
          placeholder="عنوان المستلم (0x...)"
          value={recipient}
          onChange={(e) => setRecipient(e.target.value)}
        />
        <input
          placeholder="المبلغ (مثل 100)"
          value={amount}
          onChange={(e) => setAmount(e.target.value)}
        />
        <button
          onClick={() => {
            writeContract({
              address: USDC_ADDRESS,
              abi: USDC_ABI,
              functionName: "transfer",
              args: [recipient as `0x${string}`, parseUnits(amount, 6)],
            });
          }}
          disabled={isSigning || isConfirming}
        >
          {isSigning
            ? "تأكيد في المحفظة..."
            : isConfirming
              ? "جاري التأكيد..."
              : "إرسال USDC"}
        </button>
      </div>
 
      {writeError && <p style={{ color: "red" }}>{writeError.message}</p>}
      {isSuccess && <p style={{ color: "green" }}>تم تأكيد التحويل!</p>}
      {txHash && (
        <a
          href={`https://etherscan.io/tx/${txHash}`}
          target="_blank"
          rel="noopener noreferrer"
        >
          عرض على Etherscan
        </a>
      )}
 
      <button onClick={() => disconnect()}>قطع الاتصال</button>
    </div>
  );
}

إلى أين من هنا#

هذا المقال غطى المفاهيم والأدوات الأساسية لمطوري الويب الداخلين إلى Ethereum. هناك عمق أكثر بكثير في كل مجال:

  • Solidity: إذا أردت كتابة العقود، وليس فقط التفاعل معها. الوثائق الرسمية ودورات Patrick Collins هي أفضل نقاط البداية.
  • معايير ERC: ERC-20 (الرموز القابلة للاستبدال)، ERC-721 (NFTs)، ERC-1155 (متعدد الرموز)، ERC-4626 (الخزائن المرمّزة). كل منها يُعرّف واجهة قياسية تُنفذها كل العقود في تلك الفئة.
  • الطبقة الثانية: Arbitrum، Optimism، Base، zkSync. نفس تجربة المطور، تكاليف gas أقل، افتراضات ثقة مختلفة قليلاً. كود ethers.js و viem الخاص بك يعمل بشكل مطابق — فقط غيّر معرّف السلسلة وعنوان RPC.
  • تجريد الحسابات (ERC-4337): التطور التالي لتجربة مستخدم المحفظة. محافظ عقود ذكية تدعم رعاية gas، الاسترداد الاجتماعي، والمعاملات المجمّعة. هذا هو المكان الذي يتجه إليه نمط "ربط المحفظة".
  • MEV وترتيب المعاملات: إذا كنت تبني DeFi، فهم القيمة القصوى القابلة للاستخراج ليس اختيارياً. وثائق Flashbots هي المرجع القياسي.

نظام blockchain يتحرك بسرعة، لكن الأساسيات في هذا المقال — الحسابات، المعاملات، ترميز ABI، استدعاءات RPC، فهرسة الأحداث — لم تتغير منذ 2015 ولن تتغير قريباً. تعلمها جيداً وكل شيء آخر مجرد سطح API.

مقالات ذات صلة