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
5 changes: 5 additions & 0 deletions dashboard/jest.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ module.exports = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
moduleNameMapper: {
'^@creit\\.tech/stellar-wallets-kit/modules/utils$':
'<rootDir>/src/test/stellarWalletsKitModulesMock.ts',
'^@creit\\.tech/stellar-wallets-kit$':
'<rootDir>/src/test/stellarWalletsKitMock.ts',
'^.*/config/stellarNetwork$': '<rootDir>/src/test/stellarNetworkMock.ts',
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
Expand Down
58 changes: 58 additions & 0 deletions dashboard/src/components/WalletConnectButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import '@testing-library/jest-dom';
import { render, screen, act } from '@testing-library/react';
import * as kitReal from '@creit.tech/stellar-wallets-kit';
import { WalletConnectButton } from './WalletConnectButton';
import { useWalletStore } from '../store/walletStore';
import { restoreWalletSession } from '../services/wallet';

const kit = kitReal as unknown as typeof import('../test/stellarWalletsKitMock');

const WALLET_ID_KEY = 'notify-chain:wallet-id';
const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address';

beforeEach(() => {
localStorage.clear();
useWalletStore.setState({
address: null,
isConnecting: false,
isReconnecting: false,
error: null,
});
});

afterEach(() => {
localStorage.clear();
});

describe('WalletConnectButton reconnection behaviour', () => {
it('shows a connected address that was restored from a previous session', () => {
useWalletStore.getState().setAddress('GABCDEFGHIJKLMNOP');

render(<WalletConnectButton />);

expect(screen.getByText('GABCDE...MNOP')).toBeInTheDocument();
expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument();
});

it('keeps the connected UI through a transient reconnection blip', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
localStorage.setItem(WALLET_ADDRESS_KEY, 'GABCDEFGHIJKLMNOP');
useWalletStore.getState().setAddress('GABCDEFGHIJKLMNOP');

render(<WalletConnectButton />);
expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument();

// Registers the kit's event handlers via the service's ensureInitialized().
await act(async () => {
await restoreWalletSession();
});

// A transient null state-update mid-reconnect must NOT drop the session.
act(() => {
kit.__emit('STATE_UPDATED', { address: null });
});

expect(screen.getByLabelText('Disconnect wallet')).toBeInTheDocument();
expect(screen.queryByText('Connect Wallet')).not.toBeInTheDocument();
});
});
11 changes: 11 additions & 0 deletions dashboard/src/components/WalletConnectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,19 @@ function shortenAddress(address: string): string {
export const WalletConnectButton = memo(function WalletConnectButton() {
const address = useWalletStore((state) => state.address);
const isConnecting = useWalletStore((state) => state.isConnecting);
const isReconnecting = useWalletStore((state) => state.isReconnecting);
const error = useWalletStore((state) => state.error);

if (!address && isReconnecting) {
return (
<div className="wallet-connect">
<button type="button" className="wallet-connect__button" disabled aria-busy="true">
Reconnecting…
</button>
</div>
);
}

if (address) {
return (
<div className="wallet-connect wallet-connect--connected">
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/config/stellarNetwork.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Resolves the configured Stellar network name from the Vite environment.
*
* Isolated in its own module so the `import.meta.env` access — which only Vite
* can evaluate — stays out of unit tests, where it is replaced via Jest's
* `moduleNameMapper` (see jest.config.cjs).
*/
export function getStellarNetworkName(): string {
return import.meta.env?.VITE_STELLAR_NETWORK ?? 'TESTNET';
}
158 changes: 158 additions & 0 deletions dashboard/src/services/wallet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { jest } from '@jest/globals';
import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';

const WALLET_ID_KEY = 'notify-chain:wallet-id';
const WALLET_ADDRESS_KEY = 'notify-chain:wallet-address';

type KitMock = typeof import('../test/stellarWalletsKitMock');
type WalletService = typeof import('./wallet');
type WalletStoreModule = typeof import('../store/walletStore');

/**
* Reset the module registry and re-import the kit mock, the wallet service and
* the store as a fresh, isolated graph. The store hydrates its initial address
* from localStorage at import time, so callers MUST seed localStorage before
* calling this.
*/
async function load(): Promise<{
kit: KitMock;
wallet: WalletService;
store: WalletStoreModule;
}> {
jest.resetModules();
const kit = (await import(
'@creit.tech/stellar-wallets-kit'
)) as unknown as KitMock;
const store = await import('../store/walletStore');
const wallet = await import('./wallet');
return { kit, wallet, store };
}

beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
localStorage.clear();
});

describe('restoreWalletSession', () => {
it('does nothing when no wallet was previously connected', async () => {
const { wallet, store, kit } = await load();

await wallet.restoreWalletSession();

expect(store.useWalletStore.getState().address).toBeNull();
expect(store.useWalletStore.getState().isReconnecting).toBe(false);
expect(kit.__control.setWalletCalls).toEqual([]);
});

it('restores the address on a successful reconnect', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
const { wallet, store, kit } = await load();

kit.__control.fetchAddressImpl = async () => {
kit.__emit('STATE_UPDATED', { address: 'GNEWADDRESS' });
};

await wallet.restoreWalletSession();

expect(kit.__control.setWalletCalls).toEqual(['freighter']);
expect(store.useWalletStore.getState().address).toBe('GNEWADDRESS');
expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe('GNEWADDRESS');
expect(store.useWalletStore.getState().isReconnecting).toBe(false);
});

// The core bug: a transient refresh failure used to wipe the persisted
// wallet, so users "lost" their session on reload. It must now survive.
it('keeps the persisted session when the reconnect fetch fails transiently', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
localStorage.setItem(WALLET_ADDRESS_KEY, 'GPERSISTED');
const { wallet, store, kit } = await load();

kit.__control.fetchAddressImpl = async () => {
throw new Error('RPC timeout');
};

await wallet.restoreWalletSession();

// The saved wallet must survive a transient failure so the user stays
// connected on the next load instead of losing their session.
expect(localStorage.getItem(WALLET_ID_KEY)).toBe('freighter');
expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBe('GPERSISTED');
expect(store.useWalletStore.getState().address).toBe('GPERSISTED');
expect(store.useWalletStore.getState().error).toBe('RPC timeout');
expect(store.useWalletStore.getState().isReconnecting).toBe(false);
});

// React StrictMode double-invokes effects; two concurrent restores must not
// race each other.
it('coalesces concurrent restore calls into a single reconnection', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
const { wallet, kit } = await load();

let resolveFetch: () => void = () => {};
kit.__control.fetchAddressImpl = () =>
new Promise<void>((resolve) => {
resolveFetch = resolve;
});

const first = wallet.restoreWalletSession();
const second = wallet.restoreWalletSession();

expect(first).toBe(second);

resolveFetch();
await Promise.all([first, second]);

expect(kit.__control.setWalletCalls).toEqual(['freighter']);
});
});

