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
208 changes: 208 additions & 0 deletions src/app/pages/Explore.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
import React from 'react';

import { createRoot } from 'react-dom/client';
import { act } from 'react-dom/test-utils';

import Explore from './Explore';

const mockInitiateConsumeTransaction = jest.fn();
const mockMutateClaimableNotes = jest.fn();
const mockUseClaimableNotes = jest.fn();
const mockUseRetryableSWR = jest.fn();
const mockOpenTransactionModal = jest.fn();

jest.mock('app/env', () => ({
useAppEnv: () => ({ fullPage: false })
}));

jest.mock('app/hooks/useMidenFaucetId', () => ({
__esModule: true,
default: () => 'faucet-1'
}));

jest.mock('app/icons/faucet.svg', () => ({
ReactComponent: () => null
}));

jest.mock('app/icons/receive.svg', () => ({
ReactComponent: () => null
}));

jest.mock('app/icons/send.svg', () => ({
ReactComponent: () => null
}));

jest.mock('app/layouts/PageLayout/Footer', () => () => null);

jest.mock('app/layouts/PageLayout/Header', () => () => null);

jest.mock('app/templates/AddressChip', () => () => null);

jest.mock('components/ChainInstabilityBanner', () => ({
ChainInstabilityBanner: () => null
}));

jest.mock('components/ConnectivityIssueBanner', () => ({
ConnectivityIssueBanner: () => null
}));

jest.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}));

jest.mock('lib/miden/activity', () => ({
getFailedConsumeTransactions: jest.fn(),
hasQueuedTransactions: jest.fn(),
initiateConsumeTransaction: (...args: any[]) => mockInitiateConsumeTransaction(...args),
startBackgroundTransactionProcessing: jest.fn()
}));

jest.mock('lib/miden-chain/faucet', () => ({
getFaucetUrl: jest.fn(() => 'https://faucet.test')
}));

jest.mock('lib/miden/front', () => ({
setFaucetIdSetting: jest.fn(),
useAccount: () => ({ publicKey: 'acc-1' }),
useAllBalances: () => ({ data: [] }),
useAllTokensBaseMetadata: () => ({}),
useMidenContext: () => ({ signTransaction: jest.fn() }),
useNetwork: () => ({ id: 'devnet' })
}));

jest.mock('lib/miden/front/claimable-notes', () => ({
useClaimableNotes: () => mockUseClaimableNotes()
}));

jest.mock('lib/settings/helpers', () => ({
isAutoConsumeEnabled: () => true,
isDelegateProofEnabled: () => false
}));

jest.mock('lib/swr', () => ({
useRetryableSWR: (...args: any[]) => mockUseRetryableSWR(...args)
}));

jest.mock('lib/ui/useTippy', () => ({
__esModule: true,
default: () => jest.fn()
}));

jest.mock('lib/woozie', () => ({
Link: ({ children }: { children: React.ReactNode }) => <button type="button">{children}</button>,
navigate: jest.fn()
}));

jest.mock('lib/mobile/faucet-webview', () => ({
openFaucetWebview: jest.fn()
}));

jest.mock('lib/mobile/haptics', () => ({
hapticLight: jest.fn()
}));

jest.mock('lib/platform', () => ({
isMobile: () => false
}));

jest.mock('lib/store', () => {
const useWalletStore = (selector: (state: { isTransactionModalDismissedByUser: boolean }) => boolean) =>
selector({ isTransactionModalDismissedByUser: false });
useWalletStore.getState = () => ({ openTransactionModal: mockOpenTransactionModal });
return { useWalletStore };
});

jest.mock('utils/miden', () => ({
isHexAddress: () => false
}));

jest.mock('./Explore/MainBanner', () => () => null);

jest.mock('./Explore/Tokens', () => () => null);

