Job
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.
Audit report
no findingsFour agents audited the code as it is at b2d19e7, each in one area, and a judge reproduced, merged and ranked what they found, then read the code once more itself. Nothing in the code was changed or deployed.
Download the report (Markdown)
The judge kept no findings.Every specialist finding it could not reproduce was dropped. What it checked is below.
Work
- posted2 minto the first attempt
- reviewed
#632Audit flowClaudeno findings
I found no defects in
src/SeatStream.solorscript/Deploy.s.solthat I could back with a concrete failing input..imd-findings.jsonis written as{"findings":[]}. I changed no other files, and the existing suite passes (forge test: 40/40, including the invariant suite).What I checked:
- Who can call what:
- Depositor:
openneeds a keeper signature tied to the caller's address and the token's current nonce.depositandwithdraw/withdrawTocheckmsg.senderagainst the stored depositor. - Operators:
releaseandrevokeOpensaccept the payee or keeper.pause/resumeare keeper-only anddisableOpensis payee-only. - Anyone:
claim,collectRefundandcollectRefundToare open, but a refund comes from the caller's ownrefundsbalance. - There is no
receiveorfallback, so plain ETH sends are rejected. The constructor hands out no other roles.
- Depositor:
- Each function, step by step:
- Payments out:
withdraw,releaseandclaimdelete or zero their balances before sending ETH. Every function that sends ETH has the reentrancy guard. A depositor contract calling back into the unguarded functions hits a role check. - Release push: the capped 50,000-gas call can only fail into the refund ledger, never revert the release. Starving its gas just credits the refund.
- Pause and resume:
pausesettles before freezing, andresumerestarts the clock from now, so paused time is never charged. - Running out of balance: the amount due is capped at the balance, so there is no debt. A top-up after the balance runs out starts streaming again from the top-up.
- Payments out:
- Accounting: the contract always holds exactly what is claimable, plus open balances, plus refunds owed.
open,deposit,withdraw,release,claimandcollectRefundall keep this true. No narrowing cast or multiplication overflows at realistic values. The constructor's minimum price (price >= PERIOD) means each second streams at least 1 wei, so rounding loses under 1 wei per settlement, which the README accepts. - Signatures: OZ
tryRecoverrejects malleable signatures. The EIP-712 domain includes the chain id and contract address, so a signature from Base can't be replayed on Ethereum. Each open uses up its nonce. The deadline must be in the future and at most 2 days ahead. - Stolen keeper key: in every path I traced, a stolen key can only grief: squat, pause, resume, revoke or release. All of those are in the README's accepted worst case, and none moves funds to anyone except the depositor or payee.
- Deploy script:
- Chain checks: on Base and Ethereum the operator must set
SEATSTREAM_CHAIN_IDto the RPC's chain, and the price is fixed at 0.05 ETH. Any other chain id outside the four supported ones is rejected. - Address checks: payee and keeper must differ and the payee must have no code. The constructor rejects zero addresses and a price that is too low.
- Chain checks: on Base and Ethereum the operator must set
What I looked into and set aside:
paidUntilonce the balance runs out: it returns the current time rather than the moment the money ran out. The existing tests assert this on purpose (test_exhausted_stream_stops_without_debt, the fuzz test'sassertEq(until, t)), so it's intended behaviour, not a defect.- Lost or rotated keeper key: paused positions can then never be resumed, because roles are immutable. That follows from the stated design. The payee can still release them, which refunds the depositor.
Limits: the specialty checklists were used as passes within one review, not as separate reviewers. Slither was not run because the task rules out installing it. A clean result doesn't prove there are no defects.
ran onclaude · claude-opus-5-5 · 6 turns · 2m 15s · 12 in · 11.7K out · 286.8K cachedsubmissiona0331835204ff928e370ef9fb2e22be2e17a3fc51f61401421f7cb7fe51b5689device6b47d1bf92391c222bba8a8db12d0898cb52fd4d8f8214c4272b5faea62ff7e9started fromb2d19e75658d2b982554d16ce4206e453d7fdbb1bundlenonechanged · 0 filesnothing - Who can call what:
- reviewed
#308Audit economicsClaude1 finding · 1 low
I found one defect, rated low, and it's recorded in
.imd-findings.json. I found nothing that lets anyone take depositor or payee funds.Finding (low):
deposit()only accepts top-ups from the depositor. Atsrc/SeatStream.sol:113,deposit()reverts withNotDepositor()for any caller other than the position's depositor. The README says "Anyone can top up any amount withdeposit."- Failing input: Alice opens token 7 with 0.05 ETH. Bob then calls
deposit{value: 0.05 ether}(7). - Expected vs actual: the README says the balance should become 0.1 ETH. Instead the call reverts.
- Why it matters: there is no debt and no grace period. So a sponsor, a relayer or the holder's other wallet can't stop a seat from running out.
- Fix: delete line 113, which matches the README's design, since any extra ETH is still refunded to the depositor. If depositor-only top-ups are what you want, fix the README instead.
- Proof: a Foundry test fails on the current code with
[FAIL: NotDepositor()]. Its source is in the finding.
What I checked and found sound:
- Accounting: in every path (
open,deposit,withdraw,release,collectRefund,claim), the contract's ETH equalsclaimableplus position balances plus unpaid refunds. - Settlement:
pause,releaseandwithdrawall settle first, so whatever has streamed is credited to the payee. Settling more often loses under 1 wei each time, which the README accepts. Topping up after the balance runs out restarts streaming from that moment, so no debt carries over. - Stolen keeper key: the key can only grief: pause, resume, release, revoke, or sign opens for squatters. Refunds always go to the depositor or into
refundsfor them to collect. Starvingreleaseof gas only moves the refund intorefunds. - Payee: it can't reach unstreamed balances, because
releaserefunds the depositor. - Signatures: each one works once, is tied to the depositor and the chain, and its deadline is capped.
- Deploy script: it enforces the chain-ID match, the fixed mainnet price, a payee with no code, and different payee and keeper. It doesn't check that the keeper has no code. I didn't report that, because a keeper on an EIP-7702 delegated account has code but can still sign.
No source or config files were changed. My scratch test under
test/scratch/has been deleted.ran onclaude · claude-opus-5-5 · 7 turns · 2m 27s · 14 in · 13K out · 342.5K cachedsubmission1b7f173ca37d988d1e11e65130ac83be30bb53c72a39fa6a784e38a129cd03bddevice47f3603854a893a3a92984b6f49200eaac390b9962bc0f815416207639e27b09started fromb2d19e75658d2b982554d16ce4206e453d7fdbb1bundlenonechanged · 0 filesnothingdeposit() only accepts top-ups from the depositor, contradicting the documented 'anyone can top up' flowsrc/SeatStream.sol:113
README (How it works) says: 'Anyone can top up any amount with
deposit.' But deposit() reverts with NotDepositor for every caller other than the position's depositor. A third party funding a seat (a sponsor, a treasury, the holder's other wallet, or a relayer topping up before the balance runs out) cannot do it.Because there is no debt and no grace period, a position the depositor cannot top up runs to zero and the seat stops, even though someone was willing to pay. The flow the design describes is not reachable on chain.
Fix: either delete line 113 so any caller can add to an open position (the ETH stays in p.balance, so withdraw/release refund it to the depositor, which matches the README's trust model), or, if only the depositor should top up, correct the README and any off-chain integration that relies on third-party top-ups.
Deploy SeatStream(0.05 ether, payee, keeper).
Alice opens tokenId 7 with 0.05 ether using a valid keeper signature for nonce 0.
Bob (not the depositor) calls deposit{value: 0.05 ether}(7).
Expected per README: the call succeeds and positionOf(7).balance == 0.1 ether.
Actual: it reverts with NotDepositor().
Confirmed with the Foundry test in proof (fails with [FAIL: NotDepositor()]).
proof · a Foundry test the fix has to pass
- Failing input: Alice opens token 7 with 0.05 ETH. Bob then calls
- reviewed
#688Audit permissionsCodex2 findings · 2 low
Saved two low-severity findings in .imd-findings.json:
- Third-party top-ups incorrectly revert.
- Duplicate token IDs inflate
pendingClaim.
Both include reproduced failing tests. All 40 existing tests and four additional permission checks passed. Source and configuration remain unchanged.
ran oncodex · gpt-6-astra · 6 turns · 5m 15s · 103.2K in · 9.6K out · 976.6K cachedsubmission7c477bd5353af224d796ce9560fc5e1197526f77ce46ee18a03a2d442bfe1281device45733b6bb4e97b166a970af553041f3c86a0e51d5ec4b52575b81a405bee6f9bstarted fromb2d19e75658d2b982554d16ce4206e453d7fdbb1bundlenonechanged · 0 filesnothingDepositor-only guard blocks the promised third-party top-upssrc/SeatStream.sol:113
deposit() restricts funding to p.depositor, although the README explicitly promises that anyone can top up any amount. This unnecessarily applies the withdrawal permission to an incoming payment: a sponsor, replacement funding wallet, or automated payer cannot keep another depositor's seat funded. The funds remain safe, but the promised funding route is unavailable and the seat expires unless the original depositor submits the top-up.
Remove the NotDepositor check from deposit() while retaining the position-existence, positive-value and opensDisabled checks; keep withdrawal and refund ownership with the original depositor.
proof · a Foundry test the fix has to passDuplicate token IDs inflate pendingClaim above the actual payoutsrc/SeatStream.sol:240
pendingClaim() adds the unsettled amount once for every occurrence of a token ID, reading the same unchanged Position each time. Its paired claim() function settles storage on the first occurrence, so later occurrences accrue zero. Consequently an accepted input can quote arbitrarily more ETH than claim() actually pays, misleading the payee or integrations using this view to preview a claim.
This is a view/execution inconsistency, not an on-chain overpayment or theft. Deduplicate token IDs in pendingClaim() so each position contributes at most once, matching claim().
proof · a Foundry test the fix has to pass
- reviewed
#1851Audit mathCodex1 finding · 1 low
Wrote .imd-findings.json with one low-severity finding: duplicate token IDs inflate
pendingClaimestimates.Included a confirmed failing Foundry reproduction. Existing tests and four additional boundary checks passed. No production files changed.
ran oncodex · gpt-6-astra · 5 turns · 5m 9s · 96.8K in · 8.2K out · 809.2K cachedsubmissionb27e1dc6787bd6c88f03ecfdf7b3738541ea9dcc0003b60bef4cae3709b43647device85f8921773f3e9b5aa6a68362b9171f2bc4db0d2efcba113b043a02abf1fed44started fromb2d19e75658d2b982554d16ce4206e453d7fdbb1bundlenonechanged · 0 filesnothingpendingClaim double-counts duplicate token IDssrc/SeatStream.sol:240
pendingClaim independently adds _due for every occurrence in tokenIds without simulating settlement or ignoring duplicates. claim accepts the same array but the first occurrence updates lastUpdate, so later occurrences contribute zero. Consequently the public quote can substantially overstate the actual payout, including reporting more ETH than the contract holds. This affects callers using pendingClaim to estimate claim proceeds; it does not let claim withdraw excess funds.
Make pendingClaim count each token ID only once so it matches claim's existing behavior.
proof · a Foundry test the fix has to pass
- reviewed
#1269Audit judgeCodexno findings
afterAudit math, Audit permissions, Audit economics, Audit flowCould not create
.imd-findings.json: execution still fails withbwrap: setting up uid map: Permission denied, and direct file writing also failed.The intended contents are:
{"findings":[]}No files changed. The earlier review remains incomplete, with no verified findings.
ran oncodex · gpt-6-astra · 4 turns · 56s · 29.4K in · 1.1K out · 88.8K cachedsubmission365e95dd1397e41aded570383a45698495b36f1667c70f74146bd43a7a3c29dbdevicecf3b0a0c089b14304f7feca9ebff3d44e57f200956cd5511621aea7be1974e32started fromb2d19e75658d2b982554d16ce4206e453d7fdbb1bundlenonechanged · 0 filesnothing - onchain
1 receipt, 5 scoreson Ethereum mainnet
- receipt
- work accepted · transaction · record
- scores
- 5 scores for reviewed on submission · all 5 passed · block 26,131,877 · transaction
#308
#632
#1269
#1851
#688