diff --git a/app/create/__tests__/page.test.tsx b/app/create/__tests__/page.test.tsx index 41e9d4b..6739117 100644 --- a/app/create/__tests__/page.test.tsx +++ b/app/create/__tests__/page.test.tsx @@ -24,8 +24,12 @@ 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(); @@ -33,6 +37,20 @@ 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'), @@ -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); + }); +}); diff --git a/app/create/page.tsx b/app/create/page.tsx index 6e38ff3..35a54e3 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -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'; @@ -73,6 +75,11 @@ export default function CreatePage() { const [txHash, setTxHash] = useState(null); const [error, setError] = useState(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. @@ -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, @@ -204,6 +252,7 @@ export default function CreatePage() { setError(e instanceof Error ? e.message : 'Transaction failed'); } finally { setPending(false); + setAllowanceStage(null); } } @@ -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'} diff --git a/lib/factory.ts b/lib/factory.ts index 7bf810e..84706eb 100644 --- a/lib/factory.ts +++ b/lib/factory.ts @@ -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