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
119 changes: 119 additions & 0 deletions app/create/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,33 @@ vi.mock('next/navigation', () => ({
}));

const mockCreateStream = vi.fn();
// Defaults to demo/mock mode so the pre-existing zero-rate-guard tests below
// (which predate the #218 allowance step) don't need to know about it.
const mockIsMock = vi.fn(() => true);
vi.mock('@/lib/factory', () => ({
createStream: (...args: unknown[]) => mockCreateStream(...args),
isMock: () => mockIsMock(),
}));

const mockRefreshStreamData = vi.fn();
vi.mock('@/lib/queryClient', () => ({
refreshStreamData: (...args: unknown[]) => mockRefreshStreamData(...args),
}));

const FACTORY_ID = 'CFACTORYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
vi.mock('@/lib/env', () => ({
getFactoryContractId: () => FACTORY_ID,
}));

const mockCheckAllowance = vi.fn();
const mockApprove = vi.fn();
vi.mock('@/lib/token-allowance-gateway', () => ({
getTokenAllowanceGateway: () => ({
checkAllowance: (...args: unknown[]) => mockCheckAllowance(...args),
approve: (...args: unknown[]) => mockApprove(...args),
}),
}));

vi.mock('lucide-react', () => ({
ArrowRight: () => React.createElement('span', null, '→'),
Info: () => React.createElement('span', null, 'i'),
Expand Down Expand Up @@ -155,3 +173,104 @@ describe('CreatePage — zero-rate guard (issue #243)', () => {
cleanup(root, container);
});
});

describe('CreatePage — SEP-41 allowance check before deposit (issue #218)', () => {
const XLM_ADDRESS = 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC';

beforeEach(() => {
vi.clearAllMocks();
mockIsMock.mockReturnValue(false);
mockCreateStream.mockResolvedValue('tx_hash_abc');
mockRefreshStreamData.mockResolvedValue(undefined);
});

async function submitDeposit(container: HTMLElement) {
await fillRecipient(container);
// 1000 XLM over the default 30-day duration -> well above the zero-rate floor.
await fillDeposit(container, '1000');
const form = container.querySelector('form') as HTMLFormElement;
await act(async () => {
form.requestSubmit();
await new Promise((r) => setTimeout(r, 50));
});
}

it('requests approval when the existing allowance is insufficient, then proceeds to create the stream', async () => {
mockCheckAllowance.mockResolvedValue({ success: true, data: 0n });
mockApprove.mockResolvedValue({ success: true, data: 'approve_tx_hash' });

const { container, root } = renderCreatePage();
await submitDeposit(container);

expect(mockCheckAllowance).toHaveBeenCalledWith(
expect.objectContaining({
token: XLM_ADDRESS,
owner: 'GSENDER1234567890ABCDEF',
spender: FACTORY_ID,
}),
);
expect(mockApprove).toHaveBeenCalledTimes(1);
// approve() must resolve before create_stream is submitted.
expect(mockCreateStream).toHaveBeenCalledTimes(1);

cleanup(root, container);
});

it('skips approve() when the existing allowance already covers the deposit', async () => {
mockCheckAllowance.mockResolvedValue({ success: true, data: 10_000_000_000_000n });

const { container, root } = renderCreatePage();
await submitDeposit(container);

expect(mockCheckAllowance).toHaveBeenCalledTimes(1);
expect(mockApprove).not.toHaveBeenCalled();
expect(mockCreateStream).toHaveBeenCalledTimes(1);

cleanup(root, container);
});

it('surfaces an actionable error and never submits the deposit when approval fails', async () => {
mockCheckAllowance.mockResolvedValue({ success: true, data: 0n });
mockApprove.mockResolvedValue({
success: false,
error: {
message: 'Wallet rejected the approval request',
code: 'WALLET_REJECTED',
source: 'wallet',
retryable: false,
},
});

const { container, root } = renderCreatePage();
await submitDeposit(container);

expect(mockApprove).toHaveBeenCalledTimes(1);
expect(mockCreateStream).not.toHaveBeenCalled();
expect(container.textContent).toContain('Wallet rejected the approval request');

cleanup(root, container);
});

it('surfaces an actionable error and never approves/deposits when the allowance check itself fails', async () => {
// Mirrors #291 — a transient RPC/network failure must not be treated as
// a genuine zero allowance and silently trigger an approve() call.
mockCheckAllowance.mockResolvedValue({
success: false,
error: {
message: 'Network request timed out',
code: 'NETWORK_TIMEOUT',
source: 'network',
retryable: true,
},
});

const { container, root } = renderCreatePage();
await submitDeposit(container);

expect(mockApprove).not.toHaveBeenCalled();
expect(mockCreateStream).not.toHaveBeenCalled();
expect(container.textContent).toContain('Network request timed out');

cleanup(root, container);
});
});
57 changes: 55 additions & 2 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { useForm } from 'react-hook-form';
import { z, ZodType } from 'zod';
import { ArrowRight, Info } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { createStream } from '@/lib/factory';
import { createStream, isMock } from '@/lib/factory';
import { TOKENS_TESTNET, tokenLogoUrl } from '@/lib/tokens';
import { CopyHashButton } from '@/components/ui/CopyHashButton';
import { checkRecipientExists } from '@/lib/soroban';
import { refreshStreamData } from '@/lib/queryClient';
import { getFactoryContractId } from '@/lib/env';
import { getTokenAllowanceGateway } from '@/lib/token-allowance-gateway';
import styles from './CreateStream.module.css';
import { toStroops, wouldRateTruncateToZero } from '@/lib/format';

Expand Down Expand Up @@ -73,6 +75,11 @@ export default function CreatePage() {
const [txHash, setTxHash] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

// Tracks the SEP-41 allowance pre-flight (#218) so the button label can
// tell the user which on-chain step they're currently waiting on — an
// approve() transaction is a separate wallet prompt from create_stream's.
const [allowanceStage, setAllowanceStage] = useState<'checking' | 'approving' | null>(null);

// On-chain recipient existence check — only runs after the address passes
// the Zod length/format guard (i.e. it's a plausible 56-char G… key).
// Debounced to avoid hammering the RPC on every keystroke.
Expand Down Expand Up @@ -179,6 +186,47 @@ export default function CreatePage() {
const startTime = Math.floor(Date.now() / 1000) + 60; // 60s buffer
const endTime = startTime + data.durationSeconds;

// DripFactory::create_stream pulls the deposit from the sender via the
// token's SEP-41 transfer_from, which requires a pre-existing allowance
// from sender -> factory at least as large as the deposit. Without this
// check, the first-ever deposit fails with an opaque contract error
// (see #218). Skipped in demo/mock mode — createStream() never issues
// a real RPC call there either.
if (!isMock()) {
const spender = getFactoryContractId();
const gateway = getTokenAllowanceGateway();

setAllowanceStage('checking');
const allowanceCheck = await gateway.checkAllowance({
token: tokenAddr,
owner: publicKey,
spender,
source: publicKey,
});
if (!allowanceCheck.success) {
throw new Error(
allowanceCheck.error?.message ?? 'Could not verify token allowance. Please try again.',
);
}

if ((allowanceCheck.data ?? 0n) < depositStroops) {
setAllowanceStage('approving');
const approveResult = await gateway.approve({
token: tokenAddr,
spender,
amount: depositStroops,
source: publicKey,
signTx,
});
if (!approveResult.success) {
throw new Error(
approveResult.error?.message ?? 'Token approval failed. Please try again.',
);
}
}
setAllowanceStage(null);
}

const hash = await withTimeout(
createStream({
sender: publicKey,
Expand All @@ -204,6 +252,7 @@ export default function CreatePage() {
setError(e instanceof Error ? e.message : 'Transaction failed');
} finally {
setPending(false);
setAllowanceStage(null);
}
}

Expand Down Expand Up @@ -380,7 +429,11 @@ export default function CreatePage() {
className="btn-primary w-full"
>
{pending
? 'Signing transaction…'
? allowanceStage === 'checking'
? 'Checking token allowance…'
: allowanceStage === 'approving'
? 'Requesting approval…'
: 'Signing transaction…'
: recipientStatus === 'checking'
? 'Verifying recipient…'
: 'Create stream'}
Expand Down
8 changes: 7 additions & 1 deletion lib/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ function isDemoMode(): boolean {
return process.env['NEXT_PUBLIC_DEMO_MODE'] === 'true';
}

function isMock(): boolean {
/**
* Exported so callers that need to gate non-factory on-chain steps (e.g. the
* SEP-41 allowance check/approve step ahead of create_stream — see #218) can
* skip them under the exact same condition createStream() itself uses to
* skip the real RPC call.
*/
export function isMock(): boolean {
if (isDemoMode()) return true;

// #279 — require NEXT_PUBLIC_DEMO_MODE=true explicitly rather than
Expand Down
Loading