# Audit report

> audit all functions of 0x7eb429ca085e861f9b010c8e42574abbcacd9d57. and any hooks linked to it like 0xe7b8f27047ebc33485f1a6cc3017f8658a2120cc. and the merkledistributor contract  0xb3128c8e75440e283fd100274af514f404e3100c

| | |
|---|---|
| Repository | https://github.com/identity-md-launches/launch-1170-meatbag-symbol-meat.git |
| Commit | `85b2f03a7ca2351e34e5aeca1305a4c7ab5d7c3a` |
| Job | `e537f020-006e-46d8-bff7-6f96b1e684a2` |
| Judged | 2026-10-09 19:59 UTC |
| Findings | 6 low · 4 info |

Four agents audited the code as it is at `85b2f03`, each in one area (math, permissions, economics, control flow),
and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the repository was changed or deployed.

## Findings

### 1. Low: onOracleResult never binds the attestation to the request it settles: a valid signed answer to another question (an undelivered earlier round's verdict) settles the pending round

`src/MeatbagGame.sol:332`

```
        _verifyAttestation(a, signature);
        _consume(a.requestId);
```

The callback keys the round by the Intake's requestId (pendingDay), then checks only the signer, the validity window and that a.requestId (the oracle's own UUID) was not consumed before. It never compares a.questionHash with keccak256 of the body judge() sent for that day, never ties a.requestId to the intake request id, and never checks a.chainId. OracleAttestation.sol line 29-30 documents questionHash for exactly this ('a consumer that pins its question compares this, so an answer to a different question cannot be presented as its own'). So every unexpired, unconsumed attestation the oracle signer ever issued in this game's EIP-712 domain is accepted as the verdict of whichever round is Pending, as long as its index is below that round's count. The only caller is INTAKE (line 326), and the mainnet Intake's complete() forwards writer-supplied args without checking they encode the request being completed, so this is a trusted-delivery-path gap (defence in depth), not an unprivileged bypass: an attacker's own intake request with the game as callback target fails UnknownRequest. It still matters because INTAKE is immutable and cannot be replaced, a mis-routed delivery (writer bug or off-chain compromise short of the signing key) pays 80% of the pot to a different entrant, and the chain records nothing that shows the question did not match. Merged from four specialist reports (audit_flow, audit_permissions, audit_economics, audit_math) of the same mechanism. Fix without changing the design: record keccak256(judgeBody(day)) in the Round at judge() time and require a.questionHash to equal it in onOracleResult (after confirming the oracle's canonical question hash is keccak256 of the submitted body; the repo's tests assume so), and require a.chainId == 1; treat a mismatch as a refused delivery (revert) rather than a verdict. If the protocol derives the attestation requestId from the intake id, bind that as well.

**Reproduction**

State: pot 10 ETH. Day 1: alice enters slot 0, bob slot 1. Day 2: keeper calls judge() -> intake request R1 with body1; the oracle signs attestation A1 {questionHash: keccak256(body1), answer: abi.encode(1), panelSize 7, quorum 4, agreed 5, expiresAt now + 3 days} but it is never delivered. Day 2: carol slot 0, dave slot 1. Day 3 + VERDICT_TIMEOUT: keeper calls judge() again: round 1 is swept Hung, round 2 becomes Pending under request R2 (keccak256(body1) != keccak256(judgeBody(day2))). Input: the intake calls onOracleResult(R2, A1, sig1). Expected: refused, round 2 stays Pending, nobody is paid. Actual: pendingDay[R2] = day2, signature valid, uuid1 unconsumed, index 1 < count 2, so round 2 is Settled with winner dave and claimable(dave) = 80% of the pot. Reproduced by running the attached proof (test/scratch/Proof_4e33882facca.t.sol::test_answerForRoundOneDoesNotSettleRoundTwo): it fails on the current code with 'an attestation for another question settled this round'.

**Proof**: a Foundry test that fails on this code and passes once it is fixed.

