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
1,432 changes: 1,244 additions & 188 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@
"@playwright/test": "^1.52.0",
"@stellar/stellar-sdk": "^16.0.1",
"@tailwindcss/postcss": "^4",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^20",
"@types/qrcode": "^1.5.6",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"fast-check": "^3.23.2",
"jsdom": "^25.0.1",
"msw": "^2.6.8",
"tailwindcss": "^4",
"typescript": "^5",
"vitest": "^3.2.4"
Expand Down
70 changes: 70 additions & 0 deletions src/components/StakingPendingIndicator.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import { useSorobanStaking, explorerUrl } from '@/src/hooks/useSorobanStaking';
import { useToast } from '@/src/components/Toast';
import type { PendingStake } from '@/src/store/stakingStore';

/**
* Shows in-flight staking operations with an animated "transaction in flight"
* pulse, a link to the Stellar explorer once a hash exists, and a retry button
* for failed operations. Reads the optimistic `pending` list from the hook.
*/

function statusDot(status: PendingStake['status']): string {
switch (status) {
case 'pending':
return 'bg-yellow-400 animate-pulse';
case 'confirmed':
return 'bg-green-500';
case 'failed':
return 'bg-red-500';
}
}

export function StakingPendingIndicator() {
const { showToast } = useToast();
const { pending, retry } = useSorobanStaking(showToast);

if (pending.length === 0) return null;

return (
<ul className="flex flex-col gap-2" aria-label="Pending staking transactions">
{pending.map((p) => (
<li
key={p.optimisticTxId}
className="flex items-center justify-between gap-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm dark:border-zinc-800 dark:bg-zinc-900"
>
<div className="flex items-center gap-2">
<span className={`inline-flex h-2 w-2 rounded-full ${statusDot(p.status)}`} />
<span className="font-medium capitalize">{p.action}</span>
<span className="text-zinc-500">{p.amount}</span>
{p.status === 'failed' && p.error && (
<span className="text-xs text-red-600">— {p.error.reason}</span>
)}
</div>

<div className="flex items-center gap-3">
{p.realTxHash && (
<a
href={explorerUrl(p.realTxHash)}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-blue-600 underline hover:text-blue-800"
>
{p.realTxHash.slice(0, 6)}…{p.realTxHash.slice(-4)}
</a>
)}
{p.status === 'failed' && (
<button
onClick={() => retry(p.optimisticTxId)}
className="rounded border border-red-300 px-2 py-0.5 text-xs font-medium text-red-700 hover:bg-red-50"
>
Retry
</button>
)}
</div>
</li>
))}
</ul>
);
}
6 changes: 3 additions & 3 deletions src/hooks/useSessionWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function useSessionWatcher() {
watcherRef.current = watcher

const unsubDisconnect = watcher.on("disconnected", ({ walletType: wType }) => {
const txCount = useStakingStore.getState().pendingTransactions
const txCount = useStakingStore.getState().pending.length
if (txCount > 0) return

setDisconnectedWallet(wType)
Expand All @@ -76,7 +76,7 @@ export function useSessionWatcher() {
previousRoute: previousRouteRef.current,
walletType: wType,
sessionDuration: Date.now() - (sessionStartRef.current ?? Date.now()),
pendingTransactionsCount: useStakingStore.getState().pendingTransactions,
pendingTransactionsCount: useStakingStore.getState().pending.length,
})
logout()
}
Expand Down Expand Up @@ -122,7 +122,7 @@ export function useSessionWatcher() {
previousRoute: previousRouteRef.current,
walletType: walletType ?? "unknown",
sessionDuration: Date.now() - (sessionStartRef.current ?? Date.now()),
pendingTransactionsCount: useStakingStore.getState().pendingTransactions,
pendingTransactionsCount: useStakingStore.getState().pending.length,
})
logout()
}
Expand Down
80 changes: 80 additions & 0 deletions src/hooks/useSorobanStaking.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeAll, afterAll, afterEach, beforeEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { setupServer } from 'msw/node';

// Mock the wallet so the hook has a source account without a WalletProvider.
vi.mock('@/src/hooks/useWallet', () => ({
useWallet: () => ({
activeAccount: { publicKey: 'GTESTACCOUNTXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', provider: 'freighter' },
pendingAccountSwitch: false,
}),
}));

import { useSorobanStaking } from '@/src/hooks/useSorobanStaking';
import { useStakingStore } from '@/src/store/stakingStore';

const RPC_ENDPOINT = 'https://soroban-rpc.stellar.org';

// By default the RPC simulates a FAILED on-chain execution (HostError), which
// drives the rollback path. Individual tests can override this handler.
const server = setupServer(
http.post(RPC_ENDPOINT, async ({ request }) => {
const body = (await request.json()) as { method: string };
if (body.method === 'sendTransaction') {
return HttpResponse.json({
jsonrpc: '2.0',
id: 1,
error: { code: -32000, message: 'HostError: contract trapped' },
});
}
return HttpResponse.json({ jsonrpc: '2.0', id: 1, result: { status: 'not_found' } });
})
);

const INITIAL_BALANCE = 1000;

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterAll(() => server.close());
afterEach(() => server.resetHandlers());

beforeEach(() => {
useStakingStore.getState().reset();
useStakingStore.getState().initBalance(INITIAL_BALANCE);
});

describe('useSorobanStaking optimistic updates', () => {
it('applies an optimistic balance change immediately, then rolls back on failure and re-applies on retry', async () => {
const { result } = renderHook(() => useSorobanStaking());

// (a)+(b) stake(100): the optimistic delta is applied synchronously,
// before the network round-trip resolves.
let pending: Promise<void>;
act(() => {
pending = result.current.stake(100);
});
expect(useStakingStore.getState().optimisticBalance).toBe(INITIAL_BALANCE - 100); // 900

// (c) the simulated on-chain execution fails...
await act(async () => {
await pending;
});

// (d) ...so the balance reverts to the original value.
expect(useStakingStore.getState().optimisticBalance).toBe(INITIAL_BALANCE); // 1000
const failed = useStakingStore.getState().pending.find((p) => p.status === 'failed');
expect(failed).toBeDefined();

// (e) retry() re-enters the optimistic state with the same parameters.
let retried: Promise<void>;
act(() => {
retried = result.current.retry(failed!.optimisticTxId);
});
expect(useStakingStore.getState().optimisticBalance).toBe(INITIAL_BALANCE - 100); // 900 again

await act(async () => {
await retried;
});
});
});
Loading
Loading