前章で賭け金を安全に預かれるようになりました。しかし、まだ致命的な穴があります。

問題: 黙って立ち去るだけで、両者の金が凍る

対戦中に相手が着手をやめたらどうなるか。ゲームは Active のまま、永久に決着しません。そして両者の賭け金はコントラクトの中に閉じ込められたままになります

悪意すら要りません。相手がウォレットを失くしただけ、飽きただけでも同じことが起きます。そして意図的にやるなら、負けそうになったら消えるのが常に最適戦略になります。負けを確定させるより、両者の金を人質にするほうがマシだからです。

これは「安全性 (safety)」ではなく「進行性 (liveness)」の問題です。不正なことは何も起きていないのに、正しいことも起きなくなる。

Web アプリなら、cron ジョブで期限切れのゲームを掃除すればいい。しかしコントラクトは自分から動けません。誰かがトランザクションを送らない限り、1 行も実行されないのです。

対処: 持ち時間と、誰でも押せる決着ボタン

2 つを組み合わせます。

1. 各手に締切を持たせる

struct Game {
    ...
    uint64 deadline;   // Open のときは参加受付の締切、Active のときは着手の締切
    uint32 timeout;    // 1 手あたりの持ち時間(秒)
}

着手のたびに次の締切を引き直します。

function play(uint256 gameId, uint8 cell) external {
    if (block.timestamp > g.deadline) revert DeadlinePassed(g.deadline);
    ...
    uint64 moveDeadline = uint64(block.timestamp) + g.timeout;
    g.deadline = moveDeadline;
}

2. 期限が切れたら、待っていた側の勝ちにする

function claimTimeout(uint256 gameId) external {
    Game storage g = _game(gameId);
    if (g.status != Status.Active) revert WrongStatus(gameId, g.status, Status.Active);
    if (block.timestamp <= g.deadline) revert DeadlineNotReached(g.deadline);

    address winner = g.moves % 2 == 0 ? g.playerO : g.playerX;
    _finish(gameId, g, Outcome.Forfeit, winner);
}

これで「消える」が有利な戦略ではなくなります。消えたら負けです。

externalonlyPlayer を付けていない理由

claimTimeout誰でも呼べます。勝つはずの本人でなくても構いません。意図的にそうしています。

理由は、結果がチェーン上の状態だけで完全に決まるからです。誰が呼んでも winner は同じアドレスになります。呼び出し元は結果に影響しません。

そして呼べる人を制限すると、新しい liveness の問題が生まれます。勝つはずの人までオフラインになったら、やはり資金が凍るのです。誰でも決済できるようにしておけば、第三者が(たとえばキーパーのボットが)片付けられます。

「本人しか呼べないようにする」のは一見安全に見えますが、制限は必ず何かを不可能にします。制限する前に、それが本当に必要かを問う価値があります。

参加者が来ないゲームの取り消し

もう 1 つ穴があります。ゲームを作ったが誰も参加しなかった場合、作成者の賭け金が預けっぱなしになります。

function cancelGame(uint256 gameId) external {
    Game storage g = _game(gameId);
    if (g.status != Status.Open) revert WrongStatus(gameId, g.status, Status.Open);
    if (msg.sender != g.playerX && block.timestamp <= g.deadline) revert NotCreator(msg.sender, g.playerX);

    uint256 refund = g.stake;
    g.status = Status.Cancelled;
    _credit(g.playerX, refund);
}

条件が 2 段になっています。

  • 作成者は、募集中ならいつでも取り消せる。 まだ誰の資金も risk に晒されていないからです
  • 募集期限を過ぎたら、誰でも取り消せる。 作成者が消えても賭け金が孤立しないようにするためです

返金先は常に g.playerX です。呼び出し元ではありません。誰でも呼べるが、誰が呼んでも金の行き先は変わらない。 claimTimeout と同じ原則です。

block.timestamp は信用できるのか

ここで気になるはずです。締切の判定に block.timestamp を使っていますが、これは誰が決めているのか。

ブロックを提案するバリデータです。 ただし、よく言われる「十数秒ずらせる」は PoW 時代の話です。

The Merge 以降、合意層は実行ペイロードのタイムスタンプがそのスロットの時刻と完全に一致していることを要求します。

payload.timestamp == genesis_time + slot × 12

1 秒でもずらすとブロックが無効になります。任意の秒数だけ前後させることはできません。

提案者に残された手は、自分のスロットを飛ばすことだけです。そうすると次のブロックのタイムスタンプが 12 秒先に進みます。操作の粒度は 12 秒、しかもブロック報酬を捨てる代償を伴います。

本書の既定の持ち時間は 1 時間なので、この粒度は効きません。ただし下限の 1 分では話が変わります。12 秒は持ち時間の 2 割にあたるので、締切ぎりぎりの着手は運任せになります。短い持ち時間は、賭け金のあるゲームでは避けてください。