```solidity
// SPDX-License-Identifier: MIT
pragma solidity 0.8.26;

import {Test} from "forge-std/Test.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {MeatbagGame, IIntake} from "src/MeatbagGame.sol";
import {MeatbagHerald} from "src/MeatbagHerald.sol";
import {OracleAttestation} from "src/OracleAttestation.sol";

contract ProofImd is ERC20 {
    constructor() ERC20("IMD", "IMD") {
        _mint(msg.sender, 1_000 ether);
    }
}

/// @dev A minimal intake: sells the action at 0.5 IMD, hands out sequential request ids and can deliver
/// any (requestId, attestation, signature) triple to the game from its own address.
contract ProofIntake is IIntake {
    struct Stored {
        address target;
        bytes4 selector;
    }

    uint256 public count;
    mapping(bytes32 => Stored) public requests;
    bytes public lastBody;

    function priceOf(bytes32, address) external pure returns (uint256) {
        return 0.5 ether;
    }

    function request(bytes32, bytes calldata body, Callback calldata callback, address asset, uint256 amount)
        external
        payable
        returns (bytes32 requestId)
    {
        IERC20(asset).transferFrom(msg.sender, address(this), amount);
        requestId = keccak256(abi.encode("intake", ++count));
        requests[requestId] = Stored(callback.target, callback.selector);
        lastBody = body;
    }

    function deliver(bytes32 requestId, OracleAttestation.Attestation memory a, bytes memory signature)
        external
        returns (bool ok)
    {
        Stored memory s = requests[requestId];
        (ok,) = s.target.call{gas: 200_000}(abi.encodeWithSelector(s.selector, requestId, a, signature));
    }
}

/// @title A signed answer to one round must not settle another round
/// @notice Fails on the current code: `onOracleResult` verifies the signer, the window and replay of
/// `a.requestId`, but never compares the attestation to the request it is settling (neither
/// `a.questionHash` against the body `judge()` sent for that day nor the attestation's request id against
/// the intake request). Round 1's valid, unconsumed attestation presented under round 2's intake request
/// id settles round 2 and pays 80% of the pot to round 2's entry 1. Passes once the callback pins the
/// question (or binds the attestation to the request) and treats a mismatch as a refused delivery.
contract StaleAttestationProofTest is Test {
    uint256 constant SIGNER_KEY = 0xA11CE;
    address signer = vm.addr(SIGNER_KEY);
    address keeper = address(0xC0FFEE);
    address alice = address(0xA1);
    address bob = address(0xB2);
    address carol = address(0xC3);
    address dave = address(0xD4);

    ProofIntake intake;
    ProofImd imd;
    MeatbagHerald herald;
    MeatbagGame game;

    function setUp() public {
        vm.warp(1_800_000_000);
        intake = new ProofIntake();
        imd = new ProofImd();
        address predictedGame = vm.computeCreateAddress(address(this), vm.getNonce(address(this)) + 1);
        herald = new MeatbagHerald(predictedGame);
        game = new MeatbagGame(herald, address(intake), address(imd), signer);
        require(address(game) == predictedGame, "game address");
        imd.transfer(keeper, 100 ether);
        vm.prank(keeper);
        imd.approve(address(game), type(uint256).max);
        (bool ok,) = address(game).call{value: 10 ether}("");
        require(ok, "pot");
    }

    function enterAs(address who, string memory text) internal {
        vm.deal(who, 1 ether);
        uint256 price = game.nextSlotPrice();
        vm.prank(who);
        game.enter{value: price}(text);
    }

    function nextDay() internal {
        vm.warp((game.today() + 1) * 1 days + 1 hours);
    }

    function attestation(bytes32 intakeId, bytes memory body, uint256 answerIndex)
        internal
        view
        returns (OracleAttestation.Attestation memory a)
    {
        a = OracleAttestation.Attestation({
            requestId: keccak256(abi.encode("oracle", intakeId)),
            chainId: 1,
            questionHash: keccak256(body),
            answerType: OracleAttestation.ANSWER_UINT256,
            answer: abi.encode(answerIndex),
            figure: 0,
            fromBlock: 100,
            toBlock: 200,
            blockHash: bytes32(uint256(7)),
            panelJobId: keccak256("panel"),
            panelSize: 7,
            quorum: 4,
            agreed: 5,
            issuedAt: uint64(block.timestamp),
            expiresAt: uint64(block.timestamp + 3 days)
        });
    }

    function sign(OracleAttestation.Attestation memory a) internal view returns (bytes memory) {
        (uint8 v, bytes32 r, bytes32 s) = vm.sign(SIGNER_KEY, game.attestationDigest(a));
        return abi.encodePacked(r, s, v);
    }

    function test_answerForRoundOneDoesNotSettleRoundTwo() public {
        // Round 1: alice (entry 0), bob (entry 1). Judged; the oracle signs "entry 1 wins round 1".
        uint256 day1 = game.today();
        enterAs(alice, "round one, entry zero");
        enterAs(bob, "round one, entry one");
        nextDay();
        vm.prank(keeper);
        bytes32 r1 = game.judge();
        bytes memory body1 = intake.lastBody();
        OracleAttestation.Attestation memory a1 = attestation(r1, body1, 1);
        bytes memory sig1 = sign(a1);

        // Round 2: carol (entry 0), dave (entry 1). Round 1 times out and is hung on the way; round 2 is judged.
        enterAs(carol, "round two, entry zero");
        enterAs(dave, "round two, entry one");
        uint256 day2 = game.today();
        nextDay();
        vm.warp(block.timestamp + game.VERDICT_TIMEOUT());
        vm.prank(keeper);
        bytes32 r2 = game.judge();
        assertEq(uint8(game.round(day1).status), uint8(MeatbagGame.Status.Hung));
        assertEq(game.pendingDay(r2), day2);
        assertTrue(keccak256(body1) != keccak256(game.judgeBody(day2)), "the two questions differ");

        // Round 1's attestation is still inside its window and unconsumed; it is presented for round 2.
        uint256 potBefore = game.pot();
        bool ok = intake.deliver(r2, a1, sig1);

        // Expected: refused, round 2 stays pending, nobody is paid with round 1's answer.
        assertFalse(ok, "an attestation for another question settled this round");
        assertEq(uint8(game.round(day2).status), uint8(MeatbagGame.Status.Pending), "round 2 must stay pending");
        assertEq(game.claimable(dave), 0, "dave was paid with round 1's answer");
        assertEq(game.pot(), potBefore, "the pot moved on a foreign answer");
    }
}
```

