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
61 changes: 19 additions & 42 deletions app/src/app/deposit/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,11 @@ import {
createIntent,
beginSubmission,
markAwaitingSignature,
markSubmitted,
markPending,
markConfirmed,
markFailed,
markExpired,
findActiveIntent,
type PaymentIntent,
} from '@/lib/paymentIntent';
import { waitForTransaction, TransactionTimedOutError } from '@/services/rpc';
import { toast } from '@/lib/toast';
import { runPaymentSubmission, defaultPaymentSubmissionDeps } from '@/lib/paymentFlow';

const TIERS = [
{ id: 'flex', label: 'Flex', lock: 'None', lockMonths: 0, multiplier: 1.0, multiplierLabel: '1.00x', exitFee: '0%', minDeposit: 1, apy: 4.2 },
Expand Down Expand Up @@ -45,8 +41,9 @@ function lockExpiry(lockMonths: number): string {
* regenerate `app/pnpm-lock.yaml` correctly — this environment can't
* safely run that (see the memory-constrained-box note in the repo's
* contribution history). Everything downstream of the hash this returns
* (`waitForTransaction` below) is real and already wired for the day this
* returns a genuine submission hash instead.
* (submit -> wait -> classify, in lib/paymentFlow.ts's `runPaymentSubmission`)
* is real and already wired for the day this returns a genuine submission
* hash instead.
*
* Format-valid (64 hex chars, like a real Stellar transaction hash) so it
* exercises the real confirmation poller end-to-end against the live RPC
Expand Down Expand Up @@ -137,42 +134,22 @@ function DepositFlow() {
const awaiting = markAwaitingSignature(started);
setIntent(awaiting);

try {
const txHash = await placeholderSubmissionHash(awaiting.key);
const submitted = markSubmitted(awaiting, txHash);
setIntent(submitted);
// Real submit -> sign -> wait -> classify pipeline (issue #142), shared
// with EarlyExitModal via lib/paymentFlow.ts so its failure paths are
// reachable via real, injected failures in tests — not just this
// manual UI trigger.
const result = await runPaymentSubmission(awaiting, {
...defaultPaymentSubmissionDeps,
submit: placeholderSubmissionHash,
});

const pending = markPending(submitted);
setIntent(pending);
setTxStatus('pending');

// Real polling against the live Soroban RPC endpoint (issue #141) —
// not a timer. A 20s cap keeps manual testing bearable; production
// callers get waitForTransaction's full default (60s).
await waitForTransaction(txHash, { timeoutMs: 20_000 });

const confirmed = markConfirmed(pending);
setIntent(confirmed);
setTxStatus('confirmed');
} catch (error) {
if (error instanceof TransactionTimedOutError) {
const expired = markExpired(
awaiting,
'Could not confirm this transaction in time. It may still complete — check back before retrying.',
);
setIntent(expired);
setTxStatus('expired');
setTxError(expired.error ?? '');
} else {
const message = error instanceof Error ? error.message : 'Transaction failed';
const failed = markFailed(awaiting, message);
setIntent(failed);
setTxStatus('failed');
setTxError(failed.error ?? 'An error occurred. Please try again.');
}
} finally {
setSubmitting(false);
setIntent(result.intent);
setTxStatus(result.status);
if (result.message) {
setTxError(result.message);
toast.failed(result.message);
}
setSubmitting(false);
}

function handleRetry() {
Expand Down
67 changes: 42 additions & 25 deletions app/src/components/EarlyExitModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,28 @@ import {
createIntent,
beginSubmission,
markAwaitingSignature,
markSubmitted,
markConfirmed,
markFailed,
findActiveIntent,
type PaymentIntent,
} from "@/lib/paymentIntent";
import { runPaymentSubmission, defaultPaymentSubmissionDeps } from "@/lib/paymentFlow";
import { toast } from "@/lib/toast";

function fixed7(n: number) {
return Math.round(n * 1e7) / 1e7;
}

/**
* TODO(#141 follow-up): stands in for `sdk.earlyExit({ tier, idempotencyKey
* })` (sdks/typescript's YieldLadder) until the app depends on the SDK
* directly — see the identical TODO on app/src/app/deposit/page.tsx's
* placeholderSubmissionHash for why that wiring is deferred.
*/
async function placeholderEarlyExitHash(idempotencyKey: string): Promise<string> {
return new Promise((resolve) =>
setTimeout(() => resolve(`mock-tx-${idempotencyKey}`), 2000),
);
}

export default function EarlyExitModal({
open,
onClose,
Expand All @@ -37,7 +48,9 @@ export default function EarlyExitModal({
}) {
const [intent, setIntent] = useState<PaymentIntent | null>(null);
const [submitting, setSubmitting] = useState(false);
const [status, setStatus] = useState<'idle' | 'pending' | 'confirmed' | 'failed'>('idle');
const [status, setStatus] = useState<
'idle' | 'pending' | 'confirmed' | 'failed' | 'expired'
>('idle');
const [error, setError] = useState('');

const address = loadSession()?.account.publicKey ?? 'anonymous';
Expand Down Expand Up @@ -80,27 +93,22 @@ export default function EarlyExitModal({
const awaiting = markAwaitingSignature(started);
setIntent(awaiting);

try {
// sdk.earlyExit({ tier, idempotencyKey: awaiting.key }) would go here
const txHash = await new Promise<string>((resolve) =>
setTimeout(() => resolve(`mock-tx-${awaiting.key}`), 2000),
);
const submitted = markSubmitted(awaiting, txHash);
setIntent(submitted);
const confirmed = markConfirmed(submitted);
setIntent(confirmed);
setStatus('confirmed');
} catch (err) {
const failed = markFailed(
awaiting,
err instanceof Error ? err.message : 'Transaction failed',
);
setIntent(failed);
setStatus('failed');
setError(failed.error ?? 'An error occurred. Please try again.');
} finally {
setSubmitting(false);
// Real submit -> sign -> wait -> classify pipeline (issue #142), shared
// with the deposit flow via lib/paymentFlow.ts — see that module's docs
// for why real failures (network/wallet/on-chain) are reachable here
// via injected test doubles rather than only this manual UI trigger.
const result = await runPaymentSubmission(awaiting, {
...defaultPaymentSubmissionDeps,
submit: placeholderEarlyExitHash,
});

setIntent(result.intent);
setStatus(result.status);
if (result.message) {
setError(result.message);
toast.failed(result.message);
}
setSubmitting(false);
}

const calc = useMemo(() => {
Expand Down Expand Up @@ -161,6 +169,11 @@ export default function EarlyExitModal({
{status === 'pending' && <p>Submitting transaction…</p>}
{status === 'confirmed' && <p>Exit confirmed.</p>}
{status === 'failed' && <p style={{ color: '#c33' }}>{error || 'An error occurred. Please try again.'}</p>}
{status === 'expired' && (
<p style={{ color: '#c33' }}>
{error || 'Could not confirm this transaction in time.'}
</p>
)}
<div
style={{
display: "flex",
Expand All @@ -183,7 +196,11 @@ export default function EarlyExitModal({
disabled={submitting || status === 'confirmed'}
onClick={handleExitEarly}
>
{submitting ? 'Submitting…' : 'Exit Early'}
{submitting
? 'Submitting…'
: status === 'failed' || status === 'expired'
? 'Try Again'
: 'Exit Early'}
</button>
</div>
</div>
Expand Down
194 changes: 194 additions & 0 deletions app/src/lib/paymentFlow.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
classifyPaymentError,
requestWalletSignature,
runPaymentSubmission,
} from './paymentFlow';
import {
RpcTimeoutError,
RpcUnavailableError,
TransactionFailedError,
TransactionTimedOutError,
} from '../services/rpc';
import {
WalletDisconnectedError,
WalletRejectedError,
WalletUnavailableError,
} from './wallet/errors';
import { createIntent, markAwaitingSignature, beginSubmission, type StorageLike } from './paymentIntent';

describe('classifyPaymentError (issue #142)', () => {
it('classifies a wallet rejection as retryable-safely with the wallet message', () => {
const result = classifyPaymentError(new WalletRejectedError());
expect(result.retryClassification).toBe('retryable-safely');
expect(result.message).toMatch(/declined/i);
});

it('classifies a dropped wallet connection as retryable-safely', () => {
const result = classifyPaymentError(new WalletDisconnectedError());
expect(result.retryClassification).toBe('retryable-safely');
expect(result.message).toMatch(/disconnect/i);
});

it('classifies a missing wallet extension as retryable-safely', () => {
const result = classifyPaymentError(new WalletUnavailableError('Freighter'));
expect(result.retryClassification).toBe('retryable-safely');
expect(result.message).toMatch(/freighter/i);
});

it('classifies an unreachable RPC endpoint as retryable-safely', () => {
const result = classifyPaymentError(new RpcUnavailableError('fetch failed'));
expect(result.retryClassification).toBe('retryable-safely');
expect(result.message).toMatch(/reach/i);
});

it('classifies an RPC timeout as retryable-safely', () => {
const result = classifyPaymentError(new RpcTimeoutError('timed out'));
expect(result.retryClassification).toBe('retryable-safely');
expect(result.message).toMatch(/respond/i);
});

it('classifies a confirmed on-chain failure as retryable-with-new-intent', () => {
const result = classifyPaymentError(new TransactionFailedError('abc123'));
expect(result.retryClassification).toBe('retryable-with-new-intent');
expect(result.message).toMatch(/abc123/);
});

it('classifies an ambiguous confirmation timeout as retryable-with-new-intent', () => {
const result = classifyPaymentError(new TransactionTimedOutError('abc123'));
expect(result.retryClassification).toBe('retryable-with-new-intent');
});

it('falls back to a generic message and retryable-safely for an unrecognized Error', () => {
const result = classifyPaymentError(new Error('something unexpected'));
expect(result.message).toBe('something unexpected');
expect(result.retryClassification).toBe('retryable-safely');
});

it('falls back gracefully for a non-Error throw', () => {
const result = classifyPaymentError('a string, not an Error');
expect(result.message).toBe('Transaction failed');
expect(result.retryClassification).toBe('retryable-safely');
});
});

describe('requestWalletSignature', () => {
it('resolves (placeholder) so callers can await it like a real signing step', async () => {
await expect(requestWalletSignature()).resolves.toBeUndefined();
});
});

function makeMemoryStorage(): StorageLike {
const store = new Map<string, string>();
return {
getItem: (key) => store.get(key) ?? null,
setItem: (key, value) => void store.set(key, value),
removeItem: (key) => void store.delete(key),
};
}

function makeAwaitingIntent(storage: StorageLike) {
const intent = createIntent('deposit', 'GADDR', 'flex', 'USDC', '10', storage);
return markAwaitingSignature(intent, storage);
}

describe('runPaymentSubmission (issue #142 — real injected failures)', () => {
let storage: StorageLike;

beforeEach(() => {
storage = makeMemoryStorage();
});

it('reaches the confirmed state on the happy path', async () => {
const awaiting = makeAwaitingIntent(storage);

const result = await runPaymentSubmission(awaiting, {
requestWalletSignature: vi.fn().mockResolvedValue(undefined),
submit: vi.fn().mockResolvedValue('tx-hash-1'),
waitForTransaction: vi.fn().mockResolvedValue(undefined),
storage,
});

expect(result.status).toBe('confirmed');
expect(result.intent.txHash).toBe('tx-hash-1');
expect(result.message).toBeNull();
});

it('reaches the failed state with a wallet-specific message when the wallet rejects signing', async () => {
const awaiting = makeAwaitingIntent(storage);

const result = await runPaymentSubmission(awaiting, {
requestWalletSignature: vi.fn().mockRejectedValue(new WalletRejectedError()),
submit: vi.fn(),
waitForTransaction: vi.fn(),
storage,
});

expect(result.status).toBe('failed');
expect(result.message).toMatch(/declined/i);
expect(result.intent.error).toMatch(/declined/i);
// The signing step never resolved, so submission must never be attempted.
expect(result.intent.txHash).toBeNull();
});

it('reaches the failed state with a network-specific message when submission cannot reach the RPC', async () => {
const awaiting = makeAwaitingIntent(storage);

const result = await runPaymentSubmission(awaiting, {
requestWalletSignature: vi.fn().mockResolvedValue(undefined),
submit: vi.fn().mockRejectedValue(new RpcUnavailableError('fetch failed')),
waitForTransaction: vi.fn(),
storage,
});

expect(result.status).toBe('failed');
expect(result.message).toMatch(/reach/i);
});

it('reaches the failed state when the transaction is included but reverts on-chain', async () => {
const awaiting = makeAwaitingIntent(storage);

const result = await runPaymentSubmission(awaiting, {
requestWalletSignature: vi.fn().mockResolvedValue(undefined),
submit: vi.fn().mockResolvedValue('tx-hash-2'),
waitForTransaction: vi
.fn()
.mockRejectedValue(new TransactionFailedError('tx-hash-2')),
storage,
});

expect(result.status).toBe('failed');
expect(result.message).toMatch(/tx-hash-2/);
});

it('reaches the distinct expired state (not failed) when confirmation times out ambiguously', async () => {
const awaiting = makeAwaitingIntent(storage);

const result = await runPaymentSubmission(awaiting, {
requestWalletSignature: vi.fn().mockResolvedValue(undefined),
submit: vi.fn().mockResolvedValue('tx-hash-3'),
waitForTransaction: vi
.fn()
.mockRejectedValue(new TransactionTimedOutError('tx-hash-3')),
storage,
});

expect(result.status).toBe('expired');
expect(result.intent.status).toBe('expired');
});

it('releases the operation lock on failure so a retry is not blocked', async () => {
const awaiting = makeAwaitingIntent(storage);

await runPaymentSubmission(awaiting, {
requestWalletSignature: vi.fn().mockRejectedValue(new WalletRejectedError()),
submit: vi.fn(),
waitForTransaction: vi.fn(),
storage,
});

const retryIntent = createIntent('deposit', 'GADDR', 'flex', 'USDC', '10', storage);
// beginSubmission would refuse if the previous attempt's lock were still held.
expect(beginSubmission(retryIntent, storage)).not.toBeNull();
});
});
Loading
Loading