Edited at

Ethereumのfallback関数

More than 1 year has passed since last update.

Ethereumのfallback関数についていくつか調べたりしたので書く


fallback関数(fallbackな動き)

Ethereumのコントラクトにはfallback関数というものがある。無名の関数で、コントラクトの関数呼び出しが行われたが関数シグネチャが見つからない場合に代わりに呼ばれるもの。

contract FallbackTest {

event FallbackFunctionIsCalled(address);
function () {
FallbackFunctionIsCalled(msg.sender);
}
}

contract Caller {
address ft;
function Caller(address _ftAddress) {
ft = _ftAddress;
}
function call() {
require(ft.call((bytes4(sha3("notExist()"))))); // FallbackTestの存在しない関数呼び出し
}
}


fallback関数(Ether受信時の動き)

また、コントラクトがEtherを受け取る時にも呼ばれる。この場合、fallback関数にpayableがついていないと失敗する。通常の関数呼び出し時もvalueを設定すればEtherを渡せるが、その関数もpayableがついている必要がある。payableな関数がないコントラクトはselfdestructを除いてEtherを受け取れないコントラクトということになる。(*注)

contract CanReceive {

function() payable {
}
}

contract CannotReceive {
function() { // payableがない
}
}

contract CannotReceiveToo {
// fallback関数がない
}

(*注) selfdestruct実行時、引数のアドレスに送金が行われるが引数に指定されたコントラクトのfallback関数は呼ばれない。「payableな関数がないからこのコントラクトは絶対に残高0」と思ってはいけない。


問題点その1

「コントラクトがEtherを受け取る時にもfallback関数が呼ばれる」という仕様が多くの悲劇を生み出しているようだ。

https://consensys.github.io/smart-contract-best-practices/known_attacks/#reentrancy

Ethereum Smart Contract Best Practices のReenttancyの例。

// INSECURE

mapping (address => uint) private userBalances;

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.call.value(amountToWithdraw)()); // At this point, the caller's code is executed, and can call withdrawBalance again
userBalances[msg.sender] = 0;
}

msg.sender.call.value(amountToWithdraw)()の部分は送金目的で行っているが、呼び出し元がコントラクトであればfallback関数が呼ばれる。fallback関数内で再度withdrawBalance()関数を呼べばまたmsg.sender.call.value(amountToWithdraw)()され…を繰り返すことになる。(もちろん、ガス欠になるのでどこかで止める必要があるが。)これはthe DAO Attackのバグの原因らしい。

解決策として、再入を防ぐためにmsg.sender.send(amountToWithdraw)(あるいはmsg.sender.transfer(amountToWithdraw)、これは送金失敗時にrevertする。require(msg.sender.send(amountToWithdraw))と同等)を使え、というのがある。

msg.sender.send(amountToWithdraw)ではfallback関数が呼ばれない、というわけではなくfallback関数で使えるgasが2300に制限されておりイベントの記録ぐらいしかできない、という話。

あるいは、再入されうることを考えてコーディングしろというのもある。

function withdrawBalance() public {

uint amountToWithdraw = userBalances[msg.sender];
userBalances[msg.sender] = 0;
require(msg.sender.call.value(amountToWithdraw)());
}

1度目のwithdrawBalanceでuserBalances[msg.sender]は0にクリアされるので2度目以降の送金額は0になるので安全。うーん、気を遣うことが多すぎる気がする…


問題点その2

同様に、Ethereum Smart Contract Best Practicesの例

https://consensys.github.io/smart-contract-best-practices/known_attacks/#dos-with-unexpected-revert

// INSECURE

contract Auction {
address currentLeader;
uint highestBid;

function bid() payable {
require(msg.value > highestBid);

require(currentLeader.send(highestBid)); // Refund the old leader, if it fails then revert

currentLeader = msg.sender;
highestBid = msg.value;
}
}

これはオークションの例で、highestBidよりも高いvalueを送金すると、currentLeaderにhighestBidを送金し、currentLeaderを呼び出し主に、highestBidを送金額にセットする。

currentLeaderがコントラクトなら、sendでfallback関数が呼ばれるが、fallbackにpayableがついていなかったり、2300gas以上のコードを実行すると、sendはfalseを返し、requireによりrevertされる。そうしてほかのアカウントがcurrentLeaderになることを妨害することができる。

解決策としてはpushよりもpullが望ましい、と書いてある。ロジック上、金を返す義務が発生したら直接その場で自動的に返すんじゃなくて記録しといて後で取りに来る関数を呼べという考え。

contract Auction {

address currentLeader;
uint highestBid;
mapping(address => uint) refunds;

function bid() payable {
require(msg.value > highestBid);

refunds[currentLeader] += highestBid;

currentLeader = msg.sender;
highestBid = msg.value;
}

function withdrawRefund() {
uint refund = refunds[msg.sender];
refunds[msg.sender] = 0;
msg.sender.transfer(refund);
}
}

「sendとかtransferがfallback関数呼び出すので危険なので最小単位で切り出すべき」という話に思える。


結論・教訓


  • fallback関数は直感に反してEther送金時にも呼ばれる

  • Ether送金処理(address.send(), address.transfer(), address.call.value()())はfallback関数が呼ばれることを常に意識する必要がある

  • addressはEOAだけではなくコントラクトの可能性がある