Skip to content
Open
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
13 changes: 13 additions & 0 deletions frontend/src/__tests__/components.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,19 @@ describe('RecipientStep', () => {
fireEvent.blur(screen.getByRole('textbox'));
expect(onChange).toHaveBeenCalledWith(VALID_KEY);
});

it('shows immediate inline error when recipient equals connected sender wallet address', () => {
render(
<RecipientStep
value={` ${VALID_KEY.toLowerCase()} `}
onChange={vi.fn()}
senderAddress={VALID_KEY}
/>,
);
expect(screen.getByRole('alert')).toHaveTextContent(
'Recipient address cannot be the same as your connected sender wallet',
);
});
});

// ─── AmountStep ──────────────────────────────────────────────────────────────
Expand Down
29 changes: 25 additions & 4 deletions frontend/src/components/stream-creation/RecipientStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,52 @@ interface RecipientStepProps {
value: string;
onChange: (value: string) => void;
error?: string;
senderAddress?: string | null;
}

const STELLAR_PUBLIC_KEY_LENGTH = 56;

function getPersistedWalletPublicKey(): string | undefined {
if (typeof window === "undefined") return undefined;
try {
const raw = window.localStorage.getItem("flowfi.wallet.session.v1");
if (!raw) return undefined;
const parsed = JSON.parse(raw) as { publicKey?: unknown };
return typeof parsed?.publicKey === "string" ? parsed.publicKey : undefined;
} catch {
return undefined;
}
}

