ここからが本題です。賭け金を扱います。スマートコントラクトで一番事故が起きるのがこの領域なので、素朴な実装がなぜ壊れるかを順に見ていきます。
payable — 値を受け取れる関数
関数に payable を付けると、呼び出しと一緒に ETH を送れるようになります。
function createGame(address invitedOpponent, uint32 timeout) external payable returns (uint256 gameId) {
...
games.push(Game({ playerX: msg.sender, stake: uint96(msg.value), ... }));
}
msg.value が送られてきた額(wei 単位)です。この時点でその ETH はコントラクトの残高になっています。 別途「受け取る」処理は要りません。関数が正常終了すればコントラクトが持ち、revert すれば送金ごと巻き戻ります。
参加側は同額を要求します。
function joinGame(uint256 gameId) external payable {
if (msg.value != g.stake) revert WrongStake(msg.value, g.stake);
...
}
>= ではなく != にしているのは、多く払われた分の返金処理を書きたくないからです。厳密一致を要求するほうが、コードも仕様も短くなります。
payable を付け忘れると受け取れない
逆に、payable でない関数に ETH を送ると自動的に revert します。これは安全側の既定で、意図しない受け取りを防いでくれます。
コントラクト自体に直接送金された場合も同様です。receive() も fallback() も定義していなければ、素の送金は失敗します。本書のコントラクトはどちらも定義していません。賭け金は必ず createGame か joinGame を通ります。
ただし「入金経路を塞げば残高が予測できる」とは思わないでください。 selfdestruct による強制送金や、ブロックの手数料受取先に指定されることで、receive の有無に関係なく ETH は入ってきます。本書のコントラクトは残高を条件に使っていないので実害はありませんが、address(this).balance を会計の正本にする設計は必ず壊れます。 残高は自前の mapping で持ち、balance は参照しないのが原則です。
素朴な実装: 勝ったら送る
決着したら勝者に送る。最初に書くならこうなるはずです。
// これは壊れます
function _finish(Game storage g, address winner) private {
g.status = Status.Finished;
g.winner = winner;
uint256 pot = g.stake * 2;
(bool ok, ) = winner.call{value: pot}("");
require(ok, "transfer failed");
}
一見問題なさそうです。しかし 2 つの重大な欠陥があります。
欠陥 1: 受け取れない相手がゲームを壊す
winner はコントラクトかもしれません。 そしてそのコントラクトの receive() が revert するように書かれていたら、どうなるか。
winner.call{...} が失敗 → require(ok) で revert → _finish を呼んだ play() 全体が巻き戻る。
つまり 「勝ちを確定させる着手」が永久に成立しなくなります。相手は勝てず、盤面は Active のまま止まり、両者の賭け金が永久に取り出せなくなります。
自分が勝てないと分かった側が、わざと受け取り拒否するコントラクトで参加すれば、相手の資金を人質に取れる。これは実際に成立する攻撃です。
欠陥 2: リエントランシー
call は相手に任意のコードを実行させます。受け取り側の receive() の中から、こちらのコントラクトの関数を呼び返せます。
状態の更新より先に送金していたら、決着前の状態のまま _finish をもう一度踏ませる、といったことが可能になります。上のコードは状態更新を先に書いているので直接は破れませんが、「外部呼び出しの後に何か書く」を一箇所でもやると崩れます。
これが 2016 年の The DAO 事件で 5,000 万ドルが流出した手口です。
対処: 送らずに記帳する(pull payment)
本書のコントラクトは、決着時に送金しません。台帳に書くだけです。
mapping(address => uint256) public pending;
function _finish(uint256 gameId, Game storage g, Outcome outcome, address winner) private {
uint256 stake = g.stake;
g.status = Status.Finished;
g.outcome = outcome;
g.winner = winner;
if (outcome == Outcome.Draw) {
_credit(g.playerX, stake);
_credit(g.playerO, stake);
} else {
uint256 pot = stake * 2;
uint256 fee = (pot * feeBps) / 10_000;
_credit(winner, pot - fee);
if (fee != 0) _credit(owner, fee);
}
}
function _credit(address account, uint256 amount) private {
if (amount == 0) return;
pending[account] += amount;
emit Credited(account, amount);
}
_credit は mapping に足すだけです。外部呼び出しが一切ありません。 だから失敗しようがなく、リエントランシーの入口もありません。
受け取りは本人が別のトランザクションで行います。
function withdraw() external returns (uint256 amount) {
amount = pending[msg.sender];
if (amount == 0) revert NothingToWithdraw();
pending[msg.sender] = 0; // ← 先にゼロにする
emit Withdrawn(msg.sender, amount);
(bool ok, ) = msg.sender.call{value: amount}("");
if (!ok) revert TransferFailed(msg.sender, amount);
}
checks-effects-interactions という順序です。条件を確認し(checks)、状態を更新し(effects)、最後に外部を呼ぶ(interactions)。
pending[msg.sender] = 0 を送金より前に置いているのが肝です。仮に相手が receive() の中から withdraw() を呼び返しても、そのときには残高が 0 になっているので NothingToWithdraw で弾かれます。リエントランシーガードのライブラリを使わなくても、この順序だけで防げます。
そして受け取りに失敗するのは本人だけです。他のプレイヤーには一切影響しません。
テストで証明する
「壊れないこと」は主張ではなくテストにします。受け取りを拒否するコントラクトを用意します。
contract RevertingReceiver {
TicTacToe private immutable GAME;
// ... join / play / withdraw を転送するだけ ...
receive() external payable {
revert("nope");
}
}
これを勝たせて、他人が巻き添えを食わないことを確認します。
function test_Withdraw_FailingReceiverDoesNotStrandOthers() public {
RevertingReceiver hostile = new RevertingReceiver(game);
// ... hostile を勝たせる ...
assertEq(game.getGame(gameId).winner, address(hostile));
// 本人の引き出しは失敗する。しかし決着はもう済んでいる。
vm.expectRevert(abi.encodeWithSelector(TicTacToe.TransferFailed.selector, address(hostile), STAKE * 2));
hostile.withdraw();
// 別のゲームは何事もなく完了し、引き出せる。
uint256 secondGame = /* ... */;
vm.prank(alice);
game.withdraw();
assertEq(game.pending(address(hostile)), STAKE * 2); // 記帳は残ったまま
}
最後の行が重要です。敵対的なコントラクトの取り分は台帳に残り続けます。 消えたわけでも没収されたわけでもなく、単に本人が取りに来られないだけ。他の誰にも影響しません。
引き分けと手数料
引き分けは、手数料を取らずに両者へ返します。
if (outcome == Outcome.Draw) {
_credit(g.playerX, stake);
_credit(g.playerO, stake);
}
手数料は決着したゲームからだけ取ります。取り消しからも取りません。 「勝負がついたときだけ」という規則にしておくと、返金経路に手数料計算が混ざらず、コードも仕様も単純になります。
手数料率には上限をハードコードしておきます。
uint16 public constant MAX_FEE_BPS = 500; // 5%
function setFeeBps(uint16 newFeeBps) external onlyOwner {
if (newFeeBps > MAX_FEE_BPS) revert FeeTooHigh(newFeeBps);
feeBps = newFeeBps;
}
オーナーが後から 100% に設定できてしまう設計にはしません。上限は constant なので、オーナー自身にも変更できません。
ただし 上限を固定しても、オーナーが信頼不要になったわけではありません。 本書の実装は決着時点の feeBps を読むので、両者が賭けた後にオーナーが手数料を上げると、その値が適用されます。上限 5% の範囲内であれば、進行中のゲームからも取れてしまう。
本気で信頼を要らなくするなら、ゲーム作成時の feeBps を Game に焼き付けて、決着時はそれを読みます。「オーナーにできること」を数える作業は、権限の一覧を眺めるだけでは足りません。その値がいつ読まれるかまで見てください。
単位に注意する
Solidity の uint256 は wei 単位で、1 ETH = 10^18 wei です。小数はありません。
stake を uint96 にしているのは、構造体を 32 バイト境界に詰めるためです(次章以降で扱います)。uint96 の上限は約 7.9 × 10^28 wei ≈ 790 億 ETH なので、実用上は困りません。それでも念のため確認しています。
if (msg.value > type(uint96).max) revert StakeTooLarge(msg.value);
Solidity 0.8 以降、オーバーフローは既定で revert しますが、明示的なダウンキャスト(uint96(msg.value))はチェックされません。切り捨てが静かに起きます。だからキャストの前に自分で確認します。
まとめ
payableを付けた関数だけが ETH を受け取れる。msg.valueが額- 決着時に送金してはいけない。 受け取れない相手がゲーム全体を人質に取れる
- 記帳して、本人に取りに来させる(pull payment)
- 送金する場合は checks-effects-interactions。状態をゼロにしてから送る
- オーナーの権限にはコードで上限を設ける
- 明示的なダウンキャストは自分でチェックする
これで資金は安全に預かれるようになりました。しかし、まだ相手が消えたら資金が永久に凍る問題が残っています。次章で潰します。