fallback() in Solidity
ว่าด้วยเรื่อง fallback()
Tl;Dr: fallback() เป็นฟังก์ชันพิเศษใน Solidity ที่จะถูกเรียกใช้งานอัตโนมัติ ในกรณีที่ 1) เรียกใช้ฟังก์ชันที่ไม่มีอยู่ใน contract นั้น 2) กรณีส่ง Ether โดยส่ง data เข้ามาด้วย หรือ 3) ส่ง Ether และ empty data เข้ามาใน smart contract แล้ว ใน contract นั้น ไม่มี ฟังก์ชัน receive()
Introduction
fallback() เป็น ฟังก์ชันพิเศษอย่างหนึ่ง ที่ไม่มี arguments ไม่มีการส่งค่ากลับออกมา และ ต้องมี visibility เป็นแบบ external (สามารถเรียกใช้ได้จาก contract อื่น) และจะถูกเรียกโดยอัตโนมัติจากภายนอก contract เท่านั้น ไม่สามารถเรียกโดยตรงได้
Fallback() function call
การทำงานของ fallback() คือ หากมีการเรียกใช้ฟังก์ชันที่ไม่ได้กำหนดไว้ใน contract ฟังก์ชัน fallback() จะถูกเรียกโดยอัตโนมัติ ดังตัวอย่าง
จะเห็นได้ว่าใน Caller contract
- เรียก call2foo() โดยเรียกผ่าน call(abi.encodeWithSignature) ซึ่ง ฟังก์ชันนี้ จะรับค่าเป็น เป็น string function และ ส่งค่าออกเป็น bool กับ bytes memory (ในตัวอย่างกำหนดรับ bool success อย่างเดียว) ซึ่งสุดท้าย มีผลคือ ไปเรียกใช้ ฟังก์ชัน foo() ใน Callee contract
- เรียก call2fallback() โดย ส่งชื่อฟังก์ชัน nonExistingFunction() ซึ่งฟังก์ชันนี้ ไม่มีกำหนดใน Callee contract ผล คือ fallback() จะถูกเรียกใช้งาน
สำหรับ Solidity ที่ version ต่ำกว่า 0.6.0 การเขียน fallback() จะใช้ ฟังก์ชันที่ไม่มีชื่อแทน แบบนี้
function () {}
แต่ในปัจจุบันใช้fallback()
โดยที่ไม่มีคำว่า function
Sending Ether: receive() vs fallback()
นอกจาก Wallet ของผู้ใช้ที่สามารถเก็บ Ether ได้แล้ว ตัว smart contract ก็สามารถเก็บ Ether ไว้ได้ เช่นกัน
การโอน Ether เข้ามาใน contract สามารถทำได้ 3 วิธี คือ 1. transfer() 2. send()
และ 3. call{}()
หลังจากเรียกใช้ฟังก์ชันที่มีการโอน Ether ไปยัง contract address นั้นแล้ว ถ้า contract นั้น มีฟังก์ชัน receive() ฟังก์ชัน receive() ก็จะถูกเรียกโดยอัตโนมัติ แต่หากไม่มี ก็จะเรียก fallback() แทน
ตัวอย่างเช่น ทดลอง Deploy ReceiveEther contract ก่อน แล้ว Deploy SendEther contract จากนั้น ทดลองเรียก sendViaTransfer(), sendViaSend() และ sendViaCall() จะเห็นได้ว่า receive() จะถูกเรียกทุกครั้ง จากทุกฟังก์ชันโดยอัตโนมัติ
แต่ถ้าลบ receive() ใน ReceiveEther contract ทิ้งไป fallback() ก็จะถูกเรียกโดยอัตโนมัติ เมื่อมีการโอน Ether เข้ามายัง ReceiveEther contract ซึ่งนี้ เป็นประเด็นในการโจมตีแบบ ReEntrancy
สิ่งที่เพิ่มเข้ามาในตัวอย่างนี้ คือ payable การใช้ fallback() ในกรณีนี้ จำเป็นต้อง mark payable เนื่องจากมีการโอน Ether เข้ามา ไม่เช่นนั้น trasaction จะถูก revert กลับไป ไม่สามารถโอน Ether เข้ามาได้
Ether transfer with msg.data
การส่ง Etherโดยใช้ call() นั้น สามารถกำหนดได้ว่าจะแนบข้อมูล ผ่าน msg.data ไปด้วยหรือไม่ ซึ่งถ้าข้อมูลมีการแนบไปด้วย fallback() จะถูกเรียกทำงานทันที ถึงแม้ว่าใน contract นั้น จะมีฟังก์ชัน receive() อยู่หรือไม่ก็ตาม แต่หากไม่มีส่งข้อมูล การเรียกใช้ fallback() ขึ้นอยู่กับว่ามีฟังก์ชัน receive() อยู่หรือไม่ ดังตัวอย่าง code ในฟังก์ชัน sendViaCall()
กับ sendViaCallWithData()
นี้
ข้อมูล withData จะถูกส่งผ่าน msg.data ที่อ่านจาก contract ReceiveEther ในรูปแบบ bytes ดังตัวอย่างคือ ( 0x7769746844617461) การอ่านข้อมูล จะต้องแปลง bytes กลับมาเป็น string ก่อน
ความแตกต่างระหว่าง transfer(), send() และ call{}()
ทั้ง 3 ฟังก์ชันนี้ มีหน้าที่เดียวกัน คือ ในการโอน Ether ไปยัง address ของผู้ที่เรียกใช้คำสั่งนี้ เช่น _to.send(msg.value) เป็นต้น
โดย call{}() เกิดมาก่อน แต่มีปัญหาเรื่อง Re-entrancy เพราะไม่ได้ limit ค่า gas ไว้ จึงได้มีการพัฒนา transfer() กับ send() ขึ้นมา พร้อมทั้งมีการจำกัดค่า gas ไม่เกิน 2300 ซึ่งสรุปได้ดังนี้
transfer
(2300 gas, throws error)send
(2300 gas, returns bool)call
(forward all gas or set gas, returns bool)
งั้นแสดงว่า เราไม่ควรใช้ call แต่ควรใช้ transfer หรือ send ในการส่ง Ether ใช่ไหม?
ไม่ใช่ครับ! เราไม่ควรใช้ transfer และ send ด้วยเหตุผลเรื่อง ค่า Gas นี้แหละ แม้ว่าฟังก์ชัน จะ custom ค่า gas สำหรับคำสั่ง transfer หรือ send ได้ แต่ก็ไม่แนะนำ เพราะราคาค่า gas ในปัจจุบันมีการเปลี่ยนแปลง และ smart contract ที่ใช้งาน ไม่ควรถูกยึดโยงกับ gas ที่กำหนดเป็นค่าคงที่ตายตัว ทำให้คำสั่งที่กำหนดอาจจะทำไม่สำเร็จ วิธีที่ดีกว่า คือ ส่งต่อค่า gas ทั้งหมดที่มีก่อนจะสั่งให้ฟังก์ชันทำงาน พร้อมกับตรวจสอบผลลัพธ์การทำงานว่าสำเร็จหรือไม่ ผ่านการเรียกใช้ call พร้อมกับ ReEntrancy Guard
อีกตัวอย่างของการใช้ fallback() คือ กำหนดใน Proxy upgrade pattern ให้รองรับการเพิ่มฟังก์ชันใหม่ ๆ ที่อาจจะเกิดขึ้นในอนาคต โดยกำหนดการทำงานใน fallback() แล้วส่งต่อไปยังฟังก์ชันที่ต้องการ
กล่าวคือเราควรใช้ call(){} และ มีการป้องกัน ReEntrancy ซึ่งจะได้กล่าวต่อไป
สรุปการทำงาน
ก่อนจบ ขอสรุปการทำงานของ fallback() ด้วย flowchart นี้นะครับ
ตอนต่อไป จะมาดูเรื่อง ReEntrance Attack ซึ่งต้องมีพื้นฐานเรื่องนี้ก่อน แล้วจะเข้าใจ ReEntrace สบาย ๆ เลยครับ
อ้างอิง:
- https://solidity-by-example.org/sending-ether/
- https://consensys.net/diligence/blog/2019/09/stop-using-soliditys-transfer-now
- https://docs.openzeppelin.com/upgrades-plugins/1.x/proxies
- https://docs.soliditylang.org/en/v0.8.4/contracts.html#fallback-function
- https://gist.github.com/wwarodom/dc1c4932f965cc4755cdcbc9fb196da3