export const RecipientStep: React.FC<RecipientStepProps> = ({
value,
onChange,
error,
senderAddress,
}) => {
const inputRef = useRef<HTMLInputElement>(null);
const [touched, setTouched] = useState(false);
const effectiveSenderAddress = senderAddress ?? getPersistedWalletPublicKey();

// Auto-focus on mount
useEffect(() => {
inputRef.current?.focus();
}, []);

const trimmed = value.trim();
const normalizedSender = effectiveSenderAddress?.trim();
const isSameAsSender =
Boolean(normalizedSender) &&
trimmed.length > 0 &&
trimmed.toLowerCase() === normalizedSender!.toLowerCase();

// Real-time feedback: validate once the user has committed the field
// (blur / paste) or entered a full-length key, so partial typing does not
// flash errors while still catching bad checksums immediately.
// (blur / paste), entered a full-length key, or matched the connected sender,
// so partial typing does not flash errors while still catching bad checksums
// and self-streams immediately.
const liveError =
touched || trimmed.length >= STELLAR_PUBLIC_KEY_LENGTH
? validateRecipient(value)
touched || trimmed.length >= STELLAR_PUBLIC_KEY_LENGTH || isSameAsSender
? validateRecipient(value, effectiveSenderAddress)
: null;

// The wizard-provided error (set on step validation) takes precedence.
Expand Down
22 changes: 19 additions & 3 deletions frontend/src/components/stream-creation/StreamCreationWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import {
useStreamForm,
type StreamFormData,
} from "@/hooks/useStreamForm";
import { validateAmount } from "@/lib/stream-validation";
import {
SAME_SENDER_RECIPIENT_ERROR,
validateAmount,
} from "@/lib/stream-validation";

// Re-export StreamFormData so existing imports keep working
export type { StreamFormData };
Expand Down Expand Up @@ -97,6 +100,15 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
const amountValid = amountError === null;
const canSubmit = !isSubmitting && amountValid;

const isSelfRecipient =
Boolean(walletPublicKey?.trim()) &&
formData.recipient.trim().length > 0 &&
formData.recipient.trim().toLowerCase() ===
walletPublicKey!.trim().toLowerCase();
const recipientError =
errors.recipient ??
(isSelfRecipient ? SAME_SENDER_RECIPIENT_ERROR : undefined);

const handleApplyTemplate = (templateId: string) => {
const msg = applyTemplateHook(templateId);
if (msg) setTemplateSaveMessage(msg);
Expand Down Expand Up @@ -260,7 +272,8 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
<RecipientStep
value={formData.recipient}
onChange={(value) => updateFormData({ recipient: value })}
error={errors.recipient}
error={recipientError}
senderAddress={walletPublicKey}
/>
);
case 3:
Expand Down Expand Up @@ -469,7 +482,10 @@ export const StreamCreationWizard: React.FC<StreamCreationWizardProps> = ({
Cancel
</Button>
{currentStep < STEPS.length ? (
<Button onClick={handleNext}>
<Button
onClick={handleNext}
disabled={currentStep === 2 && Boolean(recipientError)}
>
Next
<svg className="w-4 h-4 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,9 @@ import { fetchTokenBalanceDisplay } from "@/lib/soroban";
// Valid Stellar Ed25519 public key (correct StrKey checksum)
const VALID_KEY = "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7";

// Distinct valid Stellar Ed25519 public key for the connected sender wallet
const SENDER_KEY = "GA3JE5IXBSOR6DCLZSGN7JIWQWO45RCS7PUFKKVXWSTE4Y75ISIDMHJG";

// A different recipient, used to make sure polling does NOT pick it.
const OTHER_KEY = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";

Expand Down Expand Up @@ -223,7 +226,7 @@ function renderWizard(overrides: Partial<React.ComponentProps<typeof StreamCreat
<StreamCreationWizard
onClose={onClose}
onSubmit={onSubmit}
walletPublicKey={VALID_KEY}
walletPublicKey={SENDER_KEY}
{...overrides}
/>
);
Expand Down Expand Up @@ -352,6 +355,35 @@ describe("StreamCreationWizard", () => {
);
});

it("shows inline error, disables Next, and blocks advancing on step 2 when recipient matches connected sender wallet", () => {
renderWizard({ walletPublicKey: SENDER_KEY });
clickNext(); // 1 -> 2
fireEvent.change(screen.getByLabelText("Recipient Address"), {
target: { value: SENDER_KEY },
});

expect(screen.getByRole("alert")).toHaveTextContent(
"Recipient address cannot be the same as your connected sender wallet",
);
expect(screen.getByText("Next").closest("button")).toBeDisabled();

clickNext();
expect(screen.getByText("Step 2 of 5")).toBeInTheDocument();
});

it("blocks recipient matching connected sender wallet case-insensitively and with surrounding whitespace", () => {
renderWizard({ walletPublicKey: SENDER_KEY });
clickNext(); // 1 -> 2
fireEvent.change(screen.getByLabelText("Recipient Address"), {
target: { value: ` ${SENDER_KEY.toLowerCase()} ` },
});

expect(screen.getByRole("alert")).toHaveTextContent(
"Recipient address cannot be the same as your connected sender wallet",
);
expect(screen.getByText("Next").closest("button")).toBeDisabled();
});

it("advances past step 2 with a valid recipient", () => {
renderWizard();
clickNext();
Expand Down Expand Up @@ -711,7 +743,7 @@ describe("StreamCreationWizard", () => {
<StreamCreationWizard
onClose={vi.fn()}
onSubmit={onSubmit}
walletPublicKey={VALID_KEY}
walletPublicKey={SENDER_KEY}
/>
);

Expand Down Expand Up @@ -746,7 +778,7 @@ describe("StreamCreationWizard", () => {
<StreamCreationWizard
onClose={vi.fn()}
onSubmit={onSubmit}
walletPublicKey={VALID_KEY}
walletPublicKey={SENDER_KEY}
/>
);

Expand Down Expand Up @@ -785,7 +817,7 @@ describe("StreamCreationWizard", () => {
<StreamCreationWizard
onClose={vi.fn()}
onSubmit={onSubmit}
walletPublicKey={VALID_KEY}
walletPublicKey={SENDER_KEY}
/>
);

Expand All @@ -810,7 +842,7 @@ describe("StreamCreationWizard", () => {
<StreamCreationWizard
onClose={vi.fn()}
onSubmit={onSubmit}
walletPublicKey={VALID_KEY}
walletPublicKey={SENDER_KEY}
/>
);

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/hooks/useStreamForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,11 +424,12 @@ export function useStreamForm(
const newErrors = validateStreamForm(formData, {
step,
walletBalance,
senderAddress: walletPublicKey,
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
},
[formData, walletBalance],
[formData, walletBalance, walletPublicKey],
);

const validateAll = useCallback((): boolean => {
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/__tests__/stream-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,28 @@ describe("validateRecipient", () => {
it("accepts a valid key padded with mixed whitespace", () => {
expect(validateRecipient(`\n\t ${VALID_KEY} \t`)).toBeNull();
});

it("rejects recipient identical to connected sender wallet address", () => {
expect(validateRecipient(VALID_KEY, VALID_KEY)).toBe(
"Recipient address cannot be the same as your connected sender wallet",
);
});

it("rejects recipient matching connected sender wallet case-insensitively and with surrounding whitespace", () => {
expect(validateRecipient(` ${VALID_KEY.toLowerCase()} `, ` ${VALID_KEY} `)).toBe(
"Recipient address cannot be the same as your connected sender wallet",
);
});

it("propagates senderAddress check through validateStreamForm on step 2", () => {
const errors = validateStreamForm(VALID_FORM, {
step: 2,
senderAddress: VALID_KEY,
});
expect(errors.recipient).toBe(
"Recipient address cannot be the same as your connected sender wallet",
);
});
});

// ─── validateToken ────────────────────────────────────────────────────────────
Expand Down
17 changes: 15 additions & 2 deletions frontend/src/lib/stream-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ export function getSpendableBalance(

export type StreamFormErrors = Partial<Record<keyof StreamFormData, string>>;

export const SAME_SENDER_RECIPIENT_ERROR =
"Recipient address cannot be the same as your connected sender wallet";

export interface ValidationOptions {
/**
* When provided, only validate the fields relevant to the given wizard step:
Expand All @@ -84,17 +87,27 @@ export interface ValidationOptions {
step?: number;
/** Display-formatted wallet balance for the selected token (e.g. "1000.5") */
walletBalance?: string | null;
/** Connected sender wallet Stellar public key to prevent self-streaming */
senderAddress?: string | null;
}

// ─── Individual field validators ──────────────────────────────────────────────

export function validateRecipient(
recipient: string,
senderAddress?: string | null,
): string | null {
const trimmed = recipient.trim();
if (!trimmed) {
return "Recipient address is required";
}
const normalizedSender = senderAddress?.trim();
if (
normalizedSender &&
trimmed.toLowerCase() === normalizedSender.toLowerCase()
) {
return SAME_SENDER_RECIPIENT_ERROR;
}
if (!isValidStellarPublicKey(trimmed)) {
return "Invalid Stellar public key (must start with 'G' and be 56 characters)";
}
Expand Down Expand Up @@ -170,7 +183,7 @@ export function validateStreamForm(
data: StreamFormData,
options: ValidationOptions = {},
): StreamFormErrors {
const { step, walletBalance } = options;
const { step, walletBalance, senderAddress } = options;
const errors: StreamFormErrors = {};

const shouldValidate = (
Expand All @@ -180,7 +193,7 @@ export function validateStreamForm(
// Step 1 = Template selection β€” no field validation required
// Step 2 = Recipient
if (shouldValidate(2)) {
const recipientError = validateRecipient(data.recipient);
const recipientError = validateRecipient(data.recipient, senderAddress);
if (recipientError) errors.recipient = recipientError;
}

Expand Down
Loading