feat: add member-level withdrawability and fix viewer canWithdraw for late claims#121
feat: add member-level withdrawability and fix viewer canWithdraw for late claims#121franrolotti merged 7 commits intodevfrom
Conversation
bagelface
left a comment
There was a problem hiding this comment.
PR Review: feat: add member-level withdrawability and fix viewer canWithdraw for late claims
Overall
The motivation is sound — canWithdraw in the viewer was incorrect for late-claiming members because it only checked isCurrentWithdrawer && isWithdrawable. The new isMemberWithdrawable cleanly solves this, and the viewer change to use it is correct. No security vulnerabilities found.
However, there are a few issues worth addressing before merge:
1. O(N²) gas in isWithdrawable — consider short-circuit optimization
isWithdrawable now loops all N members calling _claimable for each (SavingCircles.sol:297). Each _claimable call internally:
- Calls
_isDecommissionable(_id)which itself loops all members via_allMembersDepositedForRound— O(N) - Calls
_allMembersDepositedForRoundagain — O(N)
This makes isWithdrawable O(N²). While this is only a view function (free off-chain), it will cost real gas if called from another contract (e.g. a future integration, a keeper, or a composed DeFi protocol). The _isDecommissionable result is identical for every iteration of the loop and could be hoisted:
function isWithdrawable(uint256 _id) public view override returns (bool) {
if (!isActive[_id]) return false;
if (_isDecommissionable(_id)) return false;
address[] memory members = circleMembers[_id];
for (uint256 i = 0; i < members.length; i++) {
if (_claimableSkipDecommCheck(_id, members[i])) return true;
}
return false;
}This would reduce worst-case to O(N) instead of O(N²). This isn't strictly necessary for correctness, but given this function is part of the public interface and may be called on-chain, it's worth the optimization.
2. Missing unit tests for isMemberWithdrawable
This PR adds a new public function to the interface but no dedicated tests. The following scenarios should have explicit coverage:
| Scenario | Expected |
|---|---|
isMemberWithdrawable on a non-existent / inactive circle |
false |
isMemberWithdrawable for a non-member address |
false (no revert) |
isMemberWithdrawable for a member whose turn hasn't arrived |
false |
isMemberWithdrawable for a member who has already claimed |
false |
isMemberWithdrawable for a late-claimable member (past round, deposits complete) |
true |
isMemberWithdrawable when circle is decommissionable |
false |
Several of these paths are exercised indirectly through existing withdraw tests, but since isMemberWithdrawable is now the source of truth for the viewer's canWithdraw, it deserves first-class test coverage.
3. isWithdrawable semantics are a breaking change — document it
The old isWithdrawable returned whether the current-round withdrawer could claim. The new version returns whether any member can claim. This is a different semantic. If any off-chain or on-chain consumer relied on the old meaning (e.g., a keeper that calls isWithdrawable then withdraw for the current-round member), they'd now get false positives when only a late-claimer is eligible but the current round isn't withdrawable.
Consider adding a @notice annotation on isWithdrawable clarifying the new aggregate semantics, and documenting the change in the interface natspec.
4. Fuzz test change is correct but doesn't expand late-claim coverage
The fuzz test update (SavingCirclesMultiRound.t.sol:532-538) correctly switches from isWithdrawable to isMemberWithdrawable(circleId, recipient), which is more precise. However, recipient is still always storedMembers[circleData.currentIndex] — the current-round member. The fuzz suite never exercises the late-claim path (a past-round member claiming after their round passed). This is the primary new behavior enabled by this PR and would benefit from fuzz coverage.
5. Minor: isMemberWithdrawable natspec
The interface docstring says:
@notice Check if a specific member can withdraw for a circle
"withdraw for a circle" reads ambiguously — it could mean "withdraw on behalf of a circle" rather than "withdraw from a circle". Suggest:
@notice Check if a specific member is eligible to claim their withdrawal from a circle
Verdict
The core logic changes are correct and the approach is clean. No vulnerabilities, no insolvency risks with the late-claim model (each claim always draws exactly one round's worth of deposits). The main requests are:
- Add unit tests for
isMemberWithdrawable(blocking) - Consider the O(N²) optimization (non-blocking but recommended)
- Update natspec for the
isWithdrawablesemantic change (non-blocking)
|
@bagelface 486c156 removes repeated decommission checks in |
AI Review SummaryI reviewed this branch against What looks good
Remaining notes
VerdictNo blocking issues found. |
There was a problem hiding this comment.
Pull request overview
This PR updates withdrawability semantics to support “late claims” at the member level and fixes the viewer’s canWithdraw so it reflects whether the user can claim (not just whether they are the current withdrawer).
Changes:
- Add
isMemberWithdrawable(id, member)toISavingCircles/SavingCirclesand use it inSavingCirclesViewerforUserCircleData.canWithdraw. - Redefine
isWithdrawable(id)as an aggregate “someone in this circle can withdraw” signal (any claimable member). - Update unit and fuzz tests to cover late-claim behavior and to use member-specific withdrawability when selecting a withdrawal recipient.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| test/unit/SavingCirclesViewerUnit.t.sol | Adds coverage ensuring viewer canWithdraw is true for late-claimable members and false when decommissionable. |
| test/unit/SavingCirclesUnit.t.sol | Adds unit tests for isMemberWithdrawable across non-existent/inactive/non-member/future-round/already-claimed/late-claimable/decommissionable cases. |
| test/fuzz/SavingCirclesMultiRound.t.sol | Updates fuzz withdrawal decision to use isMemberWithdrawable(circleId, recipient) instead of circle-level isWithdrawable. |
| src/interfaces/ISavingCircles.sol | Updates isWithdrawable documentation and adds the new isMemberWithdrawable function to the interface. |
| src/contracts/SavingCirclesViewer.sol | Fixes canWithdraw to be computed via member-level withdrawability. |
| src/contracts/SavingCircles.sol | Implements isMemberWithdrawable, changes isWithdrawable semantics, and refactors claimability helpers to support both checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| address[] memory members = circleMembers[_id]; | ||
| for (uint256 i = 0; i < members.length; i++) { | ||
| if (_claimableWithoutDecommissionCheck(_id, members[i])) return true; | ||
| } | ||
|
|
||
| return false; |
There was a problem hiding this comment.
isWithdrawable now loops over all members and for each member calls _claimableWithoutDecommissionCheck, which in turn calls _allMembersDepositedForRound (another full members loop). With no member-count cap (see redeemInvite comment), this makes isWithdrawable worst-case O(n^2) and repeatedly re-allocates/copies circleMembers[_id] into memory, which can become very expensive for large circles. Consider caching Circle/currentRound/depositAmount once and avoiding nested full-member scans (e.g., track per-round total deposits / “round funded” status, or refactor _allMembersDepositedForRound to reuse the already-loaded members array).
| address[] memory members = circleMembers[_id]; | |
| for (uint256 i = 0; i < members.length; i++) { | |
| if (_claimableWithoutDecommissionCheck(_id, members[i])) return true; | |
| } | |
| return false; | |
| Circle memory _circle = circles[_id]; | |
| uint256 currentRound = _currentRoundIndex(_circle); | |
| if (currentRound >= circleMembers[_id].length) return false; | |
| address withdrawer = circleMembers[_id][currentRound]; | |
| return _claimableWithoutDecommissionCheck(_id, withdrawer); |
There was a problem hiding this comment.
Context on isWithdrawable: we kept/expanded it as a circle-level aggregate signal (“someone can withdraw now”), while isMemberWithdrawable is the source of truth for user-level eligibility (and what the viewer now uses for canWithdraw, including late claims).
We could consider dropping/deprecating isWithdrawable, but may be a larger change for this PR (wdyt?). We want this PR merged to correctly display the claim button for late claims in the current testing round.
Then we could solve this, but not a blocker right now for our expected circle sizes (<10 members). Am I right here? @RonTuretzky
There was a problem hiding this comment.
Agree on the time complexity not being a blocker, but please make an issue for it so we don't lose track of it.
|
@RonTuretzky do you think we could merge this? bagel already approved |
RonTuretzky
left a comment
There was a problem hiding this comment.
Approving but please lets track the O(n^2) issue and I have a feeling that there are future viewer changes that need to be made to give the frontend everyone who's withdrawable at once
We need to change this in order for members to claim late because it fixes the
canWithdrawfrom the viewer.Today, late claims are valid at the contract level (
_claimableallows a member to claim after their round has passed, as long as their round was fully funded and they have not already claimed), but the viewer only exposed withdrawability for the current round withdrawer. That makescanWithdrawincorrect for members who are still eligible to claim from an earlier round.What changed
isMemberWithdrawable(uint256 id, address member)toISavingCirclesandSavingCirclesSavingCirclesViewerto computeUserCircleData.canWithdrawusingisMemberWithdrawable(circleId, user)instead ofisCurrentWithdrawer && isWithdrawable(circleId)isWithdrawable(uint256 id)to returntrueif any member in the circle is claimable (aggregate circle-level status).SavingCirclesMultiRound) to use member-specific withdrawability when deciding whether the selected recipient should withdrawNow:
viewer.canWithdrawreflects whether the user can claim, including late claimsisWithdrawableremains useful as a circle-level “someone can withdraw” signalFunctions behavior
isWithdrawable(id)trueif the circle is active and at least one member is currently claimableisMemberWithdrawable(id, member)trueif the circle is active and that specificmemberis claimable right now_claimable(id, member)falseif the member already claimed or the circle is decommissionablefalseif the member is not in the circle or their turn has not been reached yet (currentRound < memberIdx)trueonly when all deposits for that member’s round are completecurrentRound >= memberIdx(not only== memberIdx)Test update note
The fuzz test now checks
isMemberWithdrawable(circleId, recipient)before callingwithdraw, which matches the actual recipient being tested and avoids relying on the broader circle-levelisWithdrawableresult.