describe('Explore auto-consume', () => {
let testRoot: ReturnType<typeof createRoot> | null = null;
let testContainer: HTMLDivElement | null = null;
let consoleWarnSpy: jest.SpyInstance;

const createMockNote = (overrides = {}) => ({
id: 'note-123',
faucetId: 'faucet-1',
amount: '1000',
senderAddress: 'sender-1',
isBeingClaimed: false,
...overrides
});

const setupSWR = (failedData: unknown) => {
mockUseRetryableSWR.mockImplementation((key: unknown) => {
if (Array.isArray(key) && key[0] === 'failed-transactions') {
return { data: failedData };
}
if (Array.isArray(key) && key[0] === 'has-queued-transactions') {
return { data: false };
}
return { data: undefined };
});
};

beforeAll(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
});

afterAll(() => {
delete (globalThis as any).IS_REACT_ACT_ENVIRONMENT;
});

beforeEach(() => {
jest.clearAllMocks();
mockInitiateConsumeTransaction.mockResolvedValue('tx-id-123');
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(async () => {
consoleWarnSpy.mockRestore();
if (testRoot) {
await act(async () => {
testRoot!.unmount();
});
testRoot = null;
}
if (testContainer) {
testContainer.remove();
testContainer = null;
}
});

it('does not auto-consume while failed consume transactions are loading', async () => {
setupSWR(undefined);
mockUseClaimableNotes.mockReturnValue({ data: [createMockNote()], mutate: mockMutateClaimableNotes });

testContainer = document.createElement('div');
testRoot = createRoot(testContainer);

await act(async () => {
testRoot!.render(<Explore />);
});

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

expect(mockInitiateConsumeTransaction).not.toHaveBeenCalled();
});

it('skips auto-consume for notes with failed consume transactions', async () => {
setupSWR([{ noteId: 'note-123' }]);
mockUseClaimableNotes.mockReturnValue({ data: [createMockNote()], mutate: mockMutateClaimableNotes });

testContainer = document.createElement('div');
testRoot = createRoot(testContainer);

await act(async () => {
testRoot!.render(<Explore />);
});

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

expect(mockInitiateConsumeTransaction).not.toHaveBeenCalled();
});
});
23 changes: 21 additions & 2 deletions src/app/pages/Explore.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { TestIDProps } from 'lib/analytics';
import { MIDEN_NETWORK_NAME, MIDEN_FAUCET_ENDPOINTS } from 'lib/miden-chain/constants';
import { getFaucetUrl } from 'lib/miden-chain/faucet';
import {
getFailedConsumeTransactions,
hasQueuedTransactions,
initiateConsumeTransaction,
startBackgroundTransactionProcessing
Expand Down Expand Up @@ -74,6 +75,19 @@ const Explore: FC = () => {
await openFaucetWebview({ url: faucetUrl, title: t('midenFaucet'), recipientAddress: address });
}, [network.id, t, address]);

const { data: failedConsumeTransactions } = useRetryableSWR(
[`failed-transactions`, address],
async () => getFailedConsumeTransactions(address),
{
revalidateOnMount: true,
refreshInterval: 15_000,
dedupingInterval: 10_000
}
);
const failedConsumeNoteIds = useMemo(() => {
return new Set(failedConsumeTransactions?.map(tx => tx.noteId) ?? []);
}, [failedConsumeTransactions]);
const hasLoadedFailedConsumeTransactions = failedConsumeTransactions !== undefined;
const midenNotes = useMemo(() => {
if (!shouldAutoConsume || !claimableNotes) {
return [];
Expand All @@ -95,7 +109,7 @@ const Explore: FC = () => {
}, [midenNotes]);

const autoConsumeMidenNotes = useCallback(async () => {
if (!shouldAutoConsume || !hasAutoConsumableNotes) {
if (!shouldAutoConsume || !hasAutoConsumableNotes || !hasLoadedFailedConsumeTransactions) {
return;
}

Expand All @@ -104,8 +118,11 @@ const Explore: FC = () => {
if (notesToClaim.length === 0) {
return;
}

const promises = notesToClaim.map(async note => {
if (failedConsumeNoteIds.has(note.id)) {
console.warn('Skipping auto-consume for note with previous failed transaction', note.id);
return;
}
await initiateConsumeTransaction(account.publicKey, note, isDelegatedProvingEnabled);
});
await Promise.all(promises);
Expand All @@ -115,6 +132,8 @@ const Explore: FC = () => {
startBackgroundTransactionProcessing(signTransaction);
}, [
midenNotes,
failedConsumeNoteIds,
hasLoadedFailedConsumeTransactions,
isDelegatedProvingEnabled,
mutateClaimableNotes,
account.publicKey,
Expand Down
56 changes: 56 additions & 0 deletions src/lib/miden/activity/transactions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getTransactionsInProgress,
getAllUncompletedTransactions,
getFailedTransactions,
getFailedConsumeTransactions,
getCompletedTransactions,
getTransactionById,
cancelTransactionById,
Expand Down Expand Up @@ -130,6 +131,61 @@ describe('transactions utilities', () => {
});
});

describe('getFailedConsumeTransactions', () => {
it('returns failed consume transactions for account sorted by initiatedAt', async () => {
const txs = [
{
id: 'tx-1',
status: ITransactionStatus.Failed,
type: 'consume',
accountId: 'acc-1',
initiatedAt: 200,
noteId: 'note-1'
},
{
id: 'tx-2',
status: ITransactionStatus.Failed,
type: 'send',
accountId: 'acc-1',
initiatedAt: 100,
noteId: 'note-2'
},
{
id: 'tx-3',
status: ITransactionStatus.Failed,
type: 'consume',
accountId: 'acc-2',
initiatedAt: 50,
noteId: 'note-3'
},
{
id: 'tx-4',
status: ITransactionStatus.Completed,
type: 'consume',
accountId: 'acc-1',
initiatedAt: 10,
noteId: 'note-4'
},
{
id: 'tx-5',
status: ITransactionStatus.Failed,
type: 'consume',
accountId: 'acc-1_tag',
initiatedAt: 150,
noteId: 'note-5'
}
];

mockTransactionsFilter.mockImplementationOnce(predicate => ({
toArray: jest.fn().mockResolvedValueOnce(txs.filter(predicate))
}));

const result = await getFailedConsumeTransactions('acc-1');

expect(result.map(tx => tx.id)).toEqual(['tx-5', 'tx-1']);
});
});

describe('getCompletedTransactions', () => {
it('returns completed transactions for account', async () => {
const txs = [
Expand Down
11 changes: 11 additions & 0 deletions src/lib/miden/activity/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,17 @@ export const getFailedTransactions = async () => {
return transactions;
};

export const getFailedConsumeTransactions = async (accountId: string) => {
let transactions = await Repo.transactions
.filter(
tx =>
tx.status === ITransactionStatus.Failed && tx.type === 'consume' && compareAccountIds(tx.accountId, accountId)
)
.toArray();
transactions.sort((tx1, tx2) => tx1.initiatedAt - tx2.initiatedAt);
return transactions;
};

export const getCompletedTransactions = async (
accountId: string,
offset?: number,
Expand Down
Loading