### 2. Low: judge() pulls whatever IMD price the Intake quotes at execution time; the keeper cannot bound the cost

`src/MeatbagGame.sol:275`

```
        IERC20(IMD).safeTransferFrom(msg.sender, address(this), price);
```

judge() reads judgePrice() (the Intake's live priceOf) in the same transaction and pulls exactly that amount from msg.sender with no caller-supplied maximum. The keeper's only protection is its ERC-20 allowance, and the project's own tests and fork test approve type(uint256).max. The live Intake (0x1397434cd35e8a9C8aC312A61D3A285EB31dea56) has an owner-only setPrice, so the price the keeper saw when it approved and the price pulled when its transaction lands can differ by any factor; the Intake owner's price power is an external trust assumption, the missing bound is this code's. The game NatSpec (lines 40-41) documents that the price is read live but not that the caller has no cap. Fix that preserves the design: add a judge(uint256 maxPrice) overload (or parameter) that reverts when judgePrice() > maxPrice, and have the site pass the quoted price; keepers should also approve only the quoted amount.

**Reproduction**

State: one entry on day D; day D+1; keeper holds 100 IMD and has approved the game for type(uint256).max; game.judgePrice() returns 0.5e18. Input: the intake's price changes to 50e18 (MockIntake.setPrice(50 ether); on mainnet the Intake owner's setPrice), then keeper calls judge(). Expected: the keeper pays the 0.5 IMD it saw, or the call reverts. Actual: safeTransferFrom pulls 50e18 IMD and the request is placed at that price; imd.balanceOf(keeper) goes from 100e18 to 50e18. Reproduced in test/scratch/GameRepro.t.sol::test_judgePullsLivePriceWithNoCap (passes on the current code, demonstrating the behaviour).

