Skip to content

fix: reject duplicate member addresses across forms and pool contracts#158

Merged
Sendi0011 merged 2 commits into
JointSave-org:mainfrom
JuliobaCR:fix/duplicate-member-prevention
Jun 28, 2026
Merged

fix: reject duplicate member addresses across forms and pool contracts#158
Sendi0011 merged 2 commits into
JointSave-org:mainfrom
JuliobaCR:fix/duplicate-member-prevention

Conversation

@JuliobaCR

Copy link
Copy Markdown
Contributor

Closes #143

Investigation: on-chain behavior before this fix

add_member already guarded against duplicates on all three pool contracts (assert!(!Self::is_member(&members, &new_member), "already a member")), but initialize had no such check — it only asserted members.len() >= 2 and stored whatever Vec<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:

  • Rotationaltrigger_payout indexes the beneficiary as members.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_count is tallied by iterating the raw members vec and checking a shared HasDeposited(address) flag per slot — so a duplicated address that deposits once gets counted twice toward deposit_count. total_collected = deposit_amount * deposit_count then overstates what was actually transferred into the contract for that round, which can overpay the round's beneficiary beyond the pool's real balance.
  • Flexibledistribute_yield iterates the raw members vec and credits a proportional yield share to Balance(member) for every slot. A duplicate address would receive its proportional share twice per distribution call.
  • Target — deposit/withdraw/refund balances are keyed by address (Balance(Address)), so a duplicate slot doesn't directly double-pay there, but it still leaves inconsistent state (wasted storage, an inflated members.len()) that's safer to reject at the same layer for consistency with the other two pool types.
  • Reputation tracker — all of its storage is keyed by 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_round fires once per slot in missed_members, and report_payout/record_payout_received fire once per round; with a duplicated address occupying two member slots, both would fire twice for the same person in normal operation, double-incrementing missed_rounds/rounds_tracked/pools_completed. Rather than add defensive idempotency checks inside reputation/src/lib.rs (which would mask the real bug), this PR closes the gap at the source in each pool's initialize.

Fix

Contracts (rotational, target, flexiblesrc/lib.rs): added a has_duplicate_members helper (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 into initialize right after the existing members.len() >= 2 check, mirroring add_member's existing guard:

assert!(!Self::has_duplicate_members(&members), "duplicate member address");

Frontend: added findDuplicateAddresses(addresses: string[]): Set<number> to lib/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: added test_initialize_rejects_duplicate_member (#[should_panic(expected = "duplicate member address")]) to each.
  • frontend/lib/form-validation.test.ts (new): unit tests for findDuplicateAddresses (no duplicates, both-occurrences flagged, full-group duplicates, blank entries ignored, whitespace trimming).

Verification

  • cargo test (full smartcontract workspace, run under WSL — native Windows cargo build/test is 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 --release for rotational, target, flexible: all build cleanly.
  • pnpm test:unit (frontend): 40/40 passing, including the 5 new findDuplicateAddresses tests.
  • tsc --noEmit: no new type errors. (2 pre-existing errors on main unrelated to this change: TargetForm/FlexibleForm don't declare a prefill prop that app/dashboard/create/[type]/page.tsx passes them.)
  • pnpm run lint / next lint: currently broken on main under this Next 16 setup (the next lint subcommand was removed) — pre-existing and unrelated, could not run ESLint.

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
@JuliobaCR JuliobaCR closed this Jun 28, 2026
@JuliobaCR JuliobaCR reopened this Jun 28, 2026
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.
@Sendi0011 Sendi0011 merged commit 17fc810 into JointSave-org:main Jun 28, 2026
3 checks passed
@Sendi0011 Sendi0011 self-requested a review June 28, 2026 19:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Add duplicate member detection and prevention across the member list and reputation system

2 participants