そしてより大きな前提があります。この仕組みはチェーンが動いていることに依存しています。 自分の着手がブロックに入らなければ、原因が混雑でもシーケンサ停止でも検閲でも、結果は同じ「時間切れ負け」です。単一シーケンサの L2 が止まれば、進行中の全ゲームが一斉に不戦敗になり得ます。持ち時間は、チェーンの停止時間より十分に長く取ってください。

uint32 public constant MIN_TIMEOUT = 1 minutes;
uint32 public constant MAX_TIMEOUT = 7 days;

下限を設けているのはこのためです。「持ち時間 3 秒」を許すと、バリデータが結果を左右できてしまいます。block.timestamp の精度を、仕様の側で上回らないようにするわけです。

Foundry の lint は block.timestamp の比較に警告を出します。理由を明記したうえで抑制しておきます。

[lint]
# Deadlines here span minutes to days; the few seconds a proposer can shift
# block.timestamp by cannot change any outcome.
exclude_lints = ["block-timestamp"]

警告を消すときは理由を書く。 後から読む人(半年後の自分を含む)が判断をやり直せるようにするためです。

時間をテストする

1 時間待つわけにはいきません。Foundry の vm.warp で飛ばします。

function test_ClaimTimeout_AwardsPotToWaitingPlayer() public {
    uint256 gameId = _joined();
    uint64 deadline = game.getGame(gameId).deadline;

    vm.warp(deadline + 1);

    // X が着手せずに時間切れ。O の不戦勝。
    vm.prank(carol);           // 第三者が決済しても構わない
    game.claimTimeout(gameId);

    TicTacToe.Game memory g = game.getGame(gameId);
    assertEq(uint8(g.outcome), uint8(TicTacToe.Outcome.Forfeit));
    assertEq(g.winner, bob);
    assertEq(game.pending(bob), STAKE * 2);
}

vm.prank(carol) で、当事者でない第三者が決済できることも同時に検証しています。仕様として意図した挙動なら、テストにも書いておきます。

締切の手前で呼べないことも確認します。

function test_ClaimTimeout_RevertsBeforeDeadline() public {
    uint256 gameId = _joined();
    uint64 deadline = game.getGame(gameId).deadline;

    vm.expectRevert(abi.encodeWithSelector(TicTacToe.DeadlineNotReached.selector, deadline));
    game.claimTimeout(gameId);
}

資金が出ていく経路を数える

設計が正しいかを確かめる一番よい方法は、預けた金が出ていく経路をすべて列挙することです。本書のコントラクトはこうなります。

経路条件行き先
勝敗がついた三目が揃う勝者にポット(手数料を引いた分)
引き分け盤面が埋まる両者に賭け金を返す
時間切れ手番の側が締切を過ぎる待っていた側にポット
取り消し参加者が来ない作成者に返す

この 4 つで、Active か Open にあるすべてのゲームが必ずどれかへ到達できます。 ただし「到達できる」であって「到達する」ではありません。

誰かがトランザクションを送らないと何も起きません。 claimTimeout は勝つ側に呼ぶ動機がありますが、cancelGame は誰が呼んでも返金先が作成者なので、第三者にはガス代を払う理由がありません。実運用でキーパーを当てにするなら、呼び出した人に報酬を出す設計が要ります。

そして「ゲームが必ず決着する」ことと「資金が必ず引き出せる」ことは別です。前章で見たとおり、受け取れないアドレスに記帳された分は台帳に残り続けます。決着は詰まりませんが、その資金はコントラクトの中に残ったままです。

そしてどの経路も _credit で台帳に書くだけなので、出金に失敗して詰まることもありません

不変条件としてはこう書けます。「コントラクトの残高は、常に未決着のゲームの賭け金と、未引き出しの記帳額の合計に等しい」。fuzz テストで検証できます。

function testFuzz_PotConservation(uint96 stake, uint16 feeBps) public {
    // ... ゲームを 1 局完了させる ...
    uint256 pot = uint256(stake) * 2;
    assertEq(game.pending(alice) + game.pending(owner), pot);
    assertEq(address(game).balance, pot);
}

払い出しの合計がポットに一致し、コントラクトの残高もポットに一致する。 この経路を通った wei は、1 wei も生まれず、消えていません(外部から強制送金された分は別勘定です)。

まとめ

  • コントラクトは自分から動けない。cron に相当するものがない
  • 放置されたゲームは、放っておくと資金を永久に凍結させる
  • 持ち時間 + 誰でも押せる決着ボタンで、放置が有利にならないようにする
  • 呼び出し元を制限すると、新しい liveness の問題を作りかねない
  • block.timestamp は Merge 以降スロット時刻に完全一致する。動かせる粒度は 12 秒(スロットを飛ばす)。仕様をそれより十分に粗くしておく
  • チェーンが止まれば全ゲームが一斉に時間切れになり得る。持ち時間は停止時間より長く
  • 資金が出ていく経路を全部列挙して、抜けがないことを確認する

次章では、ここまで書いてきたコードのガス代を実測し、削ります。