### 3. Low: judge() makes its external calls (IMD transferFrom, Intake.request) before marking the round Pending, so a calling-back dependency judges one round twice

`src/MeatbagGame.sol:277`

```
        intakeRequestId = IIntake(INTAKE)
            .request(ACTION, judgeBody(day), IIntake.Callback(address(this), this.onOracleResult.selector), IMD, price);
```

Checks-effects-interactions is inverted in judge(): the keeper reward is credited, then IMD is pulled and the Intake is called, and only afterwards (lines 280-284) are r.status, r.requestedAt, r.keeper, r.intakeRequestId and pendingDay written. Between the external calls and those writes the round is still Status.Open at the same cursor, so a nested judge() from inside the dependency passes every guard, pays a second 3% keeper reward from the pot, places a second oracle request for the same day, and leaves two request ids mapped to one day (the outer write then overwrites r.intakeRequestId, so _hung() later clears only one of them and the other stays accepted by onOracleResult). Not reachable on mainnet today: the IMD token (0xD34a99Bc0f67aE1bbd63C660e6d0b0dd03E263B7) and the Intake (0x1397434cd35e8a9C8aC312A61D3A285EB31dea56) are not EIP-1967 proxies (implementation slot is zero on both, checked by RPC) and neither is known to call back from transferFrom or request(). The game hard-codes both as immutables, so the exposure is to those two contracts' future behaviour, and there is no reentrancy guard anywhere in MeatbagGame. Fix: write r.status = Status.Pending, r.requestedAt and r.keeper before the IMD pull and the Intake call, then set r.intakeRequestId and pendingDay from the returned id (or add a reentrancy guard to judge()).

**Reproduction**

State: a game wired to an intake whose request() calls game.judge() once before returning (ReenteringIntake in test/scratch/GameRepro.t.sol); pot 10 ETH plus one 0.001 ETH entry on day D; day D+1. Input: keeper calls judge(). Expected: one request, one keeper reward of 3% of the pot, one pending id for day D. Actual: the nested judge() sees the round still Open at the same cursor, credits a second reward of 3% of the reduced pot to the intake, the intake's request counter is 2, pendingDay maps both ids to day D, pot == potBefore - reward1 - reward2, and round(D).intakeRequestId holds only the outer id. Reproduced in test/scratch/GameRepro.t.sol::test_reenteringIntakeDoubleJudgesOneRound.

### 4. Low: Anyone can hang a judged round at requestedAt + 25 h although the oracle's attestation may still be inside its own 24 h validity window, discarding a valid verdict

`src/MeatbagGame.sol:296`

```
        if (block.timestamp < hungJuryAt()) revert NotTimedOut(day);
```

