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
15 changes: 14 additions & 1 deletion src/components/CreateStreamModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,18 @@ export default function CreateStreamModal({
setError(t("createStream.validation.recipientRequired"));
return false;
}
if (!isValidStellarAddress(recipient.trim())) {
const normalizedRecipient = recipient.trim();

/**
* Self-send rule: Reject streams where the recipient equals the connected wallet address.
* This prevents users from wasting a deposit on a no-op transfer to themselves.
*/
if (wallet.connected && wallet.address && normalizedRecipient.toLowerCase() === wallet.address.toLowerCase()) {
setError("Recipient cannot be the same as the connected wallet address.");
return false;
}

if (!isValidStellarAddress(normalizedRecipient)) {
setError(
t("createStream.validation.recipientInvalid"),
);
Expand Down Expand Up @@ -509,6 +520,8 @@ export default function CreateStreamModal({
const recipientError = touched.recipient
? (!recipient.trim()
? t("createStream.validation.recipientRequired")
: (wallet.connected && wallet.address && recipient.trim().toLowerCase() === wallet.address.toLowerCase())
? 'Recipient cannot be the same as the connected wallet address.'
: !isValidStellarAddress(recipient.trim())
? t("createStream.validation.recipientInvalid")
: undefined)
Expand Down
126 changes: 126 additions & 0 deletions src/components/__tests__/CreateStreamModal.recipient.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect, vi } from 'vitest';
import { render, fireEvent, within } from '@testing-library/react';
import CreateStreamModal from '../CreateStreamModal';
import * as WalletContext from '../wallet-connect/Walletcontext';
import { ToastProvider } from '../toast/ToastProvider';

const VALID_STELLAR = 'GATDOSCZNJ5YZHNOX7IOD4QDCQSTMR2YNF5IXHFNX3H6B4ICCMSDLOWN';

// Mock the wallet hook
vi.mock('../wallet-connect/Walletcontext', () => ({
useWallet: vi.fn(),
}));

function renderModal() {
return render(
<ToastProvider>
<CreateStreamModal isOpen={true} onClose={() => {}} />
</ToastProvider>
);
}

describe('CreateStreamModal: Self-send validation', () => {
it('rejects recipient when it exactly matches connected wallet address', () => {
vi.mocked(WalletContext.useWallet).mockReturnValue({
connected: true,
address: VALID_STELLAR,
network: 'TESTNET',
connect: vi.fn(),
disconnect: vi.fn(),
});

const { container } = renderModal();
const recipientInput = container.querySelector('#create-stream-recipient') as HTMLInputElement;
const nextBtn = within(container).getByRole('button', { name: /^next$/i });

// Fill valid deposit
const depositInput = container.querySelector('#create-stream-deposit') as HTMLInputElement;
fireEvent.change(depositInput, { target: { value: '100' } });

// Fill recipient with the same address
fireEvent.change(recipientInput, { target: { value: VALID_STELLAR } });
fireEvent.blur(recipientInput);

fireEvent.click(nextBtn);

// Verify error is shown
expect(container.textContent).toContain('Recipient cannot be the same as the connected wallet address.');

// Cannot advance to step 2
expect(container.querySelector('.step-item.active')?.textContent).toContain('1');
});

it('rejects recipient when it matches connected wallet address with different casing', () => {
vi.mocked(WalletContext.useWallet).mockReturnValue({
connected: true,
address: VALID_STELLAR,
network: 'TESTNET',
connect: vi.fn(),
disconnect: vi.fn(),
});

const { container } = renderModal();
const recipientInput = container.querySelector('#create-stream-recipient') as HTMLInputElement;
const nextBtn = within(container).getByRole('button', { name: /^next$/i });

const depositInput = container.querySelector('#create-stream-deposit') as HTMLInputElement;
fireEvent.change(depositInput, { target: { value: '100' } });

// Lowercase recipient
fireEvent.change(recipientInput, { target: { value: VALID_STELLAR.toLowerCase() } });
fireEvent.blur(recipientInput);
fireEvent.click(nextBtn);

expect(container.textContent).toContain('Recipient cannot be the same as the connected wallet address.');
});

it('rejects recipient when it matches connected wallet address with leading/trailing whitespace', () => {
vi.mocked(WalletContext.useWallet).mockReturnValue({
connected: true,
address: VALID_STELLAR,
network: 'TESTNET',
connect: vi.fn(),
disconnect: vi.fn(),
});

const { container } = renderModal();
const recipientInput = container.querySelector('#create-stream-recipient') as HTMLInputElement;
const nextBtn = within(container).getByRole('button', { name: /^next$/i });

const depositInput = container.querySelector('#create-stream-deposit') as HTMLInputElement;
fireEvent.change(depositInput, { target: { value: '100' } });

// Whitespace recipient
fireEvent.change(recipientInput, { target: { value: ` ${VALID_STELLAR} ` } });
fireEvent.blur(recipientInput);
fireEvent.click(nextBtn);

expect(container.textContent).toContain('Recipient cannot be the same as the connected wallet address.');
});

it('does not block step 1 if the wallet is disconnected, even with same address', () => {
vi.mocked(WalletContext.useWallet).mockReturnValue({
connected: false,
address: null,
network: null,
connect: vi.fn(),
disconnect: vi.fn(),
});

const { container } = renderModal();
const recipientInput = container.querySelector('#create-stream-recipient') as HTMLInputElement;
const nextBtn = within(container).getByRole('button', { name: /^next$/i });

const depositInput = container.querySelector('#create-stream-deposit') as HTMLInputElement;
fireEvent.change(depositInput, { target: { value: '100' } });

// Since disconnected, any valid address shouldn't trigger the self-send rule
fireEvent.change(recipientInput, { target: { value: VALID_STELLAR } });
fireEvent.blur(recipientInput);
fireEvent.click(nextBtn);

expect(container.textContent).not.toContain('Recipient cannot be the same as the connected wallet address.');
// Should advance to step 2 (nth-child(3))
expect(container.querySelector('.step-item:nth-child(3)')?.classList.contains('active')).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { createStream, getTransactionStatus } from "../../lib/stellar/tx";

vi.mock("../wallet-connect/Walletcontext", () => ({
useWallet: () => ({
address: "GATDOSCZNJ5YZHNOX7IOD4QDCQSTMR2YNF5IXHFNX3H6B4ICCMSDLOWN",
address: "GDBWW22BDP5HN3ZTG7LLID665PA72DGOLOONLUM5TKQFRAQA3EYGKIRC",
network: "TESTNET",
connected: true,
connect: vi.fn(),
Expand Down Expand Up @@ -147,7 +147,7 @@ describe("CreateStreamModal transaction confirmation", () => {
const expectedStart = Math.floor(new Date("2026-06-20T12:00:00").getTime() / 1000);
const expectedCliff = Math.floor(new Date("2026-06-21T15:00").getTime() / 1000);

expect(callArgs[0]).toBe("GATDOSCZNJ5YZHNOX7IOD4QDCQSTMR2YNF5IXHFNX3H6B4ICCMSDLOWN");
expect(callArgs[0]).toBe("GDBWW22BDP5HN3ZTG7LLID665PA72DGOLOONLUM5TKQFRAQA3EYGKIRC");
expect(callArgs[1]).toBe(VALID_STELLAR);
expect(callArgs[2]).toBe("1000000000"); // 100 USDC * 10^7
expect(callArgs[3]).toBe(expectedStart);
Expand Down