ปัญหา ERC20 transfer() does not return boolean ใน Solidity
มาดู anti pattern ที่ทำให้ไม่สามารถโอนเหรียญได้กัน
จาก code เหรียญ ERC20
contract Token{
function transfer(address to, uint value) external;
//...
}
code นี้มีปัญหายังไง ?
.
.
.
.
.
.
.
.
.
.
.
.
.
ปัญหาคือ code นี้ไม่ตรงกับ ERC20 standard
ถ้าเรามาดู ERC20 standard เราจะเห็นว่า เค้ากำหนดให้มีการ return boolean ว่า success มั้ยด้วย
แต่โปรแกรมเมอร์บางส่วนเห็นว่า ยังไงถ้า transfer ไม่ success ก็มีการ throw error ออกไปอยู่แล้ว จึงไม่จำเป็นต้อง return ค่า success ออกไปอีก
โอเค จาก pattern การเขียน code ไม่ตรงกับ ERC20 standard นี้ ส่งผลกระทบยังไง ?
.
.
.
.
.
.
.
.
.
ปัญหาจะเกิดขึ้นตอนที่มี contract อื่น มา interact กับตัว contract เหรียญนี้
ซึ่ง contract Wallet ตาม code
interface Token {
function transfer() returns (bool);
}
contract GoodToken is Token {
function transfer() returns (bool) { return true; }
}
contract BadToken {
function transfer() {}
}
contract Wallet {
function transfer(address token) {
require(Token(token).transfer());
}
}
โดยถ้า contract Wallet ถูก compile ด้วย solc version 0.4.22 ขึ้นไป
transaction จะ revert ทุกครั้งที่ contract Wallet จะเรียก Token(token).transfer() ด้วย BadToken
เพราะเมื่อ contract Wallet พยายามเช็ค return size ของ BadToken จะพบว่าไม่มีการ return ค่าด้วยซ้ำ จึง revert ออกมานั่นเอง
ซึ่งถ้า contract Wallet ถูก compile ด้วย solc version ต่ำกว่า 0.4.22 จะไม่ revert เพราะเรื่อง return size เนื่องจากยังไม่ support opcode RETURNDATASIZE
และเมื่อพยายามหาค่า return ใน memory จะไปพบกับ function selector ของตัวเอง ทำให้เข้าใจว่า return true
โอเค จากปัญหา “ERC20 transfer revert เพราะ function ไม่มี return size” แก้ยังไง ?
.
.
.
.
.
.
.
.
.
.
.
.
คำตอบคือใช้ wrapper safeTransfer จาก library SafeERC20 ของ openzeppelin มาครอบการ transfer ใน contract Wallet แบบนี้
โอเค แล้วทำไม safeTransfer ของ Openzeppelin ถึงแก้ปัญหานี้ได้ ?
.
.
.
.
.
.
.
.
.
.
เพราะ safeTransfer ไม่ได้ทำการเรียก function transfer ตรงๆ
แต่ทำการ call function แบบ low level แทนนั่นเอง ตาม code
/**
* @dev Transfer `value` amount of `token` from the calling contract to `to`. If `token` returns no value,
* non-reverting calls are assumed to be successful.
*/
function safeTransfer(IERC20 token, address to, uint256 value) internal {
_callOptionalReturn(token, abi.encodeCall(token.transfer, (to, value)));
}
/**
* @dev Imitates a Solidity high-level call (i.e. a regular function call to a contract), relaxing the requirement
* on the return value: the return value is optional (but if data is returned, it must not be false).
* @param token The token targeted by the call.
* @param data The call data (encoded using abi.encode or one of its variants).
*/
function _callOptionalReturn(IERC20 token, bytes memory data) private {
// We need to perform a low level call here, to bypass Solidity's return data size checking mechanism, since
// we're implementing it ourselves. We use {Address-functionCall} to perform this call, which verifies that
// the target address contains contract code and also asserts for success in the low-level call.
bytes memory returndata = address(token).functionCall(data);
if (returndata.length != 0 && !abi.decode(returndata, (bool))) {
revert SafeERC20FailedOperation(address(token));
}
}
เพราะ low level call สามารถ bypass การ revert จาก RETURNDATASIZE ได้นั่นเอง
นี่ทำให้ contract Wallet สามารถ interact ได้ทั้งกับ
- ERC20 ที่มีการ revert เมื่อ transfer fail แต่ไม่ได้ implement การ return ค่า
- ERC20 ที่มีการ revert เมื่อ transfer fail และ return ค่าเป็น false
หรือก็คือ interact ได้ทั้งกับ ERC20 ที่ตรง standard และ ERC20 ที่ไม่ตรง standard นั่นเอง
note: ตัว safeTransfer ของ Solmate เขียนท่ายากกว่านี้แหะ เคยเขียน บทความ เอาไว้แล้วด้วย