Two clocks disagree. The game's hung-jury clock runs from the request: declareHungJury() (line 296) and the judge() sweep (line 261) hang a Pending round once block.timestamp >= requestedAt + VERDICT_TIMEOUT (86 400 + 3 600 s). The attestation's clock runs from issue: _verifyAttestation only requires block.timestamp <= a.expiresAt, and the body judge() sends asks for validForSeconds 86400, so an attestation issued at requestedAt + L is valid until requestedAt + L + 86 400. For any panel-plus-delivery latency L > 1 h there is a window [requestedAt + 25 h, requestedAt + L + 24 h] in which the signed verdict is valid by the oracle's own terms but any unprivileged caller can call declareHungJury() first. _hung() deletes pendingDay[intakeRequestId], so the later delivery is refused with UnknownRequest, the round is Hung, the winner's 80% is never credited and the pot carries over to the next round, which anyone can fill (README line 352-356). The caller needs no stake. VERDICT_TIMEOUT = VALID_FOR_SECONDS + 1 hours suggests the timeout was sized assuming attestations are issued at request time, which they are not. Fix options that keep the design: size the Pending timeout to cover the attestation window measured from issue (for example 2 x VALID_FOR_SECONDS), or let onOracleResult still settle the round at cursor-1 when it is Hung by timeout and no later round has been judged; at minimum document that a verdict delivered after 25 h is discarded regardless of its expiresAt.

**Reproduction**

State: one closed round (alice, sole entry) with a 10 ETH pot; keeper calls judge() at T = requestedAt. The panel settles at T + 2 h and signs an attestation with issuedAt = T + 2 h, expiresAt = T + 26 h, answer 0, panelSize 7, quorum 4, agreed 5 (valid signer and domain). At T + 25 h (= requestedAt + VERDICT_TIMEOUT) an unrelated address calls declareHungJury(). Expected: the round still has a valid, undelivered verdict, so alice receives the prize when the writer delivers. Actual: declareHungJury() succeeds and round.status == Hung; block.timestamp <= a.expiresAt still holds; the intake's delivery of that attestation returns false (callback reverts UnknownRequest), claimable(alice) == 0 and the pot carries over. Reproduced in test/scratch/GameRepro.t.sol::test_strangerPreemptsStillValidLateVerdict (passes on the current code, demonstrating the behaviour).

### 5. Low: Deploy.s.sol run() mines the CREATE2 salt for msg.sender, but a broadcast CREATE2 goes through forge's deterministic deployer, so the rehearsal script always reverts

`script/Deploy.s.sol:22`

```
        (token, hook) = deploy(IPoolManager(poolManager), factory, msg.sender);
```

run() passes msg.sender as the address the salt is mined for, and deploy() then executes `new MeatbagHook{salt: salt}(...)` (line 35). Inside a forge script with vm.startBroadcast() active, a salted `new` is not a CREATE2 from the broadcaster: forge routes it through the default deterministic deployer 0x4e59b44847b379578588920cA78FbF26c0B4956C, so the hook lands at keccak(0xff, 0x4e59..., salt, initCodeHash), not keccak(0xff, msg.sender, salt, initCodeHash). The 14 permission bits of that address do not equal FLAGS and the require at line 36 reverts after about 114M gas of salt mining. test/Deploy.t.sol never hits this because it calls deploy() directly with the test contract as deployer (a plain CREATE2 from that contract), so the path the script exists for (README line 317: rehearsals on a fork or devnet) is untested and broken. Impact is limited to tooling: the launch factory mines its own salt and the live hook 0xe7b8...20cc carries the right flags. Fix: mine for the deterministic deployer when broadcasting (pass 0x4e59b44847b379578588920cA78FbF26c0B4956C as `deployer`, or compute candidates with vm.computeCreate2Address(salt, initCodeHash), which assumes that deployer), or deploy the hook from a helper contract whose address is the one mined for, as the test does.

**Reproduction**

Input: `forge script script/Deploy.s.sol --sig "run(address,address)" 0x000000000004444c5dc75cB358380D2e3dE08A90 0x12C63b581d07093F6126bc02263c58f7EadaA96F` with no RPC (the local simulation applies the same CREATE2 routing as a broadcast). Expected: the token and a hook whose address carries flags 0x20CC. Actual, run locally: `Deploy::run(...)` consumes 113,799,485 gas and the script ends with `Error: script failed: hook landed on the wrong address`.

### 6. Low: judgeBody(day) panics with an arithmetic underflow for any day without entries

`src/MeatbagGame.sol:427`

```
            bytes((n - 1).toString()),
```

