البلوكتشين والعملات الرقمية الأدلة التقنية

كيف يعمل Bitcoin؟ المعاملة والمخرجات غير المنفقة UTXO

نبني معاملة Bitcoin بأيدينا ونتتبعها حتى الكتلة، لنفهم لماذا لا يوجد رصيد، وأين تذهب الرسوم، ومتى تصبح الدفعة دفعة فعلاً.

كيف يعمل Bitcoin؟ المعاملة والمخرجات غير المنفقة UTXO

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

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

وسوف نناقش في هذا المقال ما يلي:

  • لماذا لا يوجد رصيد في Bitcoin، ونموذج المخرجات غير المنفقة UTXO مقارنة بنموذج الحسابات.
  • تشريح معاملة حقيقية: المدخلات والمخرجات والباقي Change والرسوم Fee التي لا حقل لها.
  • القفل والمفتاح: النص البرمجي Script وأنواع العناوين، والشاهد Witness.
  • بناء معاملة يدوياً بأوامر createrawtransaction وsignrawtransactionwithwallet وtestmempoolaccept، مع الأخطاء التي تظهر.
  • الطابور Mempool والتأكيدات، ومحاولة إنفاق مزدوج حقيقية بالاستبدال Replace-by-Fee.
  • الرسوم والأولوية، وتسريع معاملة عالقة بطريقة الابن يدفع عن الأب CPFP، والفرق بين قواعد الإجماع وقواعد السياسة.
  • ما الذي تكشفه المعاملة عن خصوصيتك.
  • الكتلة من الداخل: الرأس ومعاملة المكافأة Coinbase والوزن Weight.
  • ما يعنيه كل ذلك لمن يبني برنامجاً يحسب أو يستلم مدفوعات Bitcoin، مع قائمة تحقق.

أين الرصيد؟ نموذج المخرجات غير المنفقة UTXO

في البنك لكل حساب رصيد، والتحويل ينقص رصيداً ويزيد آخر، وقد بنينا هذا النموذج في مقال بناء المحفظة الرقمية. وEthereum يعمل بنفس الفكرة تقريباً، فلكل عنوان رصيد محفوظ في حالة الشبكة. أما Bitcoin فاختار طريقة أخرى تماماً، تشبه النقد الورقي أكثر من الحساب البنكي: لا يوجد رصيد محفوظ في أي مكان، وإنما توجد «قطع» من العملة، كل قطعة نتجت من معاملة سابقة، ولها قيمة محددة، ومقفلة بشرط يحدد من يستطيع أن يصرفها. وهذه القطع تسمى المخرجات غير المنفقة Unspent Transaction Outputs، واختصاراً UTXO.

والورقة الأصلية تقول ذلك صراحة في القسم الثاني من ورقة Bitcoin، فتعرف العملة الإلكترونية بأنها سلسلة من التوقيعات الرقمية: كل مالك ينقلها إلى التالي بأن يوقع هاش المعاملة السابقة والمفتاح العام للمالك التالي. ثم في القسم التاسع «Combining and Splitting Value» تشرح أن المعاملة تستطيع أن تجمع عدة مدخلات وأن تنتج مخرجين في العادة: واحد للدفعة، وواحد يعيد الباقي إلى المرسل. وهذا بالضبط ما يحدث عندما تدفع ورقة من فئة 50 لشراء شيء بـ 30: لا تقطع الورقة، وإنما تعطيها كاملة وتأخذ الباقي.

والآن نرى ذلك بعقدة حقيقية. نشغل Bitcoin Core في وضع regtest، وننشئ محفظتين، واحدة للمتجر وواحدة لعميل، ونعدن 101 كتلة لعنوان العميل حتى تصبح مكافأة الكتلة الأولى قابلة للصرف كما شرحنا في المقال الأول. والخيار -fallbackfee يعطي المحفظة معدل رسوم تستخدمه عندما لا تجد في الشبكة التجريبية بيانات كافية لتقدير الرسوم:

docker run -d --name lab-how-btc bitcoin/bitcoin:31.1 bitcoind -regtest -fallbackfee=0.0001 -printtoconsole=0
alias btc='docker exec --user bitcoin lab-how-btc bitcoin-cli -regtest'
btc createwallet shop
btc createwallet customer
C=$(btc -rpcwallet=customer getnewaddress "" bech32); echo $C
btc -rpcwallet=customer generatetoaddress 101 $C | tail -1
btc -rpcwallet=customer getbalances | head -8
btc -rpcwallet=customer listunspent | head -30

والمخرج سوف يكون كما يلي، بعد سطري اسم المحفظتين:

