# Audit report

> Audit src/SeatStream.sol and script/Deploy.s.sol. README.md has the design, threat model and accepted items. lib/ is vendored OpenZeppelin 5.1 and forge-std and is out of scope.

| | |
|---|---|
| Repository | https://github.com/lomick2090/seatstream.git |
| Commit | `10ccf075573dc34773ac2be586116595cfac13cf` |
| Job | `dc2377fd-21bc-4fcc-888e-f621a121961e` |
| Judged | 2026-10-06 11:35 UTC |
| Findings | 1 low · 1 info |

Four agents audited the code as it is at `10ccf07`, 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: Deploy script rejects a payee with code but not a keeper with code, so a contract keeper yields an immutable deployment on which open() can never succeed

`script/Deploy.s.sol:18`

```
        require(payee.code.length == 0, "payee must be a plain wallet");
```

Merged from four specialist reports (audit_math, audit_flow, audit_economics, audit_permissions), all describing the same defect at the same line. Deploy.deploy() guards the payee role: it must differ from the keeper (line 17) and must have no code (line 18). It applies no equivalent check to SEATSTREAM_KEEPER, which is read on line 16 and passed straight to the constructor; the constructor (src/SeatStream.sol:78) only rejects address(0). The keeper is the one role that must be able to produce secp256k1 signatures: open() accepts an authorization only when ECDSA.tryRecover(openDigest(...), signature) returns exactly `keeper` (src/SeatStream.sol:96-98), and there is no ERC-1271 path. A Safe, ERC-4337 account or any other deployed contract has no private key, so no signature can ever recover to its address. Because price, payee and keeper are immutable and the README states there is no admin or upgrade path, such a deployment can never open a single position and must be abandoned and redeployed at a new address. The misconfiguration is not caught by a smoke test either: pause, resume, release and revokeOpens are msg.sender checks a contract keeper can satisfy, so only open() fails. No depositor or payee funds are at risk (nothing can be deposited); the cost is a wasted deployment, a wrong address published to the operator API, and redeploy gas on the mainnets. Minimal fix, preserving the design: add `require(keeper.code.length == 0, "keeper must be a plain wallet");` next to the payee check in deploy(), and extend test/Deploy.t.sol with the mirror case. Caveat shared with the existing payee check: an EOA carrying an EIP-7702 delegation has 23 bytes of code (0xef0100 || address) yet can still sign; if the operator wants to allow that, accept exactly that code shape for the keeper (and optionally for the payee). Either variant makes the attached proof pass.

**Reproduction**

Reproduced with test/scratch/DeployKeeperCode.t.sol (source in proof) on the pinned commit. State: chain id 84532, SEATSTREAM_PAYEE = a fresh EOA, SEATSTREAM_KEEPER = the address of a freshly deployed contract (code.length > 0, standing in for a Safe), SEATSTREAM_PRICE_WEI = 50000000000000000. Call `new Deploy().deploy()`. Expected: revert with a misconfiguration message, as the script does for a payee with code. Actual: deploy() succeeds and returns a SeatStream whose keeper() is the contract address; the test fails with `next call did not revert as expected`. Supporting test on the resulting deployment: for five different private keys and (tokenId 1, depositor alice, nonce 0, deadline now+1 day), open{value: 0.05 ether}(1, deadline, sig) reverts BadSignature every time, and so does a 65-byte zero signature; the recovered address is always the key's own EOA and can never equal the contract keeper. The same file passes once the check exists (verified against a scratch copy of the script containing the one-line fix). Both specialist proofs (Proof_3ed14607c8bf, Proof_5ee4d16892f9) were also run and fail on the current script for the stated reason. Run alone: `forge test --match-path test/scratch/DeployKeeperCode.t.sol` (env vars are process-global, as test/Deploy.t.sol already notes).

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

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

import {Test} from "forge-std/Test.sol";
import {Deploy} from "script/Deploy.s.sol";
import {SeatStream} from "src/SeatStream.sol";

/// Stands in for a Safe / smart account mistakenly configured as SEATSTREAM_KEEPER.
contract ContractKeeper {
    function ping() external pure returns (uint256) {
        return 1;
    }
}