judgeBody() is a public view documented (lines 406-407) as the way for anyone to read the exact oracle.request body judge() sends for a day. For a day with no entries, n = _entries[day].length == 0 and (n - 1) underflows under checked arithmetic (lines 427 and 429), so the call reverts with Panic(0x11) instead of returning an empty body or a named error. judge() itself is unaffected (it only reaches days in roundDays, which all have at least one entry), so the impact is confined to off-chain readers: a frontend previewing today's body before the first entry, or a reader passing a wrong day, gets an opaque arithmetic panic. Fix: `if (n == 0) return "";` (or revert with a named error) before the loop.

**Reproduction**

Input: game.judgeBody(game.today()) before anyone has entered today, or game.judgeBody(0). Expected: an empty body or a named revert. Actual: a staticcall returns ok == false with return data 0x4e487b71...0011 (Panic(0x11), arithmetic underflow) for both inputs. Reproduced in test/scratch/GameRepro.t.sol::test_judgeBodyRevertsOnEmptyDay.

### 7. Info: judge() can only sweep a timed-out request when another closed round is waiting; otherwise the revert rolls the hung declaration back

`src/MeatbagGame.sol:263`

```
            if (cursor >= roundDays.length) revert NothingToJudge();
```

After _hung(day, true) marks the timed-out round hung (and may settle a sunset), judge() continues to the next round and reverts with NothingToJudge (line 263) when there is none, or RoundStillOpen (line 267) when the next round is today's. Both reverts undo the hung declaration, the streak increment and any sunset it triggered. The NatSpec at line 252 says a timed-out request 'is declared a hung jury on the way', which only holds when a further closed round exists; README line 135 ('or the next judge() sweeps it') reads the same way. Nothing is lost, because declareHungJury() hangs the round in both states at the same moment, so this is a control-flow and documentation note: the site's judge flow should call declareHungJury() in these states. If judge() is meant to always sweep, return after _hung() when nothing judgeable follows instead of reverting (the keeper reward and IMD pull have not happened yet at that point). Merged from audit_flow and audit_economics.

**Reproduction**

State: one round (day D) judged on D+1; VERDICT_TIMEOUT (90 000 s) passes with no verdict; no other round exists. Input: judge(). Expected per NatSpec: round D becomes Hung. Actual: revert NothingToJudge(); round D remains Pending and cursor stays 0. Then enter one entry today and call judge() again: revert RoundStillOpen(today); round D still Pending. declareHungJury() then hangs it. Reproduced in test/scratch/GameRepro.t.sol::test_judgeSweepRollsBackWhenNothingElseWaits.

### 8. Info: A round with a single entry has exactly one valid answer, so the lone entrant collects 80% of the pot for 0.001 ETH plus the judge price if the panel answers

`src/MeatbagGame.sol:346`

```
        if (index >= r.count) {
```

judge() has no minimum entry count and onOracleResult accepts any index below r.count. With r.count == 1 the only non-hung outcome is index 0, so on a quiet day a single entrant (who may also be the keeper) wins 80% of a pot that is mostly trading-fee revenue, for a 0.001 ETH entry and the IMD judge price. The request body sets allowAmbiguous true, so the panel may return no verdict, but nothing on chain requires competition. This matches the documented rules (winner takes 80%) and README line 352-356 already records that forty wallets can buy a whole day for 0.82 ETH; the single-entry case is the same capture at 0.001 ETH and is not mentioned. Reported as an economic observation on the agreed design, not a defect: if a contest is wanted, require r.count >= 2 in judge() (hanging or carrying over single-entry rounds) or scale the prize with the entry count.

**Reproduction**