describe('kit reactive events', () => {
it('ignores a transient null address while a wallet stays selected', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
localStorage.setItem(WALLET_ADDRESS_KEY, 'GLIVE');
const { wallet, store, kit } = await load();

// Registers the kit event handlers via ensureInitialized().
await wallet.restoreWalletSession();

kit.__emit('STATE_UPDATED', { address: null });

expect(store.useWalletStore.getState().address).toBe('GLIVE');
expect(localStorage.getItem(WALLET_ID_KEY)).toBe('freighter');
});

it('clears everything on an explicit disconnect event', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
localStorage.setItem(WALLET_ADDRESS_KEY, 'GLIVE');
const { wallet, store, kit } = await load();

await wallet.restoreWalletSession();
kit.__emit('DISCONNECT', {});

expect(store.useWalletStore.getState().address).toBeNull();
expect(localStorage.getItem(WALLET_ID_KEY)).toBeNull();
expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull();
});
});

describe('disconnectWallet', () => {
it('clears local state even when the kit disconnect call throws', async () => {
localStorage.setItem(WALLET_ID_KEY, 'freighter');
localStorage.setItem(WALLET_ADDRESS_KEY, 'GLIVE');
const { wallet, store, kit } = await load();

kit.__control.disconnectImpl = async () => {
throw new Error('module offline');
};

await wallet.disconnectWallet();

expect(store.useWalletStore.getState().address).toBeNull();
expect(localStorage.getItem(WALLET_ID_KEY)).toBeNull();
expect(localStorage.getItem(WALLET_ADDRESS_KEY)).toBeNull();
});
});

