อธิบายการทำงานของ PyLibra 🐍 + แกะ 🐑 Libra Core
ก่อนอื่นเลยคงต้องขอขอบคุณ Sorawit Suriyakarn กับ Python library ที่สามารถทำให้เราใช้ภาษา python ในการสร้างกระเป๋าตังเก็บเหรียญ Libra ในเครื่องคอมพิวเตอร์ของตัวเองได้ไม่ยาก
วันนี้เราจะมาเล่าการสร้าง PyLibra กันว่าเราทำยังไงกันบ้าง ซึ่งในนี้จะมีการเข้าไปอ่าน code บางส่วนของ Libra Core ด้วยนอกจาก code ของ PyLibra เองแล้ว เพราะฉนั้นอาจจะยาวนิดนึง (มีภาพประกอบเยอะ)
ส่วนการใช้งาน PyLibra นั้นสามารถเข้าไปอ่านวิธีการใช้งาน(การสร้างกระเป๋าตัง,ส่งเงิน, ฯลฯ) ได้ที่ https://github.com/bandprotocol/pylibra
ในปัจจุบันการที่เราจะใช้งาน Libra นั้น เราต้องสั่งการผ่าน Libra CLI เช่นในการสร้างกระเป๋าตัง, ส่งเงิน แต่ถ้าหากเราต้องการที่จะ สร้าง transaction, เซ็น transaction, ส่ง transaction เรายังไม่สามารถทำได้ผ่านทาง Libra CLI ได้เพราะทางผู้พัฒนายังไม่ได้ทำออกมาให้เราใช้ ด้วยเหตุนี้ทีมเราเลยไปศึกษา Libra Core แล้วเขียนออกมาเป็น python lib เพื่อให้เป็นอีกทางเลือกนึงของ community ที่จะได้นำไปศึกษาและพัฒนาต่อได้ง่ายขึ้น
หมายเหตุในการอธิบายจะมีการอ้างอิงถึง code ภาษา rust ที่ใช้ใน https://github.com/libra/libra ปัจจุบันด้วย ซึ่ง code อาจจะมีการอัพเดทจากทาง Libra เองในอนาคต
Outline
- การคุยกับ Node ของ Libra Testnet 💬
- Libra Account 🏦
- Libra Transaction 💸
เพื่อความเข้าใจที่มากขึ้นเราจะไล่ดู code กันไปขณะ go through
OK เริ่มกันเลย 🍾
การคุยกับ Node ของ Libra Testnet 💬
Libra เลือกใช้มาตราฐาน Protocol Buffers ในการคุยกันใน network
เราก็เลือกมาเฉพาะ .proto ที่เราใช้เท่านั้น จากนั้นสั่ง generate_protobuf.sh เพื่อคอมไพล์ .proto เหล่านั้นเป็น .py
Libra Account 🏦
Account ของ Libra ประกอบไปด้วย 3 ส่วน
- private key เป็นเลข random ขนาด 256 bits
- public key เป็นเลขขนาด 256 bits เช่นกัน ที่ถูกสร้างมาจาก private key อีกทีนึงโดยในที่นี้ Libra เลือกใช้วิธีตามมาตราฐาน Curve25519
ซึ่งใน PyLibra เราใช้ PyNaCl เป็น lib สำหรับสร้าง public key - Address แบบที่ชาวคริปโตคุ้นเคยกัน ซึ่งก็คือ sha3 ของ public key
ซึ่ง Account หลายๆ Accounts ของเรานั้นจะถูกเก็บอยู่ใน wallet ซึ่ง wallet นี้จะเก็บ mnemonic ไว้ 1 ก้อน
mnemonic ก้อนนั้นก็เปรียบเสมือนบรรพบุรุษ private key ที่เราสามารถเรียกฟังก์ชัน get_account(เลขจำนวน 1,2,3,…) แล้ว wallet จะ return account ที่ 1,2,3,… ตามที่เราเรียกพร้อมกับลูกๆ private key, public key, address
จุดต่างของ wallet ที่สร้างจาก PyLibra กับ Libra Core คือ mnemonic ของ Core นั้นมี 24 คำแบบ fixed ไว้เลย ณ ตอนนี้ …
ส่วนของ PyLibra นั้นเราสามารถกำหนดความปลอดภัยได้ (จำนวน bits เยอะก็ปลอดภัยขึ้น แต่ก็ต้องจำคำมากขึ้น) ซึ่งตอนนี้ PyLibra นั้น support 128 bits, 160 bits, 192 bits, 224 bits, 256 bits (12คำ, 15คำ, 18คำ, 21คำ, 24คำ ตามลำดับ)
โดย Default ของ PyLibra คือ 128 bits 12 คำ
หลายคนอาจจะสงสัยว่า เอ๊ะ แล้วพวก balance, sequence number, … พวกนี้เก็บไว้ที่ไหนกัน
คำตอบคือ เราไม่เก็บ แต่ว่าเราจะไปถาม Libra Testnet ทุกครั้งที่ผู้ใช้งานเรียกฟังก์ชัน get_account_states
ฟังก์ชัน get_account_states จะ return ก้อน AccountState กลับมาเป็น object ซึ่ง object AccountState นี้จะประกอบไปด้วย
- authentication key
- balance
- received events count
- sent events count
- sequence number
Libra Transaction 💸
เราเริ่มจากการไปดู Libra Core ของ Libra ก่อนว่าส่ง transaction กันยังไง ซึ่ง code ส่วนใหญ่ต่อจากนี้จะเป็นภาษา rust
ใน repository ของ libra เราก็เข้าไปที่
client/src/client_proxy.rs
เราจะเจอฟังก์ชัน transfer_coins
ซึ่งฟังก์ชัน transfer_coins จะไปเรียกฟังก์ชัน transfer_coins_int
สังเกตุว่าฟังก์ชัน transfer_coins_int ก็จะไปเรียกฟังก์ชัน vm_genesis::encode_transfer_program(address ผู้รับเงิน, จำนวนเงินที่ส่ง)
ซึ่งฟังก์ชันนี้จะ return code ของ Program ส่งเงินที่ compiled แล้วออกมา โดย Program นี้จะเป็นส่วนหนึ่งของ Transaction เป็นตัวกำหนดว่า Transaction นี้จะทำอะไร
จากนั้นก็จะไปเรียกฟังก์ชัน create_submit_transaction_req อีกที แล้วใส่ Program ส่งเงิน ไปเป็นหนึ่งในพารามิเตอร์
แล้วฟังก์ชัน create_submit_transaction_req จะ return object SubmitTransactionRequest
ซึ่งถ้าหากเราเข้าไปดูในไฟล์ admission_control.proto ที่มี สเปคของก้อน SubmitTransactionRequest อยู่จะพบว่าประกอบไปด้วย signed_txn เราก็อาจพอเดาได้ว่ามันน่าจะหมายถึง transaction ที่ถูก signed แล้ว
เราจึงต้องเรียกฟังก์ชัน create_signed_txn ก่อน
👉 👉 👉 เพื่อสร้างก้อน transaction ที่ถูก “เซ็น” แล้วขึ้นมา
แล้วค่อยสั่ง set_signed_txn
เพราะฉนั้นตอนนี้เราก็ไปดู create_signed_txn ต่อ
ซึ่งฟังก์ชัน create_signed_txn นั้นอยู่ในไฟล์
types/src/transaction_helpers.rs
ในฟังก์ชัน create_signed_txn นี้มีจุดน่าสนใจสองจุดคือ
- สร้าง ⚗️ transaction ยังไง 🤔
- types/src/transaction_helpers.rs > RawTransaction::new(พารามิเตอร์ 6 ตัว แบบที่เห็นในภาพ) - เซ็น ✍️ transaction ยังไง 🤔
- let hash = RawTransactionBytes(&bytes).hash();
- ใช้ RawTransactionHasher ในการ Hash
การสร้าง transaction ⚗️
หลังจากที่เราเจอฟังก์ชัน create_signed_txn ในไฟล์ types/src/transaction_helpers.rs เราพบว่ามีการฟังก์ชันนี้มีการสร้างก้อน
👉 RawTransaction ซึ่งเป็น object ที่มาจาก .proto
RawTransaction คือก้อน Transaction ที่ยังไม่ได้ ถูกเซ็น/signed
ภายใน object RawTransaction ของ Libra นั้นก็มีตัวแปรที่ blockchain dev คุ้นเคยกันดี เช่น
- sender_account (address คนส่ง)
- sequence_number (nonce ของคนส่ง ไว้ป้องกัน replay attack)
- oneof (อย่างใดอย่างหนึ่ง) ระหว่าง object Program กับ WriteSet
- max_gas_amount คล้ายๆ gas limit ใน Ethereum
- gas_unit_price ก็คล้ายๆ gas price ใน Ethereum
- expiration_time คิดว่าเป็นอายุของ transaction ว่าถ้าผ่านไปเวลาเท่านี้แล้วยังไม่ถูกขุดก็ให้เป็นโมฆะไป
ทีนี้เรามาดูที่ object Program ใน Libra เพราะว่าเราต้องส่งโปรแกรมไปกับ Transaction ด้วย เพื่อเป็นตัวกำหนดว่า Transaction นี้ให้ทำอะไร
แปลว่า ต้องมี code ของโปรแกรมแนบไปด้วยเวลาส่ง Transaction
แม้กระทั่งโอนเงิน 🙉
จาก Protobuf message เราพบว่าโปรแกรมประกอบไปด้วย 3 อย่าง
1. bytes code ซึ่งคือโปรแกรมภาษา move ที่ถูก compiled แล้ว
2. arguments หรือ input ของ โปรแกรม เช่น โปรแกรมส่งเงินมี input เป็น address ของผู้รับ และ จำนวนเงินที่ส่ง
3. modules คิดว่าเป็น code ของ module เวลาเรา deploy module บน Libra คล้ายๆเวลา deploy smart contract บน Ethereum (ยังไม่พบหลักฐานที่ชัดเจน)
ใน Core ของ Libra จะมี Program พื้นฐานที่ถูก compiled แล้วอยู่ เช่น Program ส่งเงิน, Program เสก/mint เงิน ฯลฯ
โดยเราสามารถเข้าไปดู Program พื้นฐานที่มีใน Libra ได้จาก
language/stdlib/src/transaction_scripts.rs
ในไฟล์ transaction_scripts.rs มี hardcode ไว้หมดแล้ว โดยจะ link ไปยังไฟล์โปรแกรมภาษา move
เมื่อเราเปิด Libra Core ขึ้นมาไฟล์ .mvir เหล่านี้ก็จะถูก compiled เป็น byte code เก็บไว้ในตัวแปรที่เป็น static
ตัวอย่าง byte code ของ Program ส่งเงิน“4c49425241564d0a010007014a00000004000000034e000000060000000c54000000050000000d5900000004000000055d0000002900000004860000002000000007a60000000d00000000000001000200010300020002040203020402063c53454c463e0c4c696272614163636f756e74046d61696e0f7061795f66726f6d5f73656e64657200000000000000000000000000000000000000000000000000000000000000000001020004000c000c01110102”
จากองค์ความรู้ตรงนี้ที่เราได้จาก Libra Core เราก็นำมา implement ใน PyLibra ในแบบที่เป็น simplified version
หลังจากที่เราได้ bytes ของ Program มา เราก็จะได้ก้อน object RawTransaction ที่พร้อมเอาไปให้เซ็น 🎉
การเซ็น ✍️ transaction
โอเค เราจะมาเซ็น RawTransaction กัน เราจะเริ่มจากการกลับไปดูฟังก์ชัน create_signed_txn ที่อยู่ในไฟล์
types/src/transaction_helpers.rs
ซึ่งเราพบว่า มีการเรียก signer.sign_txn(raw_txn) โดย signer นั้นคือ object TransactionSigner ซึ่งภายในก็มีฟังก์ชัน sign_txn
ส่วน raw_txn ในที่นี้ก็คือ object RawTransaction
object RawTransaction ถูกทำให้กลายเป็น bytes ด้วยการสั่ง
raw_txn.clone().into_proto().write_to_bytes()
จากนั้น bytes ของ RawTransaction ที่ได้มาจะถูกนำไป hash โดยใช้ฟังก์ชัน hash ของ RawTransactionBytes ในไฟล์
types/src/transaction.rs
เราเห็นว่าภายใน RawTransactionBytes มีฟังก์ชัน hash ที่มีตัวแปรชื่อ Hasher คอยทำหน้าที่ให้การ Hash อีกที
เริ่มจากการสร้างตัวแปรชื่อ state ซึ่งเป็นสิ่งที่ได้จากการเรียก Hasher::default()
จากนั้นสั่ง state.write(self.0) ซึ่ง self.0 ในที่นี้ก็คือ bytes ของ RawTransaction นั่นเอง
Hasher นั้นเป็น RawTransactionHasher object
จากการใช้ git grep เราก็พบว่า RawTransactionHasher มีต้นตออยู่ในไฟล์
crypto/legacy_crypto/src/hash.rs
พอเราเข้ามา เราก็จะเจอกับ DefaultHasher ซึ่งเป็น struct และถูก implement ฟังก์ชัน Default ให้ return DefaultHasher ที่ภายในมี state ที่เป็น ก้อน object sha3_256 (256 ในที่นี้คือจำนวน bits)
หมายเหตุ Keccak::new_sha3_256() นี้มาจาก library ชื่อ tiny_keccak ซึ่งเป็นหนึ่งใน library สำหรับการ hash ในภาษา rust
เราเจอ DefaultHasher แล้ว … ว่าแต่ RawTransactionHasher ของเราหละ
โอเคลองเลื่อนลงมาอีกนิด เราก็จะเจอ macro ตัวนึงที่ชื่อ define_hasher
define_hasher คือ macro ที่รับพารามิเตอร์ 3 ตัว ได้แก่
- hasher_type (ประเภท hasher)
- hasher_name (ชื่อ hasher)
- salt (bytes ที่จะใส่เพิ่มลงไปในการ hash โดย hasher แต่ละตัวจะมี salt ที่แตกต่างกันไป)
เพื่อไปสร้างเป็น hasher ตัวอื่นๆต่อไปตาม code ด้านล่างที่เขียนว่า define_hasher!
RawTransactionHasher คือ hasher ประเภทหนึ่งที่มี salt เป็น b”RawTransaction”
สำหรับ hasher ประเภทอื่นๆก็จะมี salt ที่แตกต่างกันไป🌟🌟🌟 หมายเหตุการเขียน b นำหน้าเป็นการบอกว่าเป็น bytes นะจ๊ะ ไม่ได้เป็น string ปกติ 🌟🌟🌟
โอเคที่นี้กลับไปที่ define_hasher macro เราพบว่าเมื่อเราสั่ง new (สร้าง object hasher ขึ้นมา) ฟังก์ชัน new จะไปเรียก DefaultHasher::new_with_salt($salt)
$salt ในที่นี้ (ของ RawTransactionHasher) ก็คือ b”RawTransaction”
แสดงว่าเราต้องไปดูก่อนว่า DefaultHasher::new_with_salt นั้นทำอะไร
ในฟังก์ชัน new_with_salt นั้นนำ salt ของเรา (b”RawTransaction”) ไปต่อท้ายด้วย LIBRA_HASH_SUFFIX
salt.extend_from_slice(LIBRA_HASH_SUFFIX);
เราจึงไปดูต่อว่า LIBRA_HASH_SUFFIX คืออะไรกันแน่
LIBRA_HASH_SUFFIX นั้นเป็น bytes ที่ hardcode ไว้เป็น b”@@$$LIBRA$$@@”
เพราะฉนั้นการบอกว่า salt.extend_from_slice(LIBRA_HASH_SUFFIX)
ก็คือการนำ bytes ของ LIBRA_HASH_SUFFIX ที่มีค่าเป็น b”@@$$LIBRA$$@@”
มาต่อด้านหลังของ salt ซึ่งในที่นี้คือ b”RawTransaction”
👉 ทำให้ตอนนี้ salt กลายเป็น b”RawTransaction@@$$LIBRA$$@@”
หลังจากได้ salt ที่เฉพาะเจาะจงกับ RawTransactionHasher มาแล้ว
ก็สั่ง state.update(HashValue::from_sha3(&salt[..]).as_ref());
HashValue::from_sha3(&salt[..]) คือการสั่งให้ hash ค่า salt (b”RawTransaction@@$$LIBRA$$@@”)
ซึ่ง hash (sha3_256) ของ b”RawTransaction@@$$LIBRA$$@@” จะได้b”F\xf1t\xdfl\xa8\xdeZ\xd2\x97E\xf9\x15\x84\xbb\x91>}\xf8\xdd\x16.>\x92\x1a\\\x1d\x867\xc8\x8d\x16”
หรือเขียนในรูปเลขฐาน 16 (hex) ได้ว่า
46f174df6ca8de5ad29745f91584bb913e7df8dd162e3e921a5c1d8637c88d16
ซึ่งฟังก์ชัน DefaultHasher::new_with_salt นั้นก็จะอัพเดท state (state ก็คล้ายๆ shazer ใน code python ตามภาพหนะแหละ) ด้วย hash ของ salt (ซึ่งก็คือ b”F\xf1t\xdfl\xa8\xdeZ\xd2\x97E\xf9\x15\x84\xbb\x91>}\xf8\xdd\x16.>\x92\x1a\\\x1d\x867\xc8\x8d\x16”) แล้วค่อย return object hasher ที่มี state เป็นดังที่กล่าวมา กลับออกไปให้กับฟังก์ชัน hash ที่อยู่ใน RawTransactionBytes ถ้าเรายังจำได้
เมื่อเราสั่ง state.write(self.0) ก็คือการอัพเดท state (อัพเดท state คือการเอา bytes ไปต่อกับ bytes เดิมที่เคยอยู่ใน state หนะแหละ) ด้วย bytes ของ RawTransaction นั่นเอง
นั่นหมายความว่า state ของเราตอนนี้จะกลายเป็น b”F\xf1t\xdfl\xa8\xdeZ\xd2\x97E\xf9\x15\x84\xbb\x91>}\xf8\xdd\x16.>\x92\x1a\\\x1d\x867\xc8\x8d\x16” ต่อกับ b”bytes ของ RawTransaction”
bytes ของ RawTransaction ก็คือ RawTransaction ที่ถูก serialized แล้วจึงกลายเป็น bytes
ถ้ายังจำได้ RawTransaction ประกอบไปด้วย sender_account, sequence_number, Program, max_gas_amount, gas_unit_price, expiration_time
จากนั้นพอเราสั่ง state.finish()
ก็คือทำการ hash b”F\xf1t\xdfl\xa8\xdeZ\xd2\x97E\xf9\x15\x84\xbb\x91>}\xf8\xdd\x16.>\x92\x1a\\\x1d\x867\xc8\x8d\x16” ต่อกับ b”bytes ของ RawTransaction”
นั้นอีกรอบนึงด้วย sha3
โอเคเราจบกับ RawTransactionBytes ตรงนี้แล้ว
ได้เวลากลับมาที่ TransactionSigner
ในบรรทัดที่ 57 เราได้ hash มาละ ซึ่ง sha3 ของ ((sha3 ของ b”RawTransaction@@$$LIBRA$$@@”) ต่อกับ bytes ของ RawTransaction)
ในบรรทัดที่ 58 เราก็ทำการ sign_message โดยเราจะ sign ค่า hash ที่เราพึ่งได้มาด้วย private key ของเรา
จากนั้นเราก็ทำการ git grep ไล่หาต้นตอของ sign_message ว่าเค้าใช้มาตราฐานอะไร เราก็พบว่า มันอยู่ในไฟล์
crypto/legacy_crypto/src/signing.rs
โอเคเราพบแล้วว่า Libra Core ใช้มาตราฐานของ Curve25519 ในการ sign
พอเราได้จิ๊กซอว์ครบเราก็กลับมา implement เลียนแบบ Libra Core ใน python
ในฟังก์ชันส่ง transaction ของเราใน PyLibra เราก็จะทำการ
- init ค่าตัวแปรต่างๆสำหรับ RawTransaction
ได้แก่ sender_account, sequence_number, Program (มากับตัวแปร transaction), max_gas_amount, gas_unit_price, expiration_time - จากนั้น serialize ตัว RawTransaction ให้กลายเป็น bytes
- สร้าง shazer ที่เป็น sha3 แบบ 256 bits
- อัพเดท shazer ด้วย 46f174df6ca8de5ad29745f91584bb913e7df8dd162e3e921a5c1d8637c88d16 ซึ่งก็คือ b”F\xf1t\xdfl\xa8\xdeZ\xd2\x97E\xf9\x15\x84\xbb\x91>}\xf8\xdd\x16.>\x92\x1a\\\x1d\x867\xc8\x8d\x16”
ซึ่งก็คือ sha3 ของ b”RawTransaction@@$$LIBRA$$@@” (หลายร่างจริงๆ …) - อัพเดท shazer ด้วย bytes ของ RawTransaction ที่เราพึ่ง serialize มา
- เราสั่ง shazer ให้ hash ทุกอย่างที่เราเคยอัพเดทให้มันไปด้วย shazer.digest()
- เอา hash ที่ได้มา sign (เซ็น) ด้วยมาตราฐาน Curve25519 โดยใช้ library PyNaCl
- เราเอาแค่ 64 bits แรกเพราะว่า PyNaCl จะ return signed message (64 bits แรก) ต่อกับ message ก่อนถูก sign (ในที่นี้คือ hash จาก shazer ของเรา)
- จับทุกอย่างยัดลงใน SubmitTransactionRequest
ซึ่งเราจะส่งไปบอก Libra Core ด้วย Protobuf message SubmitTransactionRequest
โอเคจบแล้วครับสำหรับการสำรวจ Libra Core เพื่อมานำมาสร้าง PyLibra
ผมหวังว่าข้อมูลเหล่านี้จะเป็นประโยชน์ให้กับนักพัฒนาหลายๆท่านที่สนใจการทำงานภายในของ Libra Core
สิ่งที่นำเสนอนี้เป็นเพียงส่วนหนึ่งใน Libra Core เท่านั้น ยังมีอีกหลายส่วนที่น่าสนใจภายใน Libra Core เช่น ส่วน consensus, ส่วนของการเก็บ state, ส่วนที่เป็น compiler ของภาษา move ฯลฯ
ไว้วันหลังจะพยายามมานำเสนอส่วนอื่นภายใน Libra Core อีกสวัสดีครับ 🙏🏻