State: pot 5 ETH from fees; day D has one entry from alice (0.001 ETH); day D+1. Input: alice calls judge() (pays 0.5 IMD, is credited 3% = 0.15003 ETH), the panel answers index 0 with panelSize 7, quorum 4, agreed 5. Expected under a competitive reading: a contest among several humans. Actual: alice is credited 80% of the remaining pot plus the keeper reward, claimable(alice) == 4.030806 ETH for 0.001 ETH and 0.5 IMD. Reproduced in test/scratch/GameRepro.t.sol::test_singleEntrantWinsEightyPercent.

### 9. Info: A swap that moves zero ETH posts the herald's one-time 'First trade' letter and counts as the first trade

`src/MeatbagHook.sol:400`

```
        if (before == 0) herald.announce(_H_FIRST_TRADE);
```

_recordVolume announces FIRST_TRADE whenever the running volume was zero before the swap, regardless of whether the swap moved any ETH. afterSwap reaches it with ethMoved == 0 whenever the pool settles no ETH, which v4 allows: an exact-input sell (MEAT in, ETH out) into a pool whose only liquidity sits below the current price (the tokens-only seeding the hook's claim path is designed for) walks to its price limit, moves nothing and returns delta (0, 0). The hook then charges no fee (_splitAndSettle returns at fee == 0) and keeps volume at 0, but burns the one-time FIRST_TRADE message (MeatbagHerald.announce marks a one-time code sent on first use), so the genuine first trade is never announced. On the live deployment volume is already 0.86 ETH (hook.volume() read by RPC), so the effect there is only that the letter may have been posted by a zero swap; for any future deployment the fix is to skip the announce (or return early from _recordVolume) when ethMoved == 0.

**Reproduction**

State: pool initialised at price 1:1 and seeded with MEAT only (position [MIN_TICK, -60], as HookTestBase.setUpPool(false) does); herald.sent(0) == false. Input: swapRouter.swap with SwapParams(zeroForOne = false, amountSpecified = -1e18, sqrtPriceLimitX96 = MAX_SQRT_PRICE - 1). Expected: no fee, no volume and no 'First trade' letter because nothing traded. Actual: the swap returns delta (0, 0), hook.volume() == 0 and herald.sent(0) == true. Reproduced in test/scratch/HookRepro.t.sol::test_zeroVolumeSellAnnouncesFirstTrade.

### 10. Info: No Distributed event when the swarm transfer fails although the pot and the treasury were paid

`src/MeatbagHook.sol:389`

```
            if (!ok) owedSwarm += toSwarm;
            else emit Distributed(toPot, toSwarm, toTreasury);
            return;
```

_distribute pays the pot and the treasury before attempting the swarm transfer. When the swarm call fails the function re-adds the swarm share to owedSwarm and returns without emitting anything: the unconditional emit at line 393 is skipped by the early return inside the toSwarm > 0 branch. Off-chain accounting that reconstructs distributions from Distributed events misses every distribution made while the swarm wallet rejects ETH, even though up to 80% of the fee moved. Fix: emit Distributed(toPot, 0, toTreasury) in the failure branch (or drop the early return and compute the swarm amount actually sent), so each _distribute leaves one event stating exactly what moved.

**Reproduction**

State: a pool after the decay (buyFeeBps == 200), SWARM (0xd011...bca13) etched with code that reverts in receive (as the repo's own test_aRejectingSwarmWalletNeverHaltsSwapsAndIsRetried does). Input: buyExactIn(1 ether) with vm.recordLogs(). Expected: a Distributed event recording toPot 0.011 ETH and toTreasury 0.004 ETH moved and 0 to the swarm. Actual: game.pot() == 0.011 ether, treasury.balance == 0.004 ether, owedSwarm == 0.005 ether, and no log with topic Distributed(uint256,uint256,uint256) is emitted. Reproduced in test/scratch/HookRepro.t.sol::test_noDistributedEventWhenSwarmRejects.

---

Judge's submission `ae755e613323bb670ec013d0b8c349d2d66714ecc7c5ccd842c4689b453e856d`, accepted on the IdentityMD network. Acceptance means the report met the job's checks;
it is not a guarantee that the code has no other defects.
