fix: reject duplicate member addresses across forms and pool contracts#158
Merged
Sendi0011 merged 2 commits intoJun 28, 2026
Merged
Conversation
initialize() on the rotational/target/flexible pools had no uniqueness check on the members list (only add_member did), so a duplicate Stellar address could be stored twice. For Rotational this lets one address claim two round-robin payout slots and inflates trigger_payout's deposit_count (since it's tallied per-slot against a shared HasDeposited(address) flag), overpaying the round beneficiary beyond what was actually deposited. For Flexible, distribute_yield iterates the raw members vec, so a duplicate slot grants that address two yield shares per distribution. The reputation tracker keys everything by address so it has no duplicate-storage bug itself, but it has no idempotency guard either — Rotational's per-slot report_missed_round/report_payout calls would double-count for a duplicated address, so closing the gap at initialize fixes that too. Add a has_duplicate_members guard to initialize on all three pool types, mirroring add_member's existing check. Add client-side duplicate detection (findDuplicateAddresses) wired into all three creation forms and BulkImport.tsx so duplicates show a clear inline error and block submission before they ever reach the chain. Closes JointSave-org#143
PR JointSave-org#156 (merged separately) added MAX_POOL_MEMBERS and an isMemberLimitReached guard to addMember in the same forms this branch touches. Kept that guard and dropped the now-removed setMemberErrors plumbing it referenced in target-form.tsx and flexible-form.tsx.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #143
Investigation: on-chain behavior before this fix
add_memberalready guarded against duplicates on all three pool contracts (assert!(!Self::is_member(&members, &new_member), "already a member")), butinitializehad no such check — it only assertedmembers.len() >= 2and stored whateverVec<Address>it was given verbatim. A caller bypassing the (unvalidated) frontend — or hitting the contract directly — could initialize a pool with the same address listed twice. Concretely, per pool type:trigger_payoutindexes the beneficiary asmembers.get(current_round), so a duplicate address occupying two slots claims two separate payout rounds, exactly the round-robin skew described in the issue. It's worse than just unfair:deposit_countis tallied by iterating the rawmembersvec and checking a sharedHasDeposited(address)flag per slot — so a duplicated address that deposits once gets counted twice towarddeposit_count.total_collected = deposit_amount * deposit_countthen overstates what was actually transferred into the contract for that round, which can overpay the round's beneficiary beyond the pool's real balance.distribute_yielditerates the rawmembersvec and credits a proportional yield share toBalance(member)for every slot. A duplicate address would receive its proportional share twice per distribution call.Balance(Address)), so a duplicate slot doesn't directly double-pay there, but it still leaves inconsistent state (wasted storage, an inflatedmembers.len()) that's safer to reject at the same layer for consistency with the other two pool types.Address, not by pool slot, so it has no duplicate-storage bug of its own. But it has no idempotency guard on the calls it receives either — it trusts the reporting pool. In Rotational,report_missed_roundfires once per slot inmissed_members, andreport_payout/record_payout_receivedfire once per round; with a duplicated address occupying two member slots, both would fire twice for the same person in normal operation, double-incrementingmissed_rounds/rounds_tracked/pools_completed. Rather than add defensive idempotency checks insidereputation/src/lib.rs(which would mask the real bug), this PR closes the gap at the source in each pool'sinitialize.Fix
Contracts (
rotational,target,flexible—src/lib.rs): added ahas_duplicate_membershelper (O(n²) pairwise scan — member lists are small, so this is cheaper than maintaining a separate index just to dedupe once at init) and wired it intoinitializeright after the existingmembers.len() >= 2check, mirroringadd_member's existing guard:Frontend: added
findDuplicateAddresses(addresses: string[]): Set<number>tolib/form-validation.ts, which flags every index that shares a (trimmed) value with another entry. Wired into:rotational-form.tsx,target-form.tsx,flexible-form.tsx— duplicate detection runs across the creator's own address + all manually entered member rows, so typing the creator's own address (or a row you already added) shows an inline "Duplicate address" error on the affected row(s), and submission is blocked with a clear top-level error if any duplicates remain.BulkImport.tsx— duplicate rows within an imported CSV are now flagged by line number (Line N: duplicate address (already added on line M)) and excluded from the imported member list, instead of silently passing through.The previous
Array.from(new Set(...))dedup before submission is left in place as a defense-in-depth fallback, but duplicates are now caught and surfaced before that point, with a message the user can act on.Tests
rotational/src/tests.rs,target/src/tests.rs,flexible/src/tests.rs: addedtest_initialize_rejects_duplicate_member(#[should_panic(expected = "duplicate member address")]) to each.frontend/lib/form-validation.test.ts(new): unit tests forfindDuplicateAddresses(no duplicates, both-occurrences flagged, full-group duplicates, blank entries ignored, whitespace trimming).Verification
cargo test(fullsmartcontractworkspace, run under WSL — native Windowscargo build/testis blocked by an Application Control policy on my machine, unrelated to this change): all passing, including the 3 new tests.cargo build --target wasm32-unknown-unknown --releaseforrotational,target,flexible: all build cleanly.pnpm test:unit(frontend): 40/40 passing, including the 5 newfindDuplicateAddressestests.tsc --noEmit: no new type errors. (2 pre-existing errors onmainunrelated to this change:TargetForm/FlexibleFormdon't declare aprefillprop thatapp/dashboard/create/[type]/page.tsxpasses them.)pnpm run lint/next lint: currently broken onmainunder this Next 16 setup (thenext lintsubcommand was removed) — pre-existing and unrelated, could not run ESLint.