bcrt1qu6drtkmge7fl5ctu2e6hf99u7693j5nu8dhqrn
]
{
  "mine": {
    "trusted": 50.00000000,
    "untrusted_pending": 0.00000000,
    "immature": 5000.00000000
  },
  "lastprocessedblock": {
    "hash": "383718fe26905aa64c38e2934299db92a2fd4caeef94a8a3dcd8051657b55769",
[
  {
    "txid": "ab84a33596945ba397ba074d9c3cdabba6466339628b465d4363a861e34e8a9a",
    "vout": 0,
    "address": "bcrt1qu6drtkmge7fl5ctu2e6hf99u7693j5nu8dhqrn",
    "label": "",
    "scriptPubKey": "0014e69a35db68cf93fa617c56757494bcf68b19527c",
    "amount": 50.00000000,
    "confirmations": 101,
    "spendable": true,
    "solvable": true,
    "desc": "wpkh([00485124/84h/1h/0h/0/0]037c590c5a2b61c8ed66b0a63166eb5b9bd6b743d520035f20e35750a61cb3aece)#jpghv7g2",
    "parent_descs": [
      "wpkh([00485124/84h/1h/0h]tpubDCu3V73gwbLWv93kwL9Z4auyMEsunZ8mNeFdZz1zbGF7oTxNZtjnMFfE6moiuSckawJWiGbyB7i3YQGS1xJ9Urt3JWgqrYBc9Sng3vSc9n4/0/*)#8sk00cmn"
    ],
    "safe": true
  }
]

في الإعداد أعلاه لاحظ التالي:

  • الأمر getbalances يعرض «رصيداً» قابلاً للاستخدام قدره 50، ولكن هذا رقم تحسبه المحفظة، فهي تبحث في السلسلة عن كل القطع التي تملك مفاتيحها وتجمع قيمها، والسلسلة نفسها لا تحفظ هذا الرقم.
  • الأمر listunspent يعرض الحقيقة: قطعة واحدة قابلة للصرف، يعرفها رقم المعاملة التي أنشأتها txid وترتيبها فيها vout، وهذا الزوج هو «اسم» القطعة الذي سوف نستخدمه في المعاملة.
  • الرقم immature 5000 هو مكافآت مئة كتلة أخرى لم يمر عليها بعد مئة كتلة، وهي موجودة ولكن قواعد الإجماع تمنع صرفها حتى تنضج.
  • الحقل scriptPubKey هو القفل الموضوع على القطعة، والحقل desc يبين المفتاح العام ومسار اشتقاقه من مفتاح المحفظة الرئيسي، وسوف نشرح هذا المسار في مقال المحافظ والمفاتيح.

والصورة التالية تقارن النموذجين:

مقارنة من بطاقتين: على اليمين نموذج المخرجات غير المنفقة في Bitcoin حيث محفظة المتجر فيها قطعتان 0.30 من المعاملة 4461c40b و50.0003 من مكافأة الكتلة 102، والرصيد الذي تعرضه المحفظة هو مجموع القطع التي تستطيع فتحها، وعلى اليسار نموذج الحسابات في Ethereum والبنوك حيث لكل عنوان رصيد واحد مثل 9998.99 ETH مع عداد nonce
في Bitcoin الرصيد مجموع تحسبه المحفظة، وفي نموذج الحسابات الرصيد قيمة محفوظة

وقد يتساءل البعض: لماذا اختار Bitcoin هذا النموذج الأصعب في المحاسبة؟ والإجابة أن له مزايا في التحقق: كل قطعة إما منفقة أو غير منفقة، فالعقدة تحتاج أن تعرف فقط هل المدخل موجود في مجموعة القطع غير المنفقة، ولا تحتاج أن تحسب أرصدة أو ترتب معاملات حساب واحد بعداد، ويمكن التحقق من معاملات مختلفة بالتوازي لأنها لا تلمس نفس القطع. والثمن هو ما تعانيه أنت في قصتنا: البرنامج الذي يحسب «الرصيد» أو «الدخل» يجب أن يبني هذه الأرقام بنفسه من القطع.

تشريح معاملة: المدخلات والمخرجات والباقي والرسوم

المعاملة في Bitcoin لها جزءان رئيسيان. المدخلات Inputs: كل مدخل يشير إلى قطعة غير منفقة بالزوج txid:vout، ويحمل ما يثبت أن صاحب المعاملة يملك فتح قفلها، أي التوقيع والمفتاح العام في العناوين الحديثة. والمخرجات Outputs: كل مخرج قيمة وقفل جديد، والقفل يحدد من يستطيع أن يصرف هذه القيمة لاحقاً. والقاعدة الصارمة أن المدخل يستهلك القطعة كلها، فإذا كانت القطعة 50 وأردت أن تدفع 0.3 فيجب أن تنشئ مخرجاً ثانياً يعيد لك الباقي، وإلا ذهب كل الفرق.

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

تجربة عملية: نبني معاملة يدوياً

العميل يريد أن يدفع للمتجر 0.3. المحفظة تستطيع أن تفعل ذلك بأمر واحد، ولكننا سوف نبني المعاملة بأنفسنا حتى نرى كل قطعة. نحتاج عنواناً من المتجر، وعنواناً جديداً للباقي من محفظة العميل، ثم ننشئ المعاملة بمدخل واحد هو القطعة ab84…:0 ومخرجين:

S=$(btc -rpcwallet=shop getnewaddress "" bech32)
CH=$(btc -rpcwallet=customer getrawchangeaddress bech32)
echo "shop=$S change=$CH"
RAW=$(btc createrawtransaction '[{"txid":"ab84a33596945ba397ba074d9c3cdabba6466339628b465d4363a861e34e8a9a","vout":0}]' "[{\"$S\":0.3},{\"$CH\":49.6999}]")
echo $RAW
btc decoderawtransaction $RAW

والمخرج سوف يكون كما يلي:

shop=bcrt1q96qfzvdvz5wg3l3gamxf2nr3dz4yhw694prvgh change=bcrt1q728pl2zm4rem3eck2t7vapy2p5c5yhljqrz9dt
02000000019a8a4ee361a863435d468b62396346a6bbda3c9c4d07ba97a35b949635a384ab0000000000fdffffff0280c3c901000000001600142e809131ac151c88fe28eecc954c7168aa4bbb4570073c2801000000160014f28e1fa85ba8f3b8e71652fcce848a0d31425ff200000000
{
  "txid": "4aaa0dec49a9a236202c6a26a3eef9ab0f494fdfde302435fbb7703c938a208c",
  "hash": "4aaa0dec49a9a236202c6a26a3eef9ab0f494fdfde302435fbb7703c938a208c",
  "version": 2,
  "size": 113,
  "vsize": 113,
  "weight": 452,
  "locktime": 0,
  "vin": [
    {
      "txid": "ab84a33596945ba397ba074d9c3cdabba6466339628b465d4363a861e34e8a9a",
      "vout": 0,
      "scriptSig": {
        "asm": "",
        "hex": ""
      },
      "sequence": 4294967293
    }
  ],
  "vout": [
    {
      "value": 0.30000000,
      "n": 0,
      "scriptPubKey": {
        "asm": "0 2e809131ac151c88fe28eecc954c7168aa4bbb45",
        "desc": "addr(bcrt1q96qfzvdvz5wg3l3gamxf2nr3dz4yhw694prvgh)#6w3hkaue",
        "hex": "00142e809131ac151c88fe28eecc954c7168aa4bbb45",
        "address": "bcrt1q96qfzvdvz5wg3l3gamxf2nr3dz4yhw694prvgh",
        "type": "witness_v0_keyhash"
      }
    },
    {
      "value": 49.69990000,
      "n": 1,
      "scriptPubKey": {
        "asm": "0 f28e1fa85ba8f3b8e71652fcce848a0d31425ff2",
        "desc": "addr(bcrt1q728pl2zm4rem3eck2t7vapy2p5c5yhljqrz9dt)#wwprjnm3",
        "hex": "0014f28e1fa85ba8f3b8e71652fcce848a0d31425ff2",
        "address": "bcrt1q728pl2zm4rem3eck2t7vapy2p5c5yhljqrz9dt",
        "type": "witness_v0_keyhash"
      }
    }
  ]
}

في الإعداد أعلاه لاحظ التالي:

  • المعاملة الخام Raw Transaction سلسلة Hex تبدأ بالإصدار 02000000 مكتوباً بترتيب البايت الصغير أولاً، ثم عدد المدخلات، ثم txid القطعة مقلوب البايتات 9a8a4ee3…، وهذه نفس مشكلة ترتيب البايتات التي رأيناها في إثبات Merkle في مقال التشفير في البلوكتشين.
  • المدخل لا يحمل أي توقيع بعد، فالحقل scriptSig فارغ ولا يوجد شاهد، لذلك هذه المعاملة غير صالحة حتى توقع.
  • القيمة sequence تساوي 4294967293، أي 0xfffffffd، وهي أقل من الحد الذي يصفه BIP125، وتعني أن المرسل يسمح باستبدال المعاملة برسوم أعلى، وسوف نستخدم هذه الخاصية بعد قليل.
  • المخرجان 0.3 و49.6999، ومجموعهما 49.9999، والمدخل 50، فالرسوم 0.0001 دون أن يكتبها أحد في أي حقل.
  • الأمر getrawchangeaddress يعطي عنواناً من فرع الباقي في المحفظة، وهو يختلف عن عنوان الاستلام، حتى لا يعرف المراقب بسهولة أي المخرجين الباقي.

التوقيع والفحص قبل الإرسال

الآن نوقع المعاملة بمفاتيح محفظة العميل، ثم نفحصها بالأمر testmempoolaccept الذي يطبق كل قواعد القبول دون أن يرسل المعاملة فعلاً:

SIGNED=$(btc -rpcwallet=customer signrawtransactionwithwallet $RAW | grep '"hex"' | cut -d'"' -f4)
btc decoderawtransaction $SIGNED | sed -n 1,25p
btc testmempoolaccept "[\"$SIGNED\"]"

والمخرج سوف يكون كما يلي:

{
  "txid": "4aaa0dec49a9a236202c6a26a3eef9ab0f494fdfde302435fbb7703c938a208c",
  "hash": "01d995d5aa1916d34488182a9f863e2856697bc4ca8a76e9165755896270d818",
  "version": 2,
  "size": 222,
  "vsize": 141,
  "weight": 561,
  "locktime": 0,
  "vin": [
    {
      "txid": "ab84a33596945ba397ba074d9c3cdabba6466339628b465d4363a861e34e8a9a",
      "vout": 0,
      "scriptSig": {
        "asm": "",
        "hex": ""
      },
      "txinwitness": [
        "3044022067327a3f38548846d2f8250487713a922c0bcfb5c586cd06c80fe587166fc9cf02201e827722055e29f75af7219e2f16f9d6abdabc321fcb0da7e627f43f73cf03ec01",
        "037c590c5a2b61c8ed66b0a63166eb5b9bd6b743d520035f20e35750a61cb3aece"
      ],
      "sequence": 4294967293
    }
  ],
  "vout": [
    {
[
  {
    "txid": "4aaa0dec49a9a236202c6a26a3eef9ab0f494fdfde302435fbb7703c938a208c",
    "wtxid": "01d995d5aa1916d34488182a9f863e2856697bc4ca8a76e9165755896270d818",
    "allowed": true,
    "vsize": 141,
    "fees": {
      "base": 0.00010000,
      "effective-feerate": 0.00070921,
      "effective-includes": [
        "01d995d5aa1916d34488182a9f863e2856697bc4ca8a76e9165755896270d818"
      ]
    }
  }
]

في الإعداد أعلاه لاحظ التالي:

  • ظهر الشاهد txinwitness وفيه عنصران: توقيع ECDSA بترميز DER ينتهي بالبايت 01 الذي يعني أن التوقيع يغطي كل المعاملة SIGHASH_ALL، ثم المفتاح العام المضغوط 037c590c…، وهو نفس المفتاح الذي ظهر في desc.
  • الحقل txid لم يتغير بعد التوقيع، أما hash أي wtxid فتغير، والسبب أن معرف المعاملة في SegWit لا يحسب من بيانات الشاهد كما يصف BIP141، وهذا ما أغلق باب تطويع المعرف الذي شرحناه في مقال التشفير.
  • الحجم الحقيقي 222 بايت، ولكن الوزن Weight يساوي 561، والحجم الافتراضي vsize يساوي 141، لأن بايتات الشاهد تحسب بربع وزن غيرها، والرسوم في الشبكة تحسب على vsize.
  • الحقل effective-feerate بوحدة BTC لكل ألف vbyte، أي 0.00070921 تساوي قرابة 71 ساتوشي لكل vbyte، والساتوشي Satoshi أصغر وحدة، وكل 1 BTC فيه 100 مليون ساتوشي.

نجرب العطل: مخرجات أكثر من المدخل، ونسيان الباقي

أشهر خطأين في بناء المعاملات يدوياً: أن تكتب مخرجات مجموعها أكبر من المدخلات، وأن تنسى مخرج الباقي. نجرب الاثنين بنفس المدخل:

IN='[{"txid":"ab84a33596945ba397ba074d9c3cdabba6466339628b465d4363a861e34e8a9a","vout":0}]'
R1=$(btc createrawtransaction "$IN" "[{\"$S\":0.3},{\"$CH\":49.8}]")
S1=$(btc -rpcwallet=customer signrawtransactionwithwallet $R1 | grep '"hex"' | cut -d'"' -f4)
btc testmempoolaccept "[\"$S1\"]" | grep -E 'allowed|reject'
R2=$(btc createrawtransaction "$IN" "[{\"$S\":0.3}]")
S2=$(btc -rpcwallet=customer signrawtransactionwithwallet $R2 | grep '"hex"' | cut -d'"' -f4)
btc sendrawtransaction $S2

والمخرج سوف يكون كما يلي:

    "allowed": false,
    "reject-reason": "bad-txns-in-belowout",
    "reject-details": "bad-txns-in-belowout, value in (50.00) < value out (50.10)"
error code: -25
error message:
Fee exceeds maximum configured by user (e.g. -maxtxfee, maxfeerate)

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

والصورة التالية تجمع تشريح المعاملة الصحيحة:

تشريح المعاملة 4aaa0dec: مدخل بقيمة 50 يشير إلى ab84a335:0 ومعه الشاهد من توقيع ومفتاح عام وقيمة sequence 0xfffffffd، ثم مخرجان: 0.3 للمتجر بقفل 0014 2e809131 من نوع P2WPKH، و49.6999 باقياً للعميل بقفل 0014 f28e1fa8، وبطاقة صفراء تقول إن الرسوم لا حقل لها وتساوي 50 ناقص 0.3 ناقص 49.6999 أي 0.0001، وفي الأسفل الحجم 222 بايت والوزن 561 وvsize 141 ومعدل قرابة 71 ساتوشي لكل vbyte، والخطآن bad-txns-in-belowout وFee exceeds maximum، وأن txid لم يتغير بعد التوقيع
المعاملة تستهلك القطعة كاملة، والباقي مخرج عادي، والرسوم هي الفرق الذي لا يكتب

القفل والمفتاح: النص البرمجي Script وأنواع العناوين

القفل في كل مخرج ليس اسم مالك، وإنما برنامج صغير بلغة Bitcoin Script، وهي لغة مبنية على المكدس Stack، وفيها عمليات محدودة عمداً، فلا توجد فيها حلقات، حتى لا يستطيع أحد أن يكتب قفلاً يجعل العقد تدور إلى ما لا نهاية. والمدخل الذي يريد أن يصرف القطعة يقدم «المفتاح»: بيانات توضع على المكدس، ثم ينفذ القفل، فإذا انتهى التنفيذ بقيمة صحيحة قبلت العقدة الصرف.

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

النوعبداية العنوان على الشبكة الرئيسيةما الذي يقفل عليهالمرجع
P2PKH1هاش المفتاح العام، والتوقيع في scriptSigالنوع الأصلي، بترميز Base58Check
P2SH3هاش نص برمجي كامل يقدمه الصارف، مثل محفظة متعددة التوقيعBIP16
P2WPKHbc1qهاش المفتاح العام، والتوقيع في الشاهدBIP141، بترميز Bech32
P2TRbc1pمفتاح عام واحد يمكن أن يخفي خلفه شروطاً أخرىBIP341، بتوقيعات Schnorr

وفي شبكة regtest تبدأ عناوين الشاهد بـ bcrt1 بدلاً من bc1 كما رأينا. وقوة هذا التصميم أن القفل يمكن أن يكون أكثر من «توقيع واحد»: توقيعان من ثلاثة، أو توقيع لا يصلح قبل تاريخ معين بفضل أقفال الوقت Timelocks، أو توقيع مع سر يكشفه الطرف الآخر، وهذه الأقفال الأخيرة هي التي تبني عليها شبكة Lightning كما سوف نرى في مقالها.

وقد يتساءل البعض: لماذا لم يجعل Bitcoin لغته كاملة مثل لغة Solidity في Ethereum، فيستطيع أي شخص أن يكتب أي شرط؟ والإجابة أن كل عقدة في العالم تنفذ كل قفل في كل معاملة، والقفل الذي فيه حلقات أو حسابات طويلة يصبح طريقاً سهلاً لإبطاء الشبكة كلها، وEthereum حلت هذه المشكلة بالغاز Gas الذي يدفع ثمن كل خطوة، أما Bitcoin فحلها بأن جعل اللغة محدودة من الأصل، فكل قفل ينتهي تنفيذه في وقت معروف ويمكن تقدير تكلفته من حجمه. والثمن أن المنطق المعقد لا يكتب داخل السلسلة، وإنما يبنى فوقها بتركيب أقفال بسيطة، كما تفعل Lightning.

الطابور Mempool والتأكيدات

الآن نرسل المعاملة الصحيحة، ونسأل العقدة عنها في الطابور، ونرى ماذا يعرض المتجر في محفظته:

btc sendrawtransaction $SIGNED
btc getmempoolentry 4aaa0dec49a9a236202c6a26a3eef9ab0f494fdfde302435fbb7703c938a208c | grep -E '"(vsize|weight|time|height|bip125-replaceable)"|"base"'
btc -rpcwallet=shop getbalances | head -6

والمخرج سوف يكون كما يلي:

4aaa0dec49a9a236202c6a26a3eef9ab0f494fdfde302435fbb7703c938a208c
  "vsize": 141,
  "weight": 561,
  "time": 1793150778,
  "height": 101,
    "base": 0.00010000,
  "bip125-replaceable": true,
{
  "mine": {
    "trusted": 0.00000000,
    "untrusted_pending": 0.30000000,
    "immature": 0.00000000
  },

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

خمس مراحل: إنشاء المعاملة بالأمر createrawtransaction، ثم التوقيع بالأمر signrawtransactionwithwallet، ثم الفحص بالأمر testmempoolaccept، ثم الطابور حيث يرى المتجر untrusted_pending 0.3، ثم الكتلة 103 حيث تصبح التأكيدات 1، وتحتها تنبيه أحمر أن المعاملة في الطابور يمكن أن تستبدل برسوم أعلى فلا تسلم البضاعة قبل دخولها كتلة
بين الطابور والكتلة مسافة بين وعد ودفع، والتقرير المالي يجب أن يفرق بينهما

نجرب العطل: إنفاق مزدوج في الطابور

الآن نلعب دور عميل غير أمين: بعد أن رأى المتجر الدفعة المعلقة وجهز الطلب، يرسل العميل معاملة جديدة تستهلك نفس القطعة ab84…:0، ولكنها تدفع كل شيء إلى عنوان آخر يملكه هو. نجرب أولاً بنفس الرسوم، ثم برسوم أعلى:

X=$(btc -rpcwallet=customer getnewaddress "" bech32); echo "own=$X"
R=$(btc createrawtransaction "$IN" "[{\"$X\":49.9999}]")
Sg=$(btc -rpcwallet=customer signrawtransactionwithwallet $R | grep '"hex"' | cut -d'"' -f4)
btc sendrawtransaction $Sg
R=$(btc createrawtransaction "$IN" "[{\"$X\":49.9997}]")
Sg=$(btc -rpcwallet=customer signrawtransactionwithwallet $R | grep '"hex"' | cut -d'"' -f4)
btc sendrawtransaction $Sg
btc getrawmempool
btc -rpcwallet=shop getbalances | sed -n 3,5p

والمخرج سوف يكون كما يلي:

own=bcrt1qvw7la38kke4z38gahetqzjgscn5yg4vxve3yvy
error code: -26
error message:
insufficient fee, rejecting replacement cd815a3ce451267c47779e75e3a4775dbd860acc63d5dc7387ba0bb87f7e9ead, not enough additional fees to relay; 0.00 < 0.00000011
7c78eeca02b7531189a2ea1242b0db3efaf12621892576fae79e4c1f16d63875
[
  "7c78eeca02b7531189a2ea1242b0db3efaf12621892576fae79e4c1f16d63875"
]
    "trusted": 0.00000000,
    "untrusted_pending": 0.00000000,
    "immature": 0.00000000

في الإعداد أعلاه لاحظ التالي:

  • المعاملة البديلة بنفس الرسوم رفضت، والسبب في رسالة الخطأ نفسها: البديل يجب أن يدفع رسوماً إضافية تغطي على الأقل تكلفة نشر معاملة جديدة بحجمها، وهنا الفرق صفر والمطلوب 11 ساتوشي على الأقل.
  • البديل برسوم 0.0003 قبل فوراً، والطابور الآن فيه المعاملة 7c78eeca… وحدها، أي أن معاملة الدفع للمتجر خرجت من الطابور تماماً.
  • رصيد المتجر المعلق أصبح صفراً، فالدفعة التي ظهرت قبل دقائق اختفت، وهذا بالضبط ما يراه أي برنامج يقرأ محفظة المتجر ليكتب كشف حسابه.

وقد يتساءل البعض: أليس هذا الإنفاق المزدوج الذي جاء Bitcoin ليمنعه؟ والإجابة أن Bitcoin يمنعه في الكتل وليس في الطوابير: الطابور سياسة محلية لكل عقدة، ومنذ الإصدار 28.0 أصبح الخيار mempoolfullrbf مفعلاً افتراضياً كما في ملاحظات إصدار Bitcoin Core 28.0، فتقبل العقد استبدال أي معاملة غير مؤكدة بأخرى تدفع رسوماً أعلى، سواءً أعلنت المعاملة الأصلية قابليتها للاستبدال أم لم تعلن. والقاعدة الحقيقية الوحيدة هي ما يدخل الكتلة، فنعدن كتلة ونرى من فاز، ثم نحاول إعادة إرسال معاملة الدفع الأصلية:

M=$(btc -rpcwallet=shop getnewaddress)
H=$(btc generatetoaddress 1 $M | grep -oE '[0-9a-f]{64}')
btc getblock $H 1 | grep -E '"(height|nTx|size|weight)"'
btc getblock $H 1 | sed -n '/"tx"/,/]/p'
btc sendrawtransaction $SIGNED

والمخرج سوف يكون كما يلي:

  "height": 102,
  "nTx": 2,
  "size": 440,
  "weight": 1325,
  "tx": [
    "90e7737fafc70d9b8f39747c30924a52b6376ee04f481d0705044e5659e83df7",
    "7c78eeca02b7531189a2ea1242b0db3efaf12621892576fae79e4c1f16d63875"
  ]
error code: -25
error message:
bad-txns-inputs-missingorspent

الكتلة 102 فيها معاملة المكافأة ومعاملة العميل التي أعادت المال إليه، والآن أصبحت القطعة ab84…:0 منفقة في السلسلة، فأي معاملة أخرى تحاول استهلاكها ترفض من كل العقد برسالة bad-txns-inputs-missingorspent. والصورة التالية تلخص المحاولة:

أربع خطوات: المعاملة 4aaa0dec برسوم 0.0001 في الطابور والمتجر يرى 0.3 معلقة، ثم استبدال بنفس الرسوم ترفضه العقدة برسالة insufficient fee، ثم استبدال برسوم 0.0003 بالمعاملة 7c78eeca يحل محل الأولى فيصبح رصيد المتجر المعلق صفراً، ثم الكتلة 102 تحتوي 7c78eeca وإعادة إرسال الأولى ترفض برسالة bad-txns-inputs-missingorspent، وتحتها شرح أن mempoolfullrbf مفعل افتراضياً منذ الإصدار 28.0
الإنفاق المزدوج ممكن في الطابور، والكتلة هي التي تحسم أي معاملة تبقى
⚠️
لا تسلم بضاعة أو خدمة على معاملة لم تدخل كتلة إلا إذا قبلت خسارة قيمتها، ولا تسجلها في التقارير المالية كدفعة مستلمة. وكم تأكيداً تنتظر بعد ذلك يعتمد على قيمة المبلغ، وقد شرحنا حسابه من الورقة الأصلية في قسم التأكيدات في مقال الإجماع.

الكتلة من الداخل: الرأس ومعاملة المكافأة

الكتلة في Bitcoin رأس من 80 بايت، ثم قائمة المعاملات. والرأس هو الجزء الوحيد الذي يجري عليه التعدين: الإصدار، وهاش الكتلة السابقة، وجذر Merkle الذي يلخص كل المعاملات، والوقت، والهدف المضغوط bits، وال Nonce، وقد تحققنا من رأس كتلة التكوين بأنفسنا في مقال التاريخ. وأول معاملة في كل كتلة معاملة خاصة اسمها معاملة المكافأة Coinbase Transaction، لا تستهلك أي قطعة سابقة، وتنشئ قيمة جديدة. والآن نفتحها في الكتلة 102:

H=$(btc getblockhash 102)
btc getblock $H 2 | python3 -c "
import json,sys;b=json.load(sys.stdin);cb=b['tx'][0]
print('coinbase input:',cb['vin'][0].get('coinbase'))
for o in cb['vout']: print('out',o['n'],o['value'],o['scriptPubKey']['type'])
t=b['tx'][1];print('tx1 fee',t.get('fee'))"
btc getblockstats 102 | grep -E '"(subsidy|totalfee|txs|total_weight)"'

والمخرج سوف يكون كما يلي:

coinbase input: 016600
out 0 50.0003 witness_v0_keyhash
out 1 0.0 nulldata
tx1 fee 0.0003
  "subsidy": 5000000000,
  "total_weight": 437,
  "totalfee": 30000,
  "txs": 2,

في الإعداد أعلاه لاحظ التالي:

  • مدخل معاملة المكافأة لا يشير إلى أي قطعة، وإنما يحمل بيانات حرة، وأولها 016600: بايت طول ثم الرقم 102 بترتيب البايت الصغير أولاً، أي ارتفاع الكتلة، وهذا مطلوب منذ BIP34 حتى لا تتكرر معاملتا مكافأة بنفس المعرف في كتلتين.
  • المخرج الأول 50.0003: المكافأة الأساسية Subsidy وقيمتها 50 في هذه الشبكة التجريبية، أي 5,000,000,000 ساتوشي، مضافاً إليها رسوم المعاملة الوحيدة 30,000 ساتوشي. أي أن الرسوم التي «اختفت» من معاملة العميل ظهرت هنا عند من أنشأ الكتلة.
  • المخرج الثاني قيمته صفر ونوعه nulldata، وهو التزام الشاهد Witness Commitment الذي يطلبه BIP141: هاش يربط بيانات الشواهد بالكتلة، لأن الشواهد لا تدخل في جذر Merkle العادي.
  • الكتلة كلها هنا 437 وحدة وزن، والحد الأقصى في قواعد الإجماع 4,000,000 وحدة، وهو القيمة MAX_BLOCK_WEIGHT في consensus.h، ولهذا السبب تتنافس المعاملات على مكان في الكتلة بالرسوم لكل vbyte وليس بالمبلغ.

أما كيف تنخفض المكافأة الأساسية مع الوقت، وكيف يجد المعدنون ال Nonce، فهذا موضوع مقال التعدين. والصورة التالية تجمع شكل الكتلة:

رأس الكتلة من 80 بايت بستة حقول: الإصدار وهاش السابقة وجذر Merkle والوقت والهدف وNonce، وتحته ثلاث بطاقات: معاملة المكافأة في الكتلة 102 بمدخل 016600 أي الارتفاع 102 حسب BIP34 ومخرج 50.0003 ومخرج nulldata لالتزام الشاهد، ثم المعاملة 7c78eeca برسوم 0.0003 مع subsidy 5000000000 وtotalfee 30000، ثم حدود الكتلة: MAX_BLOCK_WEIGHT أربعة ملايين وWITNESS_SCALE_FACTOR 4 وCOINBASE_MATURITY 100
الرسوم التي يدفعها المرسلون تظهر في معاملة المكافأة عند من أنشأ الكتلة

كيف تفعل المحفظة كل ذلك بأمر واحد؟

بعد التجربة اليدوية نرى ما تفعله المحفظة عادة. العميل يدفع للمتجر 0.3 بالأمر العادي، مع معدل رسوم 5 ساتوشي لكل vbyte، ثم ننظر إلى المعاملة من جهة المتجر قبل الكتلة وبعدها:

S=$(btc -rpcwallet=shop getnewaddress)
T=$(btc -rpcwallet=customer -named sendtoaddress address=$S amount=0.3 fee_rate=5); echo $T
btc -rpcwallet=shop gettransaction $T | grep -E '"(amount|confirmations|trusted)"'
btc getrawtransaction $T 1 | grep -E '"(vsize|value|n)"'
btc generatetoaddress 1 $(btc -rpcwallet=shop getnewaddress) > /dev/null
btc -rpcwallet=shop gettransaction $T | grep -E '"(confirmations|blockheight)"'
btc -rpcwallet=shop listunspent | grep -E '"(txid|amount|confirmations)"'

والمخرج سوف يكون كما يلي:

4461c40ba1a919dfbe228e44eed815ad9e5af18a0434007159dea7b50f46c5ce
  "amount": 0.30000000,
  "confirmations": 0,
  "trusted": false,
      "amount": 0.30000000,
  "vsize": 141,
      "value": 0.30000000,
      "n": 0,
      "value": 49.69969295,
      "n": 1,
  "confirmations": 1,
  "blockheight": 103,
    "txid": "4461c40ba1a919dfbe228e44eed815ad9e5af18a0434007159dea7b50f46c5ce",
    "amount": 0.30000000,
    "confirmations": 1,

المحفظة فعلت كل ما فعلناه يدوياً: اختارت قطعة من قطع العميل، وهي هنا القطعة 49.9997 التي أنشأتها معاملة الاستبدال، وأنشأت مخرجاً للمتجر ومخرج الباقي 49.69969295، وحسبت الرسوم من الحجم: 141 vbyte ضرب 5 يساوي 705 ساتوشي، أي 49.9997 ناقص 0.3 ناقص 49.69969295. وهذه العملية اسمها اختيار القطع Coin Selection، والمحافظ تستخدم فيها خوارزميات توازن بين رسوم اليوم وعدد القطع الصغيرة التي تتراكم وتجعل المعاملات القادمة أكبر وأغلى. ولاحظ أن المعاملة ظهرت عند المتجر بصفر تأكيدات وtrusted: false، ثم بعد الكتلة 103 أصبح لها تأكيد واحد وظهرت في listunspent كقطعة يملكها المتجر. والصورة التالية تبين ما تغير في مجموعة القطع غير المنفقة:

ثلاثة أعمدة: قبل المعاملة 4461c40b كانت هناك قطعة 7c78eeca:0 بقيمة 49.9997 للعميل، ثم المعاملة تستهلكها كاملة وتنشئ 0.3 للمتجر و49.69969295 باقياً للعميل برسوم 705 ساتوشي أي 5 ساتوشي لكل vbyte ضرب 141، ثم بعد الكتلة 103 حذفت القطعة القديمة وأضيفت القطعتان الجديدتان، وتحتها شرح أن أداة المحاسبة يجب أن تفرق بين المخرج الذي ذهب لطرف آخر والمخرج الذي عاد للمحفظة نفسها
كل معاملة تحذف قطعاً كاملة وتضيف قطعاً جديدة، والباقي يعود إلى نفس المالك بعنوان جديد

الرسوم والأولوية: من يدخل الكتلة أولاً؟

الكتلة محدودة بالوزن، والمعاملات في الطابور أكثر مما يتسع له المكان في أوقات الازدحام، لذلك من ينشئ الكتلة يرتب المعاملات بمعدل الرسوم لكل vbyte، لأن هذا ما يعظم ما يحصل عليه من مساحة محدودة. والمبلغ المرسل لا يهم في هذا الترتيب إطلاقاً: معاملة تنقل 0.001 برسوم 50 ساتوشي لكل vbyte تسبق معاملة تنقل 1000 برسوم 2. وهنا يظهر سؤال عملي يواجهه كل من يستلم مدفوعات: ماذا لو أرسل العميل معاملة برسوم منخفضة جداً، وبقيت في الطابور ساعات أو أياماً؟

هناك طريقتان. الأولى أن يستبدل المرسل معاملته بأخرى برسوم أعلى، وهي نفس آلية الاستبدال Replace-by-Fee التي رأيناها في الإنفاق المزدوج، ولكنها هنا تستخدم لغرض مشروع. والثانية لا تحتاج المرسل أصلاً: المستلم يصرف المخرج الذي وصله، وهو ما زال غير مؤكد، في معاملة جديدة برسوم عالية، فيضطر من ينشئ الكتلة أن يضم المعاملة الأب حتى يأخذ رسوم الابن، لأن الابن لا يصلح دون أبيه. وهذه الطريقة اسمها الابن يدفع عن الأب Child Pays For Parent، واختصاراً CPFP. والآن نجربها: العميل يرسل للمتجر 0.5 برسوم 1 ساتوشي لكل vbyte، ثم يصرف المتجر المخرج الذي وصله إلى عنوان آخر له برسوم 10,000 ساتوشي:

S=$(btc -rpcwallet=shop getnewaddress)
P=$(btc -rpcwallet=customer -named sendtoaddress address=$S amount=0.5 fee_rate=1); echo "parent $P"
btc getmempoolentry $P | grep -E '"(vsize|base|ancestor|descendant)"'
V=$(btc getrawtransaction $P 1 | python3 -c "
import json,sys;t=json.load(sys.stdin);print([o['n'] for o in t['vout'] if abs(o['value']-0.5)<1e-9][0])")
S2=$(btc -rpcwallet=shop getnewaddress)
R=$(btc createrawtransaction "[{\"txid\":\"$P\",\"vout\":$V}]" "[{\"$S2\":0.4999}]")
Sg=$(btc -rpcwallet=shop signrawtransactionwithwallet $R | grep '"hex"' | cut -d'"' -f4)
Cd=$(btc sendrawtransaction $Sg); echo "child $Cd"
btc getmempoolentry $Cd | grep -E '"(vsize|base|ancestor|descendant)"|"(ancestorcount|ancestorsize)"'
btc getmempoolentry $P | grep -E '"(descendant|descendantcount|descendantsize)"'

والمخرج سوف يكون كما يلي:

parent 285cd99c17d8918209cd7a0423fde4feee72719adaca5ddb91d60e50a2569725
  "vsize": 141,
    "base": 0.00000141,
    "ancestor": 0.00000141,
    "descendant": 0.00000141,
child 0ac91b155e0de0c12c30c924451294254507a42957742796a0aabaa22cfdd7f4
  "vsize": 110,
  "ancestorcount": 2,
  "ancestorsize": 251,
    "base": 0.00010000,
    "ancestor": 0.00010141,
    "descendant": 0.00010000,
  "descendantcount": 2,
  "descendantsize": 251,
    "descendant": 0.00010141,

في الإعداد أعلاه لاحظ التالي:

  • المعاملة الأب دفعت 141 ساتوشي على 141 vbyte، أي 1 ساتوشي لكل vbyte، وهذا معدل قد يبقيها في الطابور طويلاً على الشبكة الحقيقية في وقت الازدحام.
  • العقدة لا تنظر إلى الابن وحده، وإنما إلى العائلة: الابن له سلف واحد، ومجموع رسوم الاثنين ancestor يساوي 10,141 ساتوشي على حجم إجمالي 251 vbyte، أي قرابة 40 ساتوشي لكل vbyte للحزمة كلها.
  • والأب أيضاً يعرف أن له نسلاً Descendant يدفع عنه، فمن يبني الكتلة يحسب معدل الحزمة ويقرر على أساسه.

ثم نعدن كتلة ونرى ما دخلها:

H=$(btc generatetoaddress 1 $(btc -rpcwallet=shop getnewaddress) | grep -oE '[0-9a-f]{64}')
btc getblock $H 1 | sed -n '/"tx"/,/]/p'
btc gettxoutsetinfo | grep -E '"(height|txouts|total_amount)"'

والمخرج سوف يكون كما يلي:

  "tx": [
    "8fd18eba1f38ff247fb46c4a9e0c4099c8f849c49c7fdf4832538e48c033450c",
    "285cd99c17d8918209cd7a0423fde4feee72719adaca5ddb91d60e50a2569725",
    "0ac91b155e0de0c12c30c924451294254507a42957742796a0aabaa22cfdd7f4"
  ]
  "height": 104,
  "txouts": 106,
  "total_amount": 5200.00000000,

الأب والابن دخلا الكتلة معاً بالترتيب الذي يفرضه الاعتماد بينهما، الأب أولاً لأن الابن يستهلك مخرجه. ولاحظ السطر الأخير: مجموع كل القطع غير المنفقة في الشبكة 5200 بالضبط، وهو 104 كتل ضرب 50، أي أن العملات الجديدة لا تأتي إلا من المكافأة الأساسية في معاملات المكافأة، أما الرسوم فلا تنشئ قيمة، وإنما تنقلها من المرسلين إلى من أنشأ الكتلة. وهذا الرقم هو ما تتحقق منه أي عقدة كاملة: أن أحداً لم ينشئ قيمة من لا شيء، وهو التحقق الذي فشل في حادثة تجاوز القيمة سنة 2010 التي ذكرناها في مقال التاريخ.

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

ماذا يحدث إذا اختفت الكتلة؟ إعادة التنظيم Reorg

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

H=$(btc getbestblockhash); echo $H
btc -rpcwallet=shop gettransaction 0ac91b155e0de0c12c30c924451294254507a42957742796a0aabaa22cfdd7f4 | grep -E '"confirmations"'
btc invalidateblock $H
btc getblockcount
btc getrawmempool
btc -rpcwallet=shop gettransaction 0ac91b155e0de0c12c30c924451294254507a42957742796a0aabaa22cfdd7f4 | grep -E '"confirmations"'
btc reconsiderblock $H
btc getblockcount

والمخرج سوف يكون كما يلي:

2b3eb330e41df68a16e1d7f84e4fafe15e4aca3340b39043dd6d06ee93ad8fe4
  "confirmations": 1,
103
[
  "285cd99c17d8918209cd7a0423fde4feee72719adaca5ddb91d60e50a2569725",
  "0ac91b155e0de0c12c30c924451294254507a42957742796a0aabaa22cfdd7f4"
]
  "confirmations": 0,
104

عندما خرجت الكتلة 104 رجعت السلسلة إلى الكتلة 103، وعادت معاملتا الأب والابن إلى الطابور، وأصبح تأكيد معاملة المتجر صفراً، ثم عندما أعدنا الكتلة عادت إلى تأكيد واحد. وفي إعادة التنظيم الحقيقية تعود المعاملات إلى الطابور بنفس الطريقة، وغالباً تدخل الكتلة التالية، إلا إذا كان أحد قد أرسل معاملة منافسة تستهلك نفس القطع ودخلت السلسلة الجديدة، وهذا بالضبط ما يحاوله من يهاجم بالإنفاق المزدوج بعد التأكيد. لذلك يجب أن يتعامل برنامج المحاسبة مع التأكيدات كرقم يمكن أن ينقص، وأن يعيد حساب ما سجله في آخر كتلتين أو ثلاث عندما تتغير السلسلة، بدلاً من أن يكتب الدفعة مرة واحدة وينساها.

قواعد الإجماع وقواعد السياسة Policy

رأينا حتى الآن عدة رسائل رفض، وهي ليست من نوع واحد، والفرق بينها مهم لمن يكتب كوداً يبني المعاملات. فهناك قواعد الإجماع Consensus Rules، وهي القواعد التي تجعل الكتلة كلها غير صالحة إذا خالفتها معاملة واحدة، وكل العقد في العالم تطبقها بنفس الطريقة، مثل bad-txns-in-belowout وbad-txns-inputs-missingorspent. وهناك قواعد السياسة Policy، وهي ما تقبله عقدتك في طابورها وتنشره لغيرها، وتختلف من عقدة لأخرى ومن إصدار لآخر، مثل حد الرسوم الأقصى الذي أوقف خطأ نسيان الباقي، وحد الاستبدال. والآن نجرب اثنتين أخريين يقع فيهما المبرمجون: مخرج صغير جداً، ومعاملة بقفل زمني Locktime لم يحن وقته:

S=$(btc -rpcwallet=shop getnewaddress)
CH=$(btc -rpcwallet=customer getrawchangeaddress)
IN='[{"txid":"2d073a46ef2d3731eedc27a05a822eb5fb14d502930f785e2b86eb68abad12ae","vout":0}]'
R=$(btc createrawtransaction "$IN" "[{\"$S\":0.00000100},{\"$CH\":49.9998}]")
Sg=$(btc -rpcwallet=customer signrawtransactionwithwallet $R | grep '"hex"' | cut -d'"' -f4)
btc testmempoolaccept "[\"$Sg\"]" | grep -E 'allowed|reject'
R=$(btc createrawtransaction "$IN" "[{\"$S\":0.3},{\"$CH\":49.6999}]" 200)
Sg=$(btc -rpcwallet=customer signrawtransactionwithwallet $R | grep '"hex"' | cut -d'"' -f4)
btc testmempoolaccept "[\"$Sg\"]" | grep -E 'allowed|reject'

والمخرج سوف يكون كما يلي، والقطعة 2d073a46…:0 مكافأة كتلة نضجت بقيمة 50:

    "allowed": false,
    "reject-reason": "dust",
    "reject-details": "dust, tx with dust output must be 0-fee"
    "allowed": false,
    "reject-reason": "non-final",
    "reject-details": "non-final"

في الإعداد أعلاه لاحظ التالي:

  • المخرج بقيمة 100 ساتوشي سمي غباراً Dust، وهو قاعدة سياسة: العقدة ترفض نشر مخرج تكلفة صرفه لاحقاً أكبر من قيمته أو قريبة منها، لأنه سيبقى في مجموعة القطع غير المنفقة عند كل العقد للأبد دون أن يصرفه أحد. ولاحظ أن رسالة الإصدار الحالي تذكر استثناءً واحداً لمعاملات دون رسوم، وهذه تفاصيل سياسة تتغير بين الإصدارات، لذلك لا تبن كودك على حد غبار ثابت حفظته من مقال، واسأل العقدة نفسها.
  • المعاملة الثانية صحيحة التوقيع والمبالغ، ولكنها تحمل في الحقل locktime القيمة 200، أي لا تصلح قبل الكتلة 200، والسلسلة عند الكتلة 104، فهي غير نهائية Non-final. وهذه قاعدة إجماع، فلا يجوز لأي كتلة أن تضمها قبل وقتها، والعقدة ترفضها في الطابور لأنه لا يمكن تعدينها الآن.
  • القفل الزمني ليس خطأً دائماً، وإنما أداة: معاملة موقعة مسبقاً لا تصلح إلا بعد تاريخ معين، وهذه بالضبط فكرة الأقفال التي يقوم عليها انسحاب الطرف الآخر في قنوات Lightning.

ما الذي تكشفه المعاملة عنك؟

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

وهذه الملاحظة هي أساس ما يسمى افتراض ملكية المدخلات المشتركة Common Input Ownership، وهو أول ما تستخدمه أدوات تحليل السلسلة. ويضاف إليه تخمين مخرج الباقي: المخرج الذي قيمته رقم غير مستدير مثل 49.69969295، أو الذي يذهب إلى نوع عنوان يطابق نوع المدخل، غالباً هو الباقي. لذلك المتجر الذي يعيد استخدام عنوان واحد لكل الطلبات يكشف لكل العالم كم استلم ومتى، والمحافظ الحديثة تولد عنواناً جديداً لكل استلام ولكل مخرج باقي لهذا السبب.

ماذا تفحص العقدة في كل معاملة؟

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

  1. الشكل: هل المعاملة مكتوبة بصيغة صحيحة، وفيها مدخل واحد على الأقل ومخرج واحد على الأقل، ولا توجد قيم سالبة أو أكبر من الحد الكلي للعملة.
  2. وجود المدخلات: كل مدخل يجب أن يشير إلى قطعة موجودة في مجموعة القطع غير المنفقة، أو في معاملة أخرى في الطابور كما في حالة الابن والأب، وإلا ظهر bad-txns-inputs-missingorspent.
  3. نضج المكافأة: إذا كانت القطعة من معاملة مكافأة فيجب أن يكون قد مر عليها مئة كتلة، وهي القيمة COINBASE_MATURITY التي شرحناها في المقال الأول.
  4. الحساب: مجموع المدخلات أكبر من أو يساوي مجموع المخرجات، والفرق هو الرسوم، وإلا ظهر bad-txns-in-belowout.
  5. الأقفال: تنفيذ النص البرمجي لكل مدخل مع قفل القطعة التي يصرفها، أي التحقق من التوقيع والمفتاح العام في حالتنا، وهذه أغلى خطوة في المعالجة، ولهذا السبب تحتفظ العقدة بذاكرة مؤقتة للتوقيعات التي تحققت منها.
  6. الوقت: هل انتهى القفل الزمني في locktime أو في أقفال المدخلات، وإلا فهي non-final.
  7. السياسة: الرسوم الدنيا للنشر، والغبار، والحجم الأقصى، وقواعد الاستبدال، وهذه خاصة بالطابور ولا تطبق على الكتل.

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

لماذا تكلف المدخلات الكثيرة أكثر؟

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

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

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

ماذا يعني كل هذا لبرنامج المحاسبة؟

الآن نعود إلى قصتنا ونجيب عن المفاجآت الثلاث واحدة واحدة، لأن كل واحدة منها خطأ حقيقي يقع فيه من يبني تقارير أو نظام استلام مدفوعات:

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

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

قائمة تحقق لمن يبني على معاملات Bitcoin

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

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

إزالة التجربة

docker rm -f -v lab-how-btc
docker image rm bitcoin/bitcoin:31.1

تدريب عملي

  1. أنشئ معاملة بمدخلين ومخرج واحد للمتجر ومخرج الباقي، واحسب الرسوم بنفسك قبل أن تسأل testmempoolaccept.
  2. كرر تجربة الاستبدال، ولكن اجعل المعاملة البديلة تدفع للمتجر نفس المبلغ برسوم أعلى، ولاحظ أن المتجر يرى معرف معاملة مختلفاً لنفس الدفعة.
  3. استخدم getblockstats على عدة كتل، واحسب لكل كتلة نسبة الرسوم إلى المكافأة الأساسية.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • Bitcoin لا يحفظ أرصدة، وإنما قطعاً غير منفقة UTXO، والرصيد مجموع تحسبه المحفظة من القطع التي تملك مفاتيحها.
  • كل معاملة تستهلك قطعاً كاملة وتنشئ قطعاً جديدة، والباقي مخرج عادي يعود إلى المرسل، والرسوم هي الفرق الذي لا حقل له.
  • القفل في كل مخرج برنامج صغير بلغة Script، والأنواع الشائعة P2PKH وP2SH وP2WPKH وP2TR، والتوقيع في المعاملات الحديثة يوضع في الشاهد فلا يغير txid.
  • الأخطاء الشائعة تظهر قبل الإرسال: مخرجات أكثر من المدخلات ترفضها كل العقد، ونسيان الباقي توقفه فقط حماية الرسوم في عقدتك.
  • المعاملة في الطابور وعد قابل للاستبدال، ورأينا إنفاقاً مزدوجاً حقيقياً أزال دفعة المتجر المعلقة، والكتلة هي التي تحسم.
  • معاملة المكافأة في كل كتلة تنشئ المكافأة الأساسية مع رسوم كل المعاملات، والكتل محدودة بالوزن، فتتنافس المعاملات بالرسوم لكل vbyte.
دليلكيف يعمل البلوكتشين Blockchain ومتى لا تحتاجهما المشكلة التي يحلها البلوكتشين أصلاً وكيف يعمل من الداخل، ومتى تكون قاعدة البيانات العادية هي الحل الأصح.دليلكيف تتفق شبكة البلوكتشين؟ إثبات العمل وإثبات الحصةكيف تتفق آلاف العقد التي لا يثق بعضها ببعض على سجل واحد، ولماذا يكلف الغش كهرباء في شبكة ومالاً محجوزاً في أخرى.دليلالتشفير في البلوكتشين: الهاش والمنحنيات والتوقيعاتكيف تصنع المفاتيح والعناوين والتوقيعات في Bitcoin وEthereum، ولماذا يكشف رقم مكرر واحد في التوقيع المفتاح الخاص.دليلالتعدين Mining في Bitcoin: المكافأة والتنصيف Halvingما الذي يحسبه جهاز التعدين، ومن أين يأتي دخله ولماذا ينقص للنصف، مع تعدين كتلة باليد على عقدة Bitcoin Core.دليلالمحافظ والمفاتيح: العبارة السرية وكيف تحمي أصولكما الذي تحفظه المحفظة فعلاً، وكيف تولد اثنتا عشرة كلمة كل مفاتيحك، وكيف تفصل شركة صغيرة بين معرفة الأرصدة والتوقيع.دليلتشغيل عقدة Bitcoin كاملة على سيرفرك مع Bitcoin Coreلماذا تشغل عقدة Bitcoin كاملة، وكيف تثبتها بـ Docker Compose وتؤمن واجهة RPC، وماذا يحدث في المزامنة الأولى والتقليم.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، واختبار التجارب على Docker Engine 29.8.0 مع Bitcoin Core v31.1.0 في وضع regtest.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.