78 changes: 57 additions & 21 deletions dashboard/src/services/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,10 @@ import {
getPersistedWalletId,
setPersistedWalletId,
} from '../store/walletStore';
import { getStellarNetworkName } from '../config/stellarNetwork';

const NETWORK =
(import.meta.env.VITE_STELLAR_NETWORK ?? 'TESTNET') === 'PUBLIC'
? Networks.PUBLIC
: Networks.TESTNET;
getStellarNetworkName() === 'PUBLIC' ? Networks.PUBLIC : Networks.TESTNET;

let initialized = false;

Expand All @@ -33,10 +32,20 @@ function ensureInitialized(): void {
});

// The kit's own reactive state — fires immediately with current state on
// subscribe, then again on every change. This is the single source of
// truth for "is a wallet connected right now."
// subscribe, then again on every change.
StellarWalletsKit.on(KitEventType.STATE_UPDATED, (event) => {
useWalletStore.getState().setAddress(event.payload.address ?? null);
const nextAddress = event.payload.address ?? null;

// Mid-reconnection the kit can emit transient state updates that carry no
// address (e.g. while it re-derives the account). Honouring those would
// flip the UI back to "disconnected" and wipe the session even though a
// wallet is still selected. Only the dedicated DISCONNECT event below is
// allowed to tear a live session down.
if (!nextAddress && getPersistedWalletId()) {
return;
}

useWalletStore.getState().setAddress(nextAddress);
});

StellarWalletsKit.on(KitEventType.WALLET_SELECTED, (event) => {
Expand All @@ -45,7 +54,7 @@ function ensureInitialized(): void {

StellarWalletsKit.on(KitEventType.DISCONNECT, () => {
setPersistedWalletId(null);
useWalletStore.getState().setAddress(null);
useWalletStore.getState().clearSession();
});
}

Expand All @@ -57,6 +66,7 @@ function ensureInitialized(): void {
export async function connectWallet(): Promise<void> {
ensureInitialized();
const store = useWalletStore.getState();
store.setError(null);
store.setConnecting(true);

try {
Expand All @@ -75,30 +85,56 @@ export async function disconnectWallet(): Promise<void> {
try {
await StellarWalletsKit.disconnect();
} catch {
// Even if the module's own disconnect call fails, clear local state
// so the UI doesn't get stuck showing a stale "connected" status.
// A failed module-level disconnect shouldn't surface as an error or leave
// the UI stuck — we clear local state below regardless.
} finally {
// A disconnect is an explicit, intentional teardown — clear local state
// whether or not the module's own call succeeded.
setPersistedWalletId(null);
useWalletStore.getState().setAddress(null);
useWalletStore.getState().clearSession();
}
}

let restoreInFlight: Promise<void> | null = null;

/**
* Call once on app load. If a wallet was connected in a previous session,
* re-initializes the kit with that wallet selected and refreshes the
* address. If the wallet is no longer reachable, clears the stale session.
* re-selects it and refreshes the address.
*
* The persisted session is treated as the source of truth: a failed refresh
* (RPC blip, wallet extension still loading, account locked) is reported as a
* recoverable error but does NOT erase the saved wallet — the optimistically
* restored address keeps the user "connected" and a later retry can reconcile.
* Only an explicit disconnect clears persistence.
*
* Safe to call multiple times: concurrent calls share a single in-flight
* promise, so React StrictMode's double-invoked effects can't race each other
* into clearing a just-restored session.
*/
export async function restoreWalletSession(): Promise<void> {
export function restoreWalletSession(): Promise<void> {
ensureInitialized();

if (restoreInFlight) return restoreInFlight;

const walletId = getPersistedWalletId();
if (!walletId) return;
if (!walletId) return Promise.resolve();

try {
StellarWalletsKit.setWallet(walletId);
await StellarWalletsKit.fetchAddress();
} catch {
setPersistedWalletId(null);
useWalletStore.getState().setAddress(null);
}
const store = useWalletStore.getState();
store.setReconnecting(true);

restoreInFlight = (async () => {
try {
StellarWalletsKit.setWallet(walletId);
await StellarWalletsKit.fetchAddress();
} catch (err) {
store.setError(describeError(err));
} finally {
store.setReconnecting(false);
restoreInFlight = null;
}
})();

return restoreInFlight;
}

function describeError(err: unknown): string {
Expand Down
Loading
Loading