Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/components/create-group/BulkImport.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default function BulkImport({ onMembersChange }: BulkImportProps) {
complete: (results) => {
const parsed: Member[] = [];
const errorLines: string[] = [];
const firstLineForAddress = new Map<string, number>();
results.data.forEach((row, idx) => {
const lineNum = idx + 1;
const address = row[0]?.trim() ?? "";
Expand All @@ -46,6 +47,12 @@ export default function BulkImport({ onMembersChange }: BulkImportProps) {
errorLines.push(`Line ${lineNum}: invalid Stellar address`);
return;
}
const firstLine = firstLineForAddress.get(address);
if (firstLine !== undefined) {
errorLines.push(`Line ${lineNum}: duplicate address (already added on line ${firstLine})`);
return;
}
firstLineForAddress.set(address, lineNum);
parsed.push({ address, name, line: lineNum });
});
if (parsed.length > MAX_POOL_MEMBERS) {
Expand Down
16 changes: 10 additions & 6 deletions frontend/components/create-group/flexible-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
validateStellarAddress,
validatePositiveAmount,
validateWithdrawalFee,
findDuplicateAddresses,
} from "@/lib/form-validation"
import { MAX_POOL_MEMBERS } from "@/lib/constants"

Expand All @@ -36,7 +37,6 @@ export function FlexibleForm() {
const { address } = useStellar()
const [token, setToken] = useState<SelectedToken>({ address: "native", symbol: "XLM", decimals: 7 })
const [members, setMembers] = useState<string[]>([""])
const [memberErrors, setMemberErrors] = useState<string[]>([""])
const [error, setError] = useState("")
const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle")
const [formData, setFormData] = useState({
Expand All @@ -56,6 +56,14 @@ export function FlexibleForm() {

const allMembers = address ? [address, ...members] : members
const validMembers = Array.from(new Set(allMembers.filter(isValidStellarAddress)))
const duplicateIndices = findDuplicateAddresses(allMembers)
const memberErrors = members.map((m, i) => {
if (!m) return ""
const format = validateStellarAddress(m)
if (!format.valid) return format.message
const allMembersIndex = address ? i + 1 : i
return duplicateIndices.has(allMembersIndex) ? "Duplicate address — already in this pool's member list" : ""
})
const isCreating = step !== "idle"
const isMemberLimitReached = members.length >= MAX_POOL_MEMBERS

Expand All @@ -74,19 +82,14 @@ export function FlexibleForm() {

const updateMember = (i: number, v: string) => {
const n = [...members]; n[i] = v; setMembers(n)
const errs = [...memberErrors]
errs[i] = v ? (validateStellarAddress(v).valid ? "" : validateStellarAddress(v).message) : ""
setMemberErrors(errs)
}

const addMember = () => {
if (isMemberLimitReached) return
setMembers([...members, ""])
setMemberErrors([...memberErrors, ""])
}
const removeMember = (i: number) => {
setMembers(members.filter((_, idx) => idx !== i))
setMemberErrors(memberErrors.filter((_, idx) => idx !== i))
}

const handleSubmit = async (e: React.FormEvent) => {
Expand All @@ -104,6 +107,7 @@ export function FlexibleForm() {
})

if (!address) return setError("Please connect your wallet first")
if (duplicateIndices.size > 0) return setError("Duplicate member addresses found — please remove duplicates before continuing")
if (validMembers.length < 2) return setError("Need at least 2 valid Stellar addresses (you + 1 other)")
if (!nameResult.valid || !depositResult.valid || !feeResult.valid) return

Expand Down
23 changes: 10 additions & 13 deletions frontend/components/create-group/rotational-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
validateGroupName,
validateStellarAddress,
validatePositiveAmount,
findDuplicateAddresses,
} from "@/lib/form-validation"
import type { DuplicatePrefill } from "@/app/dashboard/create/[type]/page"
import { MAX_POOL_MEMBERS } from "@/lib/constants"
Expand Down Expand Up @@ -49,9 +50,6 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
const [members, setMembers] = useState<string[]>(
initialMembers.length > 0 ? initialMembers : [""]
)
const [memberErrors, setMemberErrors] = useState<string[]>(
initialMembers.length > 0 ? initialMembers.map(() => "") : [""]
)
const [error, setError] = useState("")
const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle")
const [formData, setFormData] = useState({
Expand All @@ -76,6 +74,14 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
// Always include creator as first member
const allMembers = address ? [address, ...members] : members
const validMembers = Array.from(new Set(allMembers.filter(isValidStellarAddress)))
const duplicateIndices = findDuplicateAddresses(allMembers)
const memberErrors = members.map((m, i) => {
if (!m) return ""
const format = validateStellarAddress(m)
if (!format.valid) return format.message
const allMembersIndex = address ? i + 1 : i
return duplicateIndices.has(allMembersIndex) ? "Duplicate address — already in this pool's member list" : ""
})
const isCreating = step !== "idle"
const isMemberLimitReached = members.length >= MAX_POOL_MEMBERS

Expand All @@ -94,25 +100,15 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
const next = [...members]
next[i] = v
setMembers(next)
const errs = [...memberErrors]
if (v) {
const r = validateStellarAddress(v)
errs[i] = r.valid ? "" : r.message
} else {
errs[i] = ""
}
setMemberErrors(errs)
}

const addMember = () => {
if (isMemberLimitReached) return
setMembers([...members, ""])
setMemberErrors([...memberErrors, ""])
}

const removeMember = (i: number) => {
setMembers(members.filter((_, idx) => idx !== i))
setMemberErrors(memberErrors.filter((_, idx) => idx !== i))
}

const handleSubmit = async (e: React.FormEvent) => {
Expand All @@ -129,6 +125,7 @@ export function RotationalForm({ prefill }: { prefill?: DuplicatePrefill }) {
})

if (!address) return setError("Please connect your wallet first")
if (duplicateIndices.size > 0) return setError("Duplicate member addresses found — please remove duplicates before continuing")
if (validMembers.length < 2) return setError("Need at least 2 valid Stellar addresses (you + 1 other)")
if (!nameResult.valid || !amountResult.valid) return

Expand Down
16 changes: 10 additions & 6 deletions frontend/components/create-group/target-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
validateStellarAddress,
validatePositiveAmount,
validateDeadline,
findDuplicateAddresses,
} from "@/lib/form-validation"
import { MAX_POOL_MEMBERS } from "@/lib/constants"

Expand All @@ -44,7 +45,6 @@ export function TargetForm() {
const { address } = useStellar()
const [token, setToken] = useState<SelectedToken>({ address: "native", symbol: "XLM", decimals: 7 })
const [members, setMembers] = useState<string[]>([""])
const [memberErrors, setMemberErrors] = useState<string[]>([""])
const [error, setError] = useState("")
const [step, setStep] = useState<"idle" | "deploying" | "initializing" | "registering" | "saving">("idle")
const [formData, setFormData] = useState({ name: "", description: "", targetAmount: "", deadline: "" })
Expand All @@ -62,6 +62,14 @@ export function TargetForm() {

const allMembers = address ? [address, ...members] : members
const validMembers = Array.from(new Set(allMembers.filter(isValidStellarAddress)))
const duplicateIndices = findDuplicateAddresses(allMembers)
const memberErrors = members.map((m, i) => {
if (!m) return ""
const format = validateStellarAddress(m)
if (!format.valid) return format.message
const allMembersIndex = address ? i + 1 : i
return duplicateIndices.has(allMembersIndex) ? "Duplicate address — already in this pool's member list" : ""
})
const isCreating = step !== "idle"
const isMemberLimitReached = members.length >= MAX_POOL_MEMBERS

Expand All @@ -80,19 +88,14 @@ export function TargetForm() {

const updateMember = (i: number, v: string) => {
const next = [...members]; next[i] = v; setMembers(next)
const errs = [...memberErrors]
errs[i] = v ? (validateStellarAddress(v).valid ? "" : validateStellarAddress(v).message) : ""
setMemberErrors(errs)
}

const addMember = () => {
if (isMemberLimitReached) return
setMembers([...members, ""])
setMemberErrors([...memberErrors, ""])
}
const removeMember = (i: number) => {
setMembers(members.filter((_, idx) => idx !== i))
setMemberErrors(memberErrors.filter((_, idx) => idx !== i))
}

const handleSubmit = async (e: React.FormEvent) => {
Expand All @@ -110,6 +113,7 @@ export function TargetForm() {
})

if (!address) return setError("Please connect your wallet first")
if (duplicateIndices.size > 0) return setError("Duplicate member addresses found — please remove duplicates before continuing")
if (validMembers.length < 2) return setError("Need at least 2 valid Stellar addresses (you + 1 other)")
if (!nameResult.valid || !amountResult.valid || !deadlineResult.valid) return

Expand Down
38 changes: 38 additions & 0 deletions frontend/lib/form-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Unit tests for member-address validation, focused on duplicate detection.
import { test } from 'node:test';
import assert from 'node:assert';
import { findDuplicateAddresses, validateStellarAddress } from './form-validation';

const A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA';
const C = 'GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCA';

test('findDuplicateAddresses - no duplicates returns an empty set', () => {
const result = findDuplicateAddresses([A, B, C]);
assert.strictEqual(result.size, 0);
});

test('findDuplicateAddresses - flags both the first and later occurrence', () => {
const result = findDuplicateAddresses([A, B, A]);
assert.deepStrictEqual(result, new Set([0, 2]));
});

test('findDuplicateAddresses - flags every member of a repeated group', () => {
const result = findDuplicateAddresses([A, A, A]);
assert.deepStrictEqual(result, new Set([0, 1, 2]));
});

test('findDuplicateAddresses - ignores empty entries (covered by other validation)', () => {
const result = findDuplicateAddresses(['', A, '', B]);
assert.strictEqual(result.size, 0);
});

test('findDuplicateAddresses - trims whitespace before comparing', () => {
const result = findDuplicateAddresses([A, ` ${A} `]);
assert.deepStrictEqual(result, new Set([0, 1]));
});

test('validateStellarAddress - still rejects malformed addresses independently of duplicate checks', () => {
assert.strictEqual(validateStellarAddress('not-an-address').valid, false);
assert.strictEqual(validateStellarAddress(A).valid, true);
});
23 changes: 23 additions & 0 deletions frontend/lib/form-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,26 @@ export function validateWithdrawalFee(value: string): ValidationResult {
if (num > 10) return err("Fee cannot exceed 10%")
return ok
}

/**
* Returns the indices of entries in `addresses` that share a value with at
* least one other entry (both the first and later occurrences are flagged,
* so every duplicate row can show an inline error). Empty entries are
* ignored since they're caught by other field validation.
*/
export function findDuplicateAddresses(addresses: string[]): Set<number> {
const firstSeenAt = new Map<string, number>()
const duplicates = new Set<number>()
addresses.forEach((raw, i) => {
const value = raw.trim()
if (!value) return
const seenAt = firstSeenAt.get(value)
if (seenAt !== undefined) {
duplicates.add(seenAt)
duplicates.add(i)
} else {
firstSeenAt.set(value, i)
}
})
return duplicates
}
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"dev": "next dev --webpack",
"lint": "next lint",
"start": "next start",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts hooks/use-keyboard-shortcuts.test.ts",
"test:unit": "tsx --test lib/csv-export.test.ts lib/analytics.test.ts lib/pool-health.test.ts lib/form-validation.test.ts hooks/use-keyboard-shortcuts.test.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:report": "playwright show-report"
Expand Down
15 changes: 15 additions & 0 deletions smartcontract/contracts/flexible/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ impl FlexiblePool {
treasury_fee_bps: u32,
) {
assert!(members.len() >= 2, "need >=2 members");
assert!(!Self::has_duplicate_members(&members), "duplicate member address");
assert!(minimum_deposit > 0, "minimum must be > 0");

// Validate the token is a real SEP-41 contract by reading its decimals
Expand Down Expand Up @@ -487,6 +488,20 @@ impl FlexiblePool {
}
false
}

/// O(n^2) pairwise scan — member lists are small, so this is cheaper
/// than maintaining a separate index just to dedupe at init time.
fn has_duplicate_members(members: &Vec<Address>) -> bool {
for i in 0..members.len() {
let a = members.get(i).unwrap();
for j in (i + 1)..members.len() {
if a == members.get(j).unwrap() {
return true;
}
}
}
false
}
}

#[cfg(test)]
Expand Down
37 changes: 37 additions & 0 deletions smartcontract/contracts/flexible/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,43 @@ fn test_token_decimals_recorded() {
assert_eq!(client.token_decimals(), 7);
}

#[test]
#[should_panic(expected = "duplicate member address")]
fn test_initialize_rejects_duplicate_member() {
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register_contract(None, FlexiblePool);
let client = FlexiblePoolClient::new(&env, &contract_id);

let token_admin = Address::generate(&env);
let token_contract = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_address = token_contract.address();

let admin = Address::generate(&env);
let treasury = Address::generate(&env);
let member_a = Address::generate(&env);
let member_b = Address::generate(&env);

// member_a appears twice — distribute_yield iterates the raw members
// vec, so a duplicate slot would grant member_a a double yield share.
let mut members = Vec::new(&env);
members.push_back(member_a.clone());
members.push_back(member_b.clone());
members.push_back(member_a.clone());

client.initialize(
&token_address,
&admin,
&members,
&10i128,
&0u32,
&false,
&treasury,
&0u32,
);
}

#[test]
#[should_panic(expected = "below minimum deposit")]
fn test_minimum_deposit_rejection() {
Expand Down
16 changes: 16 additions & 0 deletions smartcontract/contracts/rotational/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ impl RotationalPool {
treasury: Address,
) {
assert!(members.len() >= 2, "need >=2 members");
assert!(!Self::has_duplicate_members(&members), "duplicate member address");
assert!(deposit_amount > 0, "deposit must be > 0");
assert!(round_duration > 0, "round_duration must be > 0");

Expand Down Expand Up @@ -446,6 +447,21 @@ impl RotationalPool {
false
}

/// O(n^2) pairwise scan — member lists are small (capped well below
/// the resource limits that would make this costly), so this is cheaper
/// than maintaining a separate index just to dedupe at init time.
fn has_duplicate_members(members: &Vec<Address>) -> bool {
for i in 0..members.len() {
let a = members.get(i).unwrap();
for j in (i + 1)..members.len() {
if a == members.get(j).unwrap() {
return true;
}
}
}
false
}

fn member_index(members: &Vec<Address>, who: &Address) -> Option<u32> {
let mut index = 0u32;
for m in members.iter() {
Expand Down
Loading
Loading