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
24 changes: 24 additions & 0 deletions app/create/__tests__/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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();
Expand Down
22 changes: 3 additions & 19 deletions app/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -55,23 +56,6 @@ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise
});
}

function zodResolver<T extends ZodType>(schema: T) {
return async (values: Record<string, unknown>) => {
const result = await schema.safeParseAsync(values);
if (result.success) {
return { values: result.data, errors: {} };
}
const errors: Record<string, { message?: string; type?: string }> = {};
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<typeof schema>;

export default function CreatePage() {
Expand Down
11 changes: 8 additions & 3 deletions lib/soroban-pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand Down Expand Up @@ -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);
});
Expand All @@ -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);
});
Expand Down
43 changes: 20 additions & 23 deletions lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
};

Expand All @@ -466,7 +457,7 @@ async function pollForConfirmation(
hash: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<string> {
): Promise<InvokeContractResult> {
for (let i = 0; i < MAX_POLL_ATTEMPTS; i++) {
if (signal?.aborted) throw new OperationAbortedError();

Expand All @@ -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
Expand All @@ -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 };
}

/**
Expand Down
1 change: 1 addition & 0 deletions lib/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
4 changes: 2 additions & 2 deletions lib/tokens.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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);
});
});
Loading