diff --git a/app/create/__tests__/page.test.tsx b/app/create/__tests__/page.test.tsx index 01f7aa3..e3bc189 100644 --- a/app/create/__tests__/page.test.tsx +++ b/app/create/__tests__/page.test.tsx @@ -58,6 +58,15 @@ vi.mock('lucide-react', () => ({ Check: () => React.createElement('span', null, 'check'), })); +// CreatePage runs a debounced `checkRecipientExists()` RPC whenever the +// recipient field holds a 56-char address, and (since #363) `onSubmit` blocks +// while that check is in flight. Mock it to resolve "exists" immediately; the +// tests still await the 600ms debounce via `settleRecipientCheck()`. +const mockCheckRecipientExists = vi.fn().mockResolvedValue(true); +vi.mock('@/lib/soroban', () => ({ + checkRecipientExists: (...args: unknown[]) => mockCheckRecipientExists(...args), +})); + // ── Import after mocks ─────────────────────────────────────────────────────── import CreatePage from '../page'; @@ -98,6 +107,19 @@ async function fillRecipient(container: HTMLElement) { }); } +/** + * Wait out CreatePage's 600ms recipient-check debounce + the (mocked, instant) + * checkRecipientExists() call, so `recipientStatus` settles to 'valid' before a + * submit. Without this, onSubmit short-circuits with "Still verifying the + * recipient address" (#363). + */ +async function settleRecipientCheck(container: HTMLElement) { + await act(async () => { + await new Promise((r) => setTimeout(r, 700)); + }); + expect(container.textContent).not.toContain('Still verifying the recipient address'); +} + async function fillDeposit(container: HTMLElement, amount: string) { const depositInput = container.querySelector('input[placeholder="1000"]') as HTMLInputElement; await act(async () => { @@ -156,6 +178,7 @@ describe('CreatePage — zero-rate guard (issue #243)', () => { await fillRecipient(container); // 1000 XLM over the default 30-day duration -> ~3858 stroops/s, well above zero. await fillDeposit(container, '1000'); + await settleRecipientCheck(container); expect(container.textContent).not.toContain('Deposit too small for this duration'); @@ -188,6 +211,7 @@ describe('CreatePage — SEP-41 allowance check before deposit (issue #218)', () await fillRecipient(container); // 1000 XLM over the default 30-day duration -> well above the zero-rate floor. await fillDeposit(container, '1000'); + await settleRecipientCheck(container); const form = container.querySelector('form') as HTMLFormElement; await act(async () => { form.requestSubmit(); diff --git a/app/create/page.tsx b/app/create/page.tsx index 68883e0..4dc565e 100644 --- a/app/create/page.tsx +++ b/app/create/page.tsx @@ -3,7 +3,8 @@ import { useState, useEffect, useRef } from 'react'; import { useRouter } from 'next/navigation'; import { useForm } from 'react-hook-form'; -import { z, ZodType } from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; import { ArrowRight, Info } from 'lucide-react'; import { useWallet } from '@/contexts/WalletContext'; import { createStream, isMock } from '@/lib/factory'; @@ -15,7 +16,7 @@ import { getFactoryContractId } from '@/lib/env'; import { getTokenAllowanceGateway } from '@/lib/token-allowance-gateway'; import styles from './CreateStream.module.css'; import { toStroops, fromStroops, wouldRateTruncateToZero } from '@/lib/format'; -import { isValidStellarPublicKey } from '@/lib/stellar-address'; +import { isValidStellarAddress } from '@/lib/stellar-address'; const schema = z.object({ @@ -55,23 +56,6 @@ function withTimeout(promise: Promise, ms: number, label: string): Promise }); } -function zodResolver(schema: T) { - return async (values: Record) => { - const result = await schema.safeParseAsync(values); - if (result.success) { - return { values: result.data, errors: {} }; - } - const errors: Record = {}; - for (const issue of result.error.issues) { - const path = issue.path.join('.'); - if (!errors[path]) { - errors[path] = { message: issue.message, type: issue.code }; - } - } - return { values: {}, errors }; - }; -} - type FormValues = z.infer; export default function CreatePage() { diff --git a/lib/soroban-pipeline.test.ts b/lib/soroban-pipeline.test.ts index bc86024..47311c0 100644 --- a/lib/soroban-pipeline.test.ts +++ b/lib/soroban-pipeline.test.ts @@ -82,8 +82,13 @@ function simError(message: string) { return { error: message }; } -beforeEach(() => { +beforeEach(async () => { vi.useFakeTimers(); + // The fee-stats cache is module-level and survives individual invokeContract + // calls by design — clear it so one case's populated cache can't bleed into + // the next (e.g. the getFeeStats-unavailable fallback case). + const { resetFeeStatsCache } = await import('./soroban.js'); + resetFeeStatsCache(); mockGetAccount.mockReset().mockResolvedValue({ accountId: () => SOURCE, sequenceNumber: () => '1' }); mockSimulate.mockReset(); mockSend.mockReset().mockResolvedValue({ status: 'PENDING', hash: 'deadbeef' }); @@ -212,7 +217,7 @@ describe('invokeContract', () => { promise.catch(() => {}); await vi.advanceTimersByTimeAsync(2000); - expect(await promise).toBe('deadbeef'); + expect(await promise).toEqual({ hash: 'deadbeef' }); expect(signTx).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalledTimes(1); }); @@ -227,7 +232,7 @@ describe('invokeContract', () => { promise.catch(() => {}); await vi.advanceTimersByTimeAsync(31_000); - expect(await promise).toBe('deadbeef'); + expect(await promise).toEqual({ hash: 'deadbeef' }); expect(signTx).toHaveBeenCalledTimes(1); expect(mockSend).toHaveBeenCalledTimes(1); }); diff --git a/lib/soroban.ts b/lib/soroban.ts index 0f141ab..cb2f122 100644 --- a/lib/soroban.ts +++ b/lib/soroban.ts @@ -242,6 +242,16 @@ function validateCall( let feeStatsCache: { fee: number; at: number } | undefined; +/** + * Clear the in-memory fee-stats cache. Exported for tests, which otherwise + * leak a populated cache from one case into the next (the cache is + * module-level and intentionally survives individual `invokeContract` calls + * in production). + */ +export function resetFeeStatsCache(): void { + feeStatsCache = undefined; +} + /** * Price the inclusion (bid) fee for a contract transaction. * @@ -426,25 +436,6 @@ export async function invokeContract( throw new Error('Submission returned no transaction hash'); } - if (status.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { - // #362 — surface the confirmed transaction's return value instead - // of discarding it. Contract functions like DripFactory::create_stream - // return data (the assigned stream_id) that callers otherwise have - // no way to obtain without a separate re-query. - return { hash, returnValue: status.returnValue }; - } - if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { - // Non-retryable — transaction executed and failed on-chain - recordFailure(); - throw new Error(`Transaction failed: ${hash}`); - } - // status === 'NOT_FOUND' — keep polling - } - throw new Error(`Transaction timed out after ${MAX_POLL_ATTEMPTS}s: ${hash}`); - }, { - context: `invokeContract(${method})`, - signal, - }); return pollForConfirmation(hash, timeoutMs, signal); }; @@ -466,7 +457,7 @@ async function pollForConfirmation( hash: string, timeoutMs: number, signal?: AbortSignal, -): Promise { +): Promise { for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) { if (signal?.aborted) throw new OperationAbortedError(); @@ -492,7 +483,12 @@ async function pollForConfirmation( if (status.status === SorobanRpc.Api.GetTransactionStatus.SUCCESS) { // The RPC round-trip completed — the network is healthy. resetCircuitBreaker(); - return hash; + // #362 — surface the confirmed transaction's return value instead of + // discarding it. Contract functions like DripFactory::create_stream + // return data (the assigned stream_id) that callers otherwise have no + // way to obtain without a separate re-query. `returnValue` is undefined + // for a void-returning function. + return { hash, returnValue: status.returnValue }; } if (status.status === SorobanRpc.Api.GetTransactionStatus.FAILED) { // The transaction executed and the contract reverted. The RPC worked @@ -503,8 +499,9 @@ async function pollForConfirmation( // status === 'NOT_FOUND' — keep polling } - // Submitted but unconfirmed within the window — pending, not failed. - return hash; + // Submitted but unconfirmed within the window — pending, not failed. No + // return value is available without a confirmed status. + return { hash }; } /** diff --git a/lib/stream.test.ts b/lib/stream.test.ts index 90f765e..ab0c0c5 100644 --- a/lib/stream.test.ts +++ b/lib/stream.test.ts @@ -116,6 +116,7 @@ describe('getStreamInfo', () => { it('throws a clear error when a field is missing rather than silently defaulting', async () => { mockSimulateReadOnly.mockResolvedValue(scvMap({ sender: new Address(SENDER).toScVal(), + flags: xdr.ScVal.scvU32(0), // recipient deliberately omitted })); const { getStreamInfo } = await import('./stream.js'); diff --git a/lib/tokens.test.ts b/lib/tokens.test.ts index e23799e..0f6a66a 100644 --- a/lib/tokens.test.ts +++ b/lib/tokens.test.ts @@ -103,7 +103,7 @@ describe('SEP-41 Token Allowance Helpers (#347, #348)', () => { }); it('approveAllowance calls SAC with 4 arguments and succeeds (#347)', async () => { - vi.mocked(soroban.invokeContract).mockResolvedValueOnce('tx_hash_123'); + vi.mocked(soroban.invokeContract).mockResolvedValueOnce({ hash: 'tx_hash_123' }); const mockSignTx = vi.fn().mockResolvedValue('signed'); const result = await approveAllowance( @@ -127,7 +127,7 @@ describe('SEP-41 Token Allowance Helpers (#347, #348)', () => { ); // Verify 4 arguments were passed to invokeContract - const passedArgs = vi.mocked(soroban.invokeContract).mock.calls[0][3]; + const passedArgs = vi.mocked(soroban.invokeContract).mock.calls[0]![3]; expect(passedArgs).toHaveLength(4); }); });