/// script/Deploy.s.sol refuses a payee with code ("payee must be a plain wallet", line 18) but accepts a
/// keeper with code. SeatStream.open() only accepts an authorization when ECDSA.tryRecover returns exactly
/// `keeper` (src/SeatStream.sol:96-98); a contract has no secp256k1 key, so on such a deployment open()
/// reverts BadSignature for every input and the immutable contract can never take a deposit.
///
/// Fails on the current script (deploy() succeeds); passes once deploy() rejects a keeper that cannot sign.
/// Run alone: `forge test --match-path test/scratch/DeployKeeperCode.t.sol` (env vars are process-global,
/// like in test/Deploy.t.sol).
contract DeployKeeperCodeTest is Test {
    function test_deploy_rejects_keeper_that_cannot_sign() public {
        vm.chainId(84532);
        address payee = makeAddr("payee");
        ContractKeeper keeper = new ContractKeeper();
        assertGt(address(keeper).code.length, 0, "precondition: keeper is a contract");

        vm.setEnv("SEATSTREAM_PAYEE", vm.toString(payee));
        vm.setEnv("SEATSTREAM_KEEPER", vm.toString(address(keeper)));
        vm.setEnv("SEATSTREAM_PRICE_WEI", "50000000000000000");

        Deploy d = new Deploy();
        // Expected: the script refuses, as it refuses a payee with code.
        // Actual today: it deploys a SeatStream whose open() can never pass the signature check.
        vm.expectRevert();
        d.deploy();
    }

    /// Supporting evidence: on such a deployment no signer can open a position.
    function test_contract_keeper_makes_open_unusable() public {
        ContractKeeper keeper = new ContractKeeper();
        SeatStream s = new SeatStream(0.05 ether, makeAddr("payee"), address(keeper));
        address alice = makeAddr("alice");
        vm.deal(alice, 1 ether);
        uint256 deadline = block.timestamp + 1 days;
        for (uint256 pk = 1; pk <= 5; ++pk) {
            (uint8 v, bytes32 r, bytes32 ss) = vm.sign(pk, s.openDigest(1, alice, 0, deadline));
            vm.prank(alice);
            vm.expectRevert(SeatStream.BadSignature.selector);
            s.open{value: 0.05 ether}(1, deadline, abi.encodePacked(r, ss, v));
        }
        // An empty / malformed signature is rejected too.
        vm.prank(alice);
        vm.expectRevert(SeatStream.BadSignature.selector);
        s.open{value: 0.05 ether}(1, deadline, new bytes(65));
    }
}
```

### 2. Info: claim() pushes to the immutable payee with no alternate recipient: if the payee ever rejects ETH, streamed revenue is stuck (trust assumption, not a vulnerability)

`src/SeatStream.sol:214`

```
        _send(payee, amount);
```

Reported by audit_economics; reproduced and kept at info as a documented trust assumption and robustness gap, since it has no unprivileged trigger and needs the trusted payee's own action. Every other outbound ETH path tolerates a recipient that cannot take a plain transfer: withdraw has withdrawTo, collectRefund has collectRefundTo, and release falls back to a refunds credit. claim() is the asymmetry: it is the payee's only revenue path, it pushes with _send (which reverts on failure), and there is no claimTo or pull alternative. The payee is a cold wallet that the deploy script requires to have no code, so the only way to reach this state on Base or Ethereum is the payee itself signing an EIP-7702 delegation to an implementation whose receive/fallback rejects ETH. In that state claim() reverts TransferFailed for everyone, `claimable` keeps growing as depositors stream, nothing can move it out, and there is no admin to change the destination. Depositor funds are unaffected: withdraw, withdrawTo, release and collectRefund(To) keep working. Minimal fix that preserves the design: add `claimTo(uint256[] calldata tokenIds, address to)` restricted to msg.sender == payee (claim() can stay permissionless and keep paying the payee), or credit the payee into `refunds` when the push fails so collectRefundTo can be used. No proof attached: the fix adds a new path rather than changing claim()'s behaviour, so no test can fail now and pass after.

**Reproduction**

Reproduced with test/scratch/ClaimRecipient.t.sol (passes as a demonstration of the stuck state). State: SeatStream(price 0.05 ether, payee EOA, keeper); alice opens token 1 with 0.05 ETH using a valid keeper signature; warp 15 days, so pendingClaim([1]) == 0.025 ETH. Then vm.etch(payee, code of a contract whose receive() reverts), standing in for a 7702 delegation the payee signed. Call claim([1]). Expected under the design's intent: the payee receives 0.025 ETH or has some other way to collect it. Actual: claim reverts TransferFailed; afterwards claimable == 0 (the settlement was rolled back), pendingClaim([1]) is still 0.025 ETH, the contract balance is still 0.05 ETH and the payee balance is 0. No function other than claim() reads or pays out `claimable`, so the amount cannot leave the contract while the payee rejects ETH.

---

Judge's submission `6aa61f1bf9b557c6cc5552220abfaaf629fef38e0336b4e986972f658dfccb5f`, 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.
