{"assessments":[],"deployments":[],"fuzz":[],"identity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"interpretation":"Records acceptance and evidence. Neither completion nor an AI assessment establishes correctness, safety, or independent review.","jobId":"dc2377fd-21bc-4fcc-888e-f621a121961e","kind":"audit","nodes":[{"acceptedSubmissionHash":"85b8281219cee0174b6e35d48a7d4dc39d4876e869f2528316a9403b4576603f","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_economics","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"dc5d6d765a30f289b95122f42fe88799c8909ad360d1aeefbca7a7505fe8296c","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_flow","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"6aa61f1bf9b557c6cc5552220abfaaf629fef38e0336b4e986972f658dfccb5f","dependsOn":["audit_math","audit_permissions","audit_economics","audit_flow"],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","tools":[]},"key":"audit_judge","kind":"code","role":"review","skillHash":"3014f1ea5961918ca059453a484bf4c8bcbbfc2248dbe31d94ac7c5cdf8f50bd","skillId":"audit-judge","state":"accepted"},{"acceptedSubmissionHash":"0f27b012ff7dfddcf360a440f827d0533add66e427d168e23166df0ad1626cb7","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_math","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"},{"acceptedSubmissionHash":"3109ae744a0ffc4f4057e2f7c4ec277b5f6111fe9e09a6cc640839fef0a334be","dependsOn":[],"execution":{"network":false,"profile":"foundry","requires":[],"skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","tools":[]},"key":"audit_permissions","kind":"code","role":"review","skillHash":"e5ac2cb1fd91a56aa40b16487fc230c48de0d317c8266140331dd3219bb40a85","skillId":"audit-specialist","state":"accepted"}],"objective":"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.","parentJobId":null,"planHash":"2340b2e4778e1f66f7eff81015a2e768858eb22edbb6bd9b30d0a6407887d84b","previousHash":"0000000000000000000000000000000000000000000000000000000000000000","projectId":"dc2377fd-21bc-4fcc-888e-f621a121961e","publication":{"commit":null,"deliveredAt":null,"repoUrl":null},"receiptIdentity":{"adapter":"0xde152afb7db5373f34876e1499fbd893a82dd336","chainId":1,"collection":"0x0000ec93127baa929e58e97dd0095a2bfb38ec1d","registry":"0x8004a169fb4a3325136eb29fa0ceb6d2e539a432"},"registry":"0xb6d0a187b050fa5bb0b87033a203f37becf4a775","research":[],"schema":"identitymd-work-v1","signals":[{"agentId":"51425","feedbackHash":"6c6bbb176da37331e26d1ed8c3a6b9d419186be9e47b6ee814b56ef1c27918c5","nodeKey":"audit_economics","submissionHash":"85b8281219cee0174b6e35d48a7d4dc39d4876e869f2528316a9403b4576603f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"50961","feedbackHash":"b5a847acfa40e31ed8d6780e6a4a87570e75e8175a954c00ce209b6a65c6bbbb","nodeKey":"audit_flow","submissionHash":"dc5d6d765a30f289b95122f42fe88799c8909ad360d1aeefbca7a7505fe8296c","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51216","feedbackHash":"a5316c0d96c115ae3a3354013ab8bc67b54a160abba730927f7fbc7a788ecb42","nodeKey":"audit_judge","submissionHash":"6aa61f1bf9b557c6cc5552220abfaaf629fef38e0336b4e986972f658dfccb5f","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"51510","feedbackHash":"95a09650955b3283b63cc26237adb7e6a75b1ed0df77f2d4a0296b7adb624586","nodeKey":"audit_math","submissionHash":"0f27b012ff7dfddcf360a440f827d0533add66e427d168e23166df0ad1626cb7","tag1":"review:submission","tag2":"acceptance-v2","value":1},{"agentId":"52180","feedbackHash":"0108d38cca52f63c1a7dae139c6cd8fe5eabf35ba01154c41f21f66d5eb31a21","nodeKey":"audit_permissions","submissionHash":"3109ae744a0ffc4f4057e2f7c4ec277b5f6111fe9e09a6cc640839fef0a334be","tag1":"review:submission","tag2":"acceptance-v2","value":1}],"site":null,"snapshotHash":"effe1a026e6001b36acde6bc189ff6a228bbf002dbad760d88d680806ca3e63d","state":"completed","submissions":[{"artifacts":[],"attempt":1,"bundleHash":null,"device":"8d428b115b0ebd64","findings":[{"citation":"resolved","description":"SeatStream.open() accepts a position only when ECDSA.tryRecover(openDigest(...), signature) returns exactly the immutable `keeper` address (src/SeatStream.sol:96-98). ECDSA recovery yields the address of a secp256k1 key; no ERC-1271 path exists. An address that holds contract code (a Safe, a hardware-wallet smart account, any CREATE/CREATE2 contract) has no private key, so no signature can ever recover to it. Roles are immutable (README: no admin, no upgrades), so a SeatStream deployed with a contract keeper can never open a single position and must be redeployed. The deploy script guards against exactly this class of mistake for the payee (line 18, `payee must be a plain wallet`) and against payee == keeper (line 17), but reads SEATSTREAM_KEEPER on line 16 and passes it straight to the constructor with no code check. The constructor (src/SeatStream.sol:78) only rejects address(0). A keeper with code is a more damaging misconfiguration than a payee with code: a contract payee can still receive ETH via claim() in most cases, while a contract keeper disables the only entry point that funds the contract, with release/revoke/pause still callable from that contract so nothing fails loudly at deploy time. Fix: add `require(keeper.code.length == 0, \"keeper must be a plain wallet\");` next to the payee check in deploy() (the test in test/Deploy.t.sol can be extended the same way the payee case is). Note: under EIP-7702 a delegated EOA has code and can still sign; the project pins evm_version cancun, and the same caveat already applies to the existing payee check, so the two checks stay consistent.","line":18,"path":"script/Deploy.s.sol","reproduction":"Inputs: SEATSTREAM_PAYEE = any EOA, SEATSTREAM_KEEPER = address of any deployed contract (e.g. `new Dummy()` in a test, or a Safe on Base), SEATSTREAM_PRICE_WEI = 50000000000000000, SEATSTREAM_CHAIN_ID = 8453, chain id 8453. Expected: Deploy.deploy() reverts the way it does for a payee with code. Actual: deploy() succeeds, `s.keeper()` is the contract address and `s.keeper().code.length > 0` (verified with a scratch Foundry test: test_deploy_accepts_contract_keeper passes, i.e. deployment is accepted). Afterwards every call to open(tokenId, deadline, signature) reverts with BadSignature because ECDSA.tryRecover can only return an address derived from a private key, which a contract address does not have; the deployment is permanently unable to take deposits and has to be redeployed with a new address.","severity":"low","snippet":"        require(payee.code.length == 0, \"payee must be a plain wallet\");","title":"Deploy script checks that the payee has no code but not the keeper, so an immutable keeper that cannot produce ECDSA signatures deploys a contract on which open() can never succeed"}],"hash":"0f27b012ff7dfddcf360a440f827d0533add66e427d168e23166df0ad1626cb7","nodeId":"00130ac1-8206-4b1b-903d-5190d6b0634f","outcome":"completed","summary":"The review is complete. Findings are written to `.imd-findings.json` in the repository root.\n\n**Result: one low-severity finding, no math defects in the contract.**\n\n**What I checked** against the Math Precision, Boundary and Numerical Gap guides on `src/SeatStream.sol` and `script/Deploy.s.sol`:\n\n- **Streaming arithmetic.** The per-second accrual, its cap at the stored balance, the paidUntil projection, and every subtraction were traced with concrete values. The minimum-price guard guarantees at least 1 wei per second, so the zero-rounding seam that would let frequent settlements make the stream free cannot occur. Scratch tests confirmed exact streaming at the minimum price, strictly less than 1 wei lost per settlement at the worst near-minimum price, and that paidUntil lands within one second before the real exhaustion second for fuzzed deposits, exactly as the README accepts.\n- **Overflow and downcasts.** No reachable overflow in the multiply-before-divide chains for any ETH supply or realistic price. The uint64 timestamp cast is safe.\n- **Boundaries.** Every external call follows checks-effects-interactions with state deleted or zeroed before the send. The capped-gas release push credits a refund on failure. Both payable entry points credit the full value. There is no receive or fallback, so no stray ETH path.\n- **View versus write consistency.** positionOf, paidUntil, pendingClaim and the settle path share one accrual function. The duplicate-id overcount in pendingClaim is already an accepted item.\n\n**The one finding** is in the deploy script. It refuses a payee with contract code but accepts a keeper with contract code. Because open requires an ECDSA signature recovering to the immutable keeper, a contract keeper can never sign, so the deployment could never open a position and would have to be redeployed. A scratch test confirmed the script accepts a contract keeper on chain 8453. The fix is a one-line code-length check mirroring the payee one.\n\n**Not reported.** Role powers such as keeper pause, release and revoke are the documented trust model and accepted griefing surface. The existing 40 tests pass on this tree. The scratch tests under `test/scratch/` are disposable and nothing else was changed.","treeHash":null,"usage":{"cachedInputTokens":511213,"inputTokens":226,"model":"claude-fable-5-1","outputTokens":22416,"runtime":"claude","turns":23,"wallClockMs":319397}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"c722c2e9ac9aa084","findings":[{"citation":"resolved","description":"Deploy.s.sol guards the payee role against being a contract (line 18) because the roles are immutable and a wrong role address cannot be corrected after deployment. The keeper role has the same immutability but no equivalent guard: `address keeper = vm.envAddress(\"SEATSTREAM_KEEPER\")` (line 16) is only checked to differ from the payee (line 17). The keeper's sole way to authorize an open is an ECDSA signature that `ECDSA.tryRecover` must resolve to exactly `keeper` (src/SeatStream.sol:96-98). A contract address (a Safe, an ERC-4337 account, any address with code) has no private key, so no signature can ever recover to it; `ecrecover` only yields EOA-style addresses. If the operator sets SEATSTREAM_KEEPER to a smart-contract wallet, the script deploys successfully and prints the addresses, but `open()` reverts with `BadSignature()` for every caller forever. `pause`, `resume`, `release` and `revokeOpens` from that keeper would still work (they are msg.sender checks a contract can satisfy), which makes the misconfiguration look partially functional in a smoke test. There is no admin, no upgrade and no way to change `keeper`, so the only remedy is a fresh deployment at a new address. This is a deploy-time trust-gap asymmetry: the script enforces the 'plain wallet' precondition for one immutable role and silently skips it for the other, while the contract constructor (src/SeatStream.sol:75-82) checks neither. No funds are at risk because nobody can open a position on the dead deployment; the cost is a wasted deployment, a wrong address published to the operator API, and on mainnets the gas of a redeploy. Fix: add `require(keeper.code.length == 0, \"keeper must be a plain wallet\");` next to the payee check in Deploy.s.sol (and optionally a matching note in the README). Note that an EOA carrying an EIP-7702 delegation also has code (0xef0100..) and would be rejected by both checks; that is consistent with the existing payee rule.","line":18,"path":"script/Deploy.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Deploy} from \"../../script/Deploy.s.sol\";\nimport {SeatStream} from \"../../src/SeatStream.sol\";\n\n/// Deploy.s.sol rejects a payee that has code but not a keeper that has code. A keeper with code (a Safe, a\n/// smart account, any contract) can never produce an ECDSA signature that recovers to its own address, so\n/// every open() on the resulting immutable deployment reverts with BadSignature and the contract is dead.\n/// Expected: deploy() reverts for a keeper with code, as it does for a payee with code. Actual: it deploys.\ncontract DeployKeeperCodeTest is Test {\n    uint256 constant PRICE = 0.05 ether;\n\n    function test_deploy_rejects_keeper_with_code() public {\n        vm.setEnv(\"SEATSTREAM_PAYEE\", vm.toString(makeAddr(\"payee\")));\n        vm.setEnv(\"SEATSTREAM_KEEPER\", vm.toString(address(this))); // has code, like a Safe\n        vm.setEnv(\"SEATSTREAM_PRICE_WEI\", \"50000000000000000\");\n        vm.chainId(84532);\n        Deploy d = new Deploy();\n        vm.expectRevert();\n        d.deploy();\n    }\n}","reproduction":"Input: environment SEATSTREAM_PAYEE=<any EOA>, SEATSTREAM_KEEPER=<any address with code, e.g. a Safe proxy>, SEATSTREAM_PRICE_WEI=50000000000000000, chain id 84532 (or 8453 with SEATSTREAM_CHAIN_ID=8453). Run `new Deploy().deploy()` (or `forge script script/Deploy.s.sol`). Expected: revert, as the script does for a payee with code (`payee must be a plain wallet`). Actual: the SeatStream contract is deployed with `keeper()` equal to the contract address. Afterwards, for any tokenId, depositor and deadline, every `open(tokenId, deadline, signature)` reverts with `BadSignature()` because `ECDSA.tryRecover` can only return an address derived from a secp256k1 public key, which a contract address is not. The scratch test test/scratch/DeployKeeperCode.t.sol (source in `proof`) sets SEATSTREAM_KEEPER to the test contract's own address (which has code), calls deploy() under `vm.expectRevert()`, and fails on the current code with `next call did not revert as expected`; it passes once the script rejects a keeper with code. Run it alone with `forge test --match-path test/scratch/DeployKeeperCode.t.sol`; it sets process-global env vars, like the existing test/Deploy.t.sol does.","severity":"low","snippet":"        require(payee.code.length == 0, \"payee must be a plain wallet\");","title":"Deploy script rejects a payee with code but not a keeper with code, although a contract keeper bricks every open() permanently"}],"hash":"3109ae744a0ffc4f4057e2f7c4ec277b5f6111fe9e09a6cc640839fef0a334be","nodeId":"12ef8dca-6b8a-44a4-8ce1-68376f11ac7d","outcome":"completed","summary":"The findings file and scratch tests are on disk; they are ignored by a harness-level exclude rather than the repo's own gitignore, which is the expected handling for review outputs. The review is complete.\n\n## Result\n\nThe permission model of `src/SeatStream.sol` holds up. I found one low-severity defect, in `script/Deploy.s.sol`, and recorded it in `.imd-findings.json` with a failing Foundry proof.\n\n**Finding (low): the deploy script guards the payee against being a contract but not the keeper.** Line 18 of the script rejects a payee with code because the roles are immutable. The keeper gets no such check. A keeper that is a contract (a Safe, an ERC-4337 account) can never produce an ECDSA signature that recovers to its own address, so every `open` on that deployment reverts with `BadSignature` forever. The keeper's other powers still work, so a smoke test would look partially healthy. Fix is one line: the same code-length check for the keeper. The proof test under `test/scratch/DeployKeeperCode.t.sol` fails on the current code with \"next call did not revert as expected\".\n\n## What was checked and found sound\n\n- **Entry-point inventory.** Seven public functions, five role-gated ones, no `receive` or `fallback`. Every guard matches the README's role table. `nonReentrant` covers every function that makes an external call, and the only non-guarded state changers are keeper- or payee-only.\n- **Trust gaps.** Neither role can reach unstreamed balances: `release` only refunds the depositor, `claim` only pays `claimable`, and nothing but `_settle` increases `claimable`. `pause` settles before flagging, and `resume` resets the timestamp, so no retroactive charge or sweep exists.\n- **Asymmetries.** `withdraw` and `release` both settle, delete, then send. `open` and `revokeOpens` both bump the nonce. The EIP-712 digest binds token, depositor, nonce, deadline, chain id, and contract address.\n- **Executed confirmations.** A depositor contract that re-enters all nine entry points from `receive` during `withdraw` and `release` succeeds at none. Sending `withdrawTo` to the contract itself reverts. Settling a paused position through `claim` changes nothing. All 40 existing tests pass, including the invariant suite.\n\n## Not reported\n\nKeeper-key griefing (squats, pauses, releases, revokes) and the one-way `disableOpens` are documented trust assumptions, not defects. A payee that later adopts EIP-7702 code which rejects ETH would only block its own `claim`, so that is self-harm and was left out. Per-settlement rounding loss stays below one wei, as the README accepts.","treeHash":null,"usage":{"cachedInputTokens":1811833,"inputTokens":450,"model":"claude-fable-5-1","outputTokens":30941,"runtime":"claude","turns":38,"wallClockMs":482573}},{"artifacts":[],"attempt":2,"bundleHash":null,"device":"fa2b7fcb5f53535a","findings":[{"citation":"resolved","description":"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.","line":18,"path":"script/Deploy.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Deploy} from \"script/Deploy.s.sol\";\nimport {SeatStream} from \"src/SeatStream.sol\";\n\n/// Stands in for a Safe / smart account mistakenly configured as SEATSTREAM_KEEPER.\ncontract ContractKeeper {\n    function ping() external pure returns (uint256) {\n        return 1;\n    }\n}\n\n/// script/Deploy.s.sol refuses a payee with code (\"payee must be a plain wallet\", line 18) but accepts a\n/// keeper with code. SeatStream.open() only accepts an authorization when ECDSA.tryRecover returns exactly\n/// `keeper` (src/SeatStream.sol:96-98); a contract has no secp256k1 key, so on such a deployment open()\n/// reverts BadSignature for every input and the immutable contract can never take a deposit.\n///\n/// Fails on the current script (deploy() succeeds); passes once deploy() rejects a keeper that cannot sign.\n/// Run alone: `forge test --match-path test/scratch/DeployKeeperCode.t.sol` (env vars are process-global,\n/// like in test/Deploy.t.sol).\ncontract DeployKeeperCodeTest is Test {\n    function test_deploy_rejects_keeper_that_cannot_sign() public {\n        vm.chainId(84532);\n        address payee = makeAddr(\"payee\");\n        ContractKeeper keeper = new ContractKeeper();\n        assertGt(address(keeper).code.length, 0, \"precondition: keeper is a contract\");\n\n        vm.setEnv(\"SEATSTREAM_PAYEE\", vm.toString(payee));\n        vm.setEnv(\"SEATSTREAM_KEEPER\", vm.toString(address(keeper)));\n        vm.setEnv(\"SEATSTREAM_PRICE_WEI\", \"50000000000000000\");\n\n        Deploy d = new Deploy();\n        // Expected: the script refuses, as it refuses a payee with code.\n        // Actual today: it deploys a SeatStream whose open() can never pass the signature check.\n        vm.expectRevert();\n        d.deploy();\n    }\n\n    /// Supporting evidence: on such a deployment no signer can open a position.\n    function test_contract_keeper_makes_open_unusable() public {\n        ContractKeeper keeper = new ContractKeeper();\n        SeatStream s = new SeatStream(0.05 ether, makeAddr(\"payee\"), address(keeper));\n        address alice = makeAddr(\"alice\");\n        vm.deal(alice, 1 ether);\n        uint256 deadline = block.timestamp + 1 days;\n        for (uint256 pk = 1; pk <= 5; ++pk) {\n            (uint8 v, bytes32 r, bytes32 ss) = vm.sign(pk, s.openDigest(1, alice, 0, deadline));\n            vm.prank(alice);\n            vm.expectRevert(SeatStream.BadSignature.selector);\n            s.open{value: 0.05 ether}(1, deadline, abi.encodePacked(r, ss, v));\n        }\n        // An empty / malformed signature is rejected too.\n        vm.prank(alice);\n        vm.expectRevert(SeatStream.BadSignature.selector);\n        s.open{value: 0.05 ether}(1, deadline, new bytes(65));\n    }\n}","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).","severity":"low","snippet":"        require(payee.code.length == 0, \"payee must be a plain wallet\");","title":"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"},{"citation":"resolved","description":"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.","line":214,"path":"src/SeatStream.sol","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.","severity":"info","snippet":"        _send(payee, amount);","title":"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)"}],"hash":"6aa61f1bf9b557c6cc5552220abfaaf629fef38e0336b4e986972f658dfccb5f","nodeId":"c9c6a1b2-a7b9-48b6-aaed-700ca3bdfd02","outcome":"completed","summary":"The review is complete. The tracked tree is unchanged, the findings file and scratch tests are git-excluded by design, and the embedded proof is byte-identical to the test I ran.\n\n**Outcome: two findings kept, written to `.imd-findings.json`.**\n\n- **Low, merged from all four specialists:** the deploy script rejects a payee with code but not a keeper with code. Since `open()` only accepts an ECDSA recovery equal to the immutable keeper, a Safe or smart-account keeper produces a deployment that can never open a position and must be redeployed. Reproduced with a scratch Foundry test: deploying with a contract keeper on chain 84532 succeeds instead of reverting, and five different signing keys plus a zero signature all hit `BadSignature` on the result. The test fails on the current script and passes against a scratch copy carrying the one-line keeper check. Both specialist proofs were also run and fail for the stated reason. My own proof, using root-relative imports, is attached to the finding.\n- **Info, from the economics specialist:** `claim()` is the only revenue path and pushes to the immutable payee with no `claimTo` or pull fallback, unlike every other outbound path. Reproduced by etching rejecting code onto the payee after 15 days of streaming: claim reverts, the half-period of revenue stays in the contract, and nothing else can move it. Kept as a documented trust assumption because it needs the trusted payee's own action (an EIP-7702 delegation to code that rejects ETH) and has no unprivileged trigger. No proof attached, since the fix adds a new path rather than changing current behaviour.\n\n**Dropped or merged:** the four keeper-check reports were duplicates at the same line with the same fix and were merged into one. Nothing else was dropped; every specialist finding reproduced.\n\n**Independent pass:** I walked every entry point, the settlement math, the EIP-712 domain and nonce handling, reentrancy coverage, the capped-gas release path, and the deploy guards against the Pashov, x-ray, Trail of Bits and ethskills checklists. The existing suite of 40 unit, fuzz and invariant tests passes. I found no additional defects beyond the two above. One lead I checked and rejected: arithmetic overflow in per-second accrual would need a price above roughly 1e57 wei, which the deploy script's fixed mainnet price rules out.","treeHash":null,"usage":{"cachedInputTokens":715266,"inputTokens":258,"model":"claude-fable-5-1","outputTokens":21781,"runtime":"claude","turns":27,"wallClockMs":312265}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"bcb8a3f211778fd6","findings":[{"citation":"resolved","description":"Deploy.s.sol validates the payee (must differ from the keeper, must have no code) but performs no check at all on SEATSTREAM_KEEPER beyond the constructor's non-zero test. The keeper is the one role that must be able to produce secp256k1 signatures: open() accepts a position only when ECDSA.tryRecover(openDigest(...)) == keeper (src/SeatStream.sol:96-98). A contract address (multisig, smart account, a typo pointing at any deployed contract) has no private key, so no signature can ever recover to it. Because price, payee and keeper are immutable and there is no admin path, such a deployment can never open a position and must be abandoned and redeployed. The script already demonstrates the intended rule for the payee one line earlier, so the keeper omission is an asymmetry in the deploy checks rather than a design decision. Related note: the payee check itself is only a heuristic. It rejects a cold-wallet EOA that carries an EIP-7702 delegation (23 bytes of code, live on both Ethereum and Base) even though such a wallet can still receive ETH, and it accepts a counterfactual contract address that has not been deployed yet. Minimal fix: add `require(keeper.code.length == 0 || _isEip7702Delegation(keeper), \"keeper must be able to sign\")` next to the payee check, where the helper accepts exactly 23-byte code starting with 0xef0100 (a delegated EOA can still sign). Optionally apply the same 7702 allowance to the payee check so a delegated cold wallet is not refused.","line":18,"path":"script/Deploy.s.sol","proof":"// SPDX-License-Identifier: MIT\npragma solidity 0.8.28;\n\nimport {Test} from \"forge-std/Test.sol\";\nimport {Deploy} from \"script/Deploy.s.sol\";\nimport {SeatStream} from \"src/SeatStream.sol\";\n\n/// Stands in for a multisig / smart account mistakenly configured as SEATSTREAM_KEEPER.\ncontract ContractKeeper {\n    function ping() external pure returns (uint256) {\n        return 1;\n    }\n}\n\n/// Deploy.s.sol refuses a payee with code (\"payee must be a plain wallet\") but accepts a keeper with code.\n/// A keeper that is a contract can never produce an ECDSA signature that recovers to its own address, so\n/// `open` reverts BadSignature forever and the (immutable) deployment is dead on arrival.\n/// Fails on the current script (deploy succeeds); passes once the script rejects a non-signing keeper.\ncontract DeployKeeperCheckTest is Test {\n    function test_deploy_rejects_keeper_that_cannot_sign() public {\n        vm.chainId(84532);\n        address payee = makeAddr(\"payee\");\n        ContractKeeper keeper = new ContractKeeper();\n        assertGt(address(keeper).code.length, 0, \"precondition: keeper is a contract\");\n\n        vm.setEnv(\"SEATSTREAM_PAYEE\", vm.toString(payee));\n        vm.setEnv(\"SEATSTREAM_KEEPER\", vm.toString(address(keeper)));\n        vm.setEnv(\"SEATSTREAM_PRICE_WEI\", \"50000000000000000\");\n\n        Deploy d = new Deploy();\n        // Expected: the script refuses, exactly as it refuses a payee with code.\n        // Actual today: it deploys a SeatStream whose open() can never pass the signature check.\n        vm.expectRevert();\n        d.deploy();\n    }\n\n    /// Supporting evidence for the claim above: on such a deployment no signer can open a position.\n    function test_contract_keeper_makes_open_unusable() public {\n        ContractKeeper keeper = new ContractKeeper();\n        SeatStream s = new SeatStream(0.05 ether, makeAddr(\"payee\"), address(keeper));\n        address alice = makeAddr(\"alice\");\n        vm.deal(alice, 1 ether);\n        uint256 deadline = block.timestamp + 1 days;\n        // Any real key recovers to its own EOA, never to the contract address held as `keeper`.\n        for (uint256 pk = 1; pk <= 5; ++pk) {\n            (uint8 v, bytes32 r, bytes32 ss) = vm.sign(pk, s.openDigest(1, alice, 0, deadline));\n            vm.prank(alice);\n            vm.expectRevert(SeatStream.BadSignature.selector);\n            s.open{value: 0.05 ether}(1, deadline, abi.encodePacked(r, ss, v));\n        }\n    }\n}","reproduction":"State: chain 84532 (or any supported id), SEATSTREAM_PAYEE = a fresh EOA, SEATSTREAM_KEEPER = the address of any deployed contract (e.g. a Safe), SEATSTREAM_PRICE_WEI = 50000000000000000. Call Deploy.deploy(). Expected: revert, mirroring the payee check. Actual: a SeatStream is deployed with keeper = that contract. Then for any private key pk and any (tokenId, depositor, deadline), open(tokenId, deadline, sign(pk, openDigest(...))) reverts BadSignature, because the recovered address is pk's EOA and can never equal the contract keeper. No position can ever be opened; the only remedy is a new deployment. test/scratch/DeployKeeperCheck.t.sol: test_deploy_rejects_keeper_that_cannot_sign fails today (deploy does not revert), test_contract_keeper_makes_open_unusable shows five different keys all rejected on such a deployment.","severity":"low","snippet":"        require(payee.code.length == 0, \"payee must be a plain wallet\");","title":"Deploy script accepts a keeper address that can never sign, producing a dead immutable deployment"},{"citation":"resolved","description":"Every other outbound ETH path anticipates 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 to the immutable payee with _send, which reverts on failure, and there is no claimTo or pull-based alternative. The payee is a trusted cold wallet and the deploy script requires it to have no code, so this needs the payee's own action to trigger (an EIP-7702 delegation to an implementation whose receive/fallback is not payable or reverts, which is possible on both target chains) and it has no unprivileged trigger. It is reported as a trust assumption and robustness gap, not a vulnerability: once in that state, claimable keeps growing as depositors stream, nothing can ever move it out, and the contract has no admin to change the destination. Minimal fix preserving the design: add `claimTo(uint256[] calldata tokenIds, address to)` restricted to msg.sender == payee (claim() can stay permissionless and keep paying the payee address), or make claim credit the payee into refunds on a failed push so collectRefundTo can be used.","line":214,"path":"src/SeatStream.sol","reproduction":"State: a position open for 15 days at price 0.05 ETH, so pendingClaim == 0.025 ETH. The payee EOA then carries code that rejects plain ETH (simulated with vm.etch of a contract whose receive() reverts, standing in for a 7702 delegation). claim([1]) reverts TransferFailed; pendingClaim stays 0.025 ETH, contract balance stays 0.05 ETH, payee balance stays 0. No other function can move claimable. Verified by test/scratch/ClaimRecipient.t.sol (passes as a demonstration of the stuck state; no proof attached since the fix adds a new path rather than changing claim's behaviour).","severity":"info","snippet":"        _send(payee, amount);","title":"claim() has no alternate recipient: if the immutable payee ever rejects ETH, streamed revenue is stuck forever"}],"hash":"85b8281219cee0174b6e35d48a7d4dc39d4876e869f2528316a9403b4576603f","nodeId":"ceb68580-ca9d-423e-8139-e95119e9a270","outcome":"completed","summary":"The audit is complete and the findings file holds two substantiated items. Nothing outside `test/scratch/` and `.imd-findings.json` was changed.\n\n**Outcome.** `src/SeatStream.sol` has no reproducible defect that lets anyone take depositor or payee funds. The single real gap is in `script/Deploy.s.sol`, plus one robustness note on the contract. Both are recorded in `.imd-findings.json`.\n\n**Findings**\n- **Low, deploy script.** The script refuses a payee with code but never checks the keeper. A keeper set to a contract address (a Safe, a smart account, a typo) can never produce an ECDSA signature that recovers to itself, so `open` reverts `BadSignature` forever and the immutable deployment is dead. The attached proof fails on the current script (deploy succeeds) and would pass once the keeper is validated. The description also notes the payee check is a heuristic: it rejects an EIP-7702 delegated cold wallet and accepts a not-yet-deployed contract address.\n- **Info, contract.** `claim` is the payee's only revenue path, pushes to the immutable payee with a reverting send, and has no `claimTo` or pull fallback, unlike every other outbound ETH path. If the payee address ever rejects ETH, streamed revenue is stuck for good. Verified with a scratch test. It needs the payee's own action to trigger, so it is a trust assumption, not a vulnerability.\n\n**What was checked and held.** Solvency conservation across open, deposit, withdraw, release, claim and refunds. Settlement rounding stays under one wei per settlement and cannot be amplified profitably. Pause and resume accounting, exhaustion and top-up restart, the no-debt cap, and `paidUntil` bounds. Signature binding to token id, depositor, per-token nonce, bounded deadline, chain id and contract, with OpenZeppelin 5.1.0 rejecting high-s and zero recoveries. Reentrancy on every value-moving path, the 50,000 gas refund push with returndata ignored, and the refund fallback. Keeper and payee powers match the README threat model. Griefing by a stolen keeper key is possible but is the documented accepted case. The existing suite of 40 tests, including the invariant suite, passes.\n\n**Not run.** Slither and Mythril are not available on this worker. Fuzz and invariant runs used the repository's configured depth only.","treeHash":null,"usage":{"cachedInputTokens":1307723,"inputTokens":322,"model":"claude-fable-5-1","outputTokens":34155,"runtime":"claude","turns":26,"wallClockMs":527204}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"e3a598aae0640402","findings":[],"hash":"b4915e57d8c279614d2a3dbfcdd93597007c9ccaf432154dc2892251e81773f1","nodeId":"c9c6a1b2-a7b9-48b6-aaed-700ca3bdfd02","outcome":"failed","summary":"runtime reported <synthetic>, not the required premium model claude-fable-5-1","treeHash":null,"usage":{"cachedInputTokens":0,"inputTokens":0,"model":"<synthetic>","outputTokens":0,"runtime":"claude","turns":1,"wallClockMs":4682}},{"artifacts":[],"attempt":1,"bundleHash":null,"device":"d68ba89c0b30801c","findings":[{"citation":"resolved","description":"Deploy.deploy() guards the payee (`payee.code.length == 0`, line 18) and the payee/keeper distinctness (line 17) but applies no equivalent check to `keeper`. SeatStream.open() authenticates the keeper only through `ECDSA.tryRecover(...) == keeper` (src/SeatStream.sol:96-98), so a keeper address that is a smart account (Safe, ERC-1271 wallet, or any contract) can never produce an accepted authorization: ecrecover never yields a contract's address, and there is no ERC-1271 path. Because `keeper` is immutable and the README states there is no admin or upgrade path, such a deployment is permanently inert: no position can ever be opened, and the only remedy is a redeployment with a new address, breaking any integration that already recorded the first one. Deploy.s.sol is in scope precisely to catch misconfiguration of the immutable roles (it already pins price and chain id for that reason), and this is the one immutable role it leaves unchecked. No funds are at risk, since nothing can be deposited; the impact is operational and limited to the deploying party. Fix: add `require(keeper.code.length == 0, \"keeper must be a plain wallet\")` next to the payee check, and extend test/Deploy.t.sol with the mirror case. Note the same caveat as the payee check: an EIP-7702-delegated EOA carries code at deploy time and would be rejected, which is acceptable since the README describes the keeper as a hot key on the operator's box.","line":18,"path":"script/Deploy.s.sol","reproduction":"State: SEATSTREAM_PAYEE = an EOA, SEATSTREAM_KEEPER = the address of any deployed contract (e.g. a Safe), SEATSTREAM_PRICE_WEI = 50000000000000000, SEATSTREAM_CHAIN_ID = 8453, chain id 8453. Call Deploy.deploy(). Expected (by analogy with the payee check at line 18): revert with a misconfiguration message. Actual: SeatStream deploys with keeper() == the contract address. Afterwards every open(tokenId, deadline, signature) for any inputs reverts with BadSignature, because tryRecover can only return an EOA-style address and it can never equal the contract keeper. Verified with a Foundry test: `new Deploy().deploy()` succeeds with a ContractKeeper as SEATSTREAM_KEEPER, `s.keeper().code.length > 0`, and `s.open{value: 0.05 ether}(1, block.timestamp + 1 days, new bytes(65))` reverts with BadSignature.","severity":"low","snippet":"        require(payee.code.length == 0, \"payee must be a plain wallet\");","title":"Deploy script rejects a payee with code but accepts a keeper with code, producing a deployment on which open() can never succeed"}],"hash":"dc5d6d765a30f289b95122f42fe88799c8909ad360d1aeefbca7a7505fe8296c","nodeId":"8d2586b7-f801-4355-8618-695f191e3d12","outcome":"completed","summary":"The review is complete. The findings file is written and valid, the scratch tests are removed, and no repository file was changed. The git working tree is clean apart from the untracked findings file.\n\n**Outcome: one low finding, no critical, high or medium defects.** The file `.imd-findings.json` holds it.\n\n**The finding.** The deploy script refuses a payee with code but accepts a keeper with code. SeatStream authenticates the keeper only through ecrecover, so a Safe or other smart account as keeper yields a deployment on which no position can ever be opened, and the role is immutable with no admin path. I confirmed this with a Foundry test: deploy succeeded with a contract keeper, and every open reverted with BadSignature. No funds are at risk, so it stays low. The fix is a mirror check on the keeper plus a test case.\n\n**What I covered in depth.** I built the entry-point inventory for all twelve state-changing functions and traced each through guards, settlement, deletion and the external call, following the Execution Trace, Periphery and First Principles guides, then applied the validation gates.\n\n- **Signature path.** Digest binds token id, caller, per-token nonce and deadline under a chain-and-address domain. Malleability and length are handled by the vendored library. Replay after withdraw, release and revoke is blocked. The deadline cap holds at the boundary.\n- **Streaming math.** Accrual floors in the depositor's favour, is capped at the balance, and never charges paused time. Resume resets the clock without retroactive debt. I traced exhaust, pause, deposit-while-paused, claim-while-paused, resume and withdraw in one sequence and conservation held exactly.\n- **External calls.** Withdraw and release delete the position before sending. Release uses a 50k gas call with no returndata copy, and a depositor that reenters claim during the push is stopped by the reentrancy guard. Claim pays only the payee.\n- **Threat model.** A stolen keeper key can squat, pause, release and revoke but cannot reach depositor balances or claimable. The payee cannot reach unstreamed balances. Those match the accepted griefing in the README.\n- **Deployment.** Constructor bounds, chain-id pinning and mainnet price pinning behave as documented.\n\n**Leads I checked and rejected.** Frequent settlement to shave rounding costs far more gas than it saves. A release sent with too little gas credits the refund instead of pushing it, which is the documented fallback and loses nothing. The repeated-id overstatement in the pending-claim view is already accepted in the README.\n\n**Trust assumptions worth stating, not defects.** Keeper key loss, as opposed to theft, permanently stops new opens on that deployment because roles cannot be rotated. After the payee disables opens, exhausted positions cannot be topped up, which is the intended wind-down.\n\n**Limits.** Slither and Mythril were not run because they are not provided. The existing unit, fuzz and invariant suites all pass at their configured run counts.","treeHash":null,"usage":{"cachedInputTokens":920189,"inputTokens":290,"model":"claude-fable-5-1","outputTokens":28197,"runtime":"claude","turns":33,"wallClockMs":389424}}],"verification":[]}