diff --git a/src/app/pages/Receive.tsx b/src/app/pages/Receive.tsx index 76696f59..d5dab845 100644 --- a/src/app/pages/Receive.tsx +++ b/src/app/pages/Receive.tsx @@ -15,6 +15,7 @@ import { formatBigInt } from 'lib/i18n/numbers'; import { getFailedTransactions, getUncompletedTransactions, + initiateBatchConsumeTransaction, initiateConsumeTransaction, verifyStuckTransactionsFromNode, waitForConsumeTx @@ -122,8 +123,12 @@ export const Receive: React.FC = () => { // 1. Check local IndexedDB for failed consume transactions const failedTxs = await getFailedTransactions(); for (const tx of failedTxs) { - if (tx.type === 'consume' && tx.noteId) { - failedIds.add(tx.noteId); + if (tx.type === 'consume') { + // Support batch consume: check noteIds array first, then fallback to noteId + const txNoteIds = (tx as any).noteIds ?? (tx.noteId ? [tx.noteId] : []); + for (const nid of txNoteIds) { + failedIds.add(nid); + } } } @@ -184,65 +189,50 @@ export const Receive: React.FC = () => { const noteIds = freshUnclaimedNotes.map(n => n!.id); setClaimingNoteIds(new Set(noteIds)); - // Track results - let succeeded = 0; - let failed = 0; - let queueFailed = 0; - // Clear previous failures setFailedNoteIds(new Set()); try { - // Queue all transactions first, before opening loading page - // This ensures all notes get queued even if the popup closes - const transactionIds: { noteId: string; txId: string }[] = []; - for (const note of freshUnclaimedNotes) { - try { - const id = await initiateConsumeTransaction(account.publicKey, note, isDelegatedProvingEnabled); - transactionIds.push({ noteId: note.id, txId: id }); - } catch (err) { - console.error('Error queuing note for claim:', note.id, err); - queueFailed++; - // Mark as failed and remove from claiming set - setFailedNoteIds(prev => new Set(prev).add(note.id)); - setClaimingNoteIds(prev => { - const next = new Set(prev); - next.delete(note.id); - return next; - }); - } + // Batch consume: create a single transaction for all notes + const txId = await initiateBatchConsumeTransaction( + account.publicKey, + freshUnclaimedNotes, + isDelegatedProvingEnabled + ); + + if (!txId) { + return; } - // Open loading page (popup stays open since tab is not active) + // Open loading page useWalletStore.getState().openTransactionModal(); - // Wait for all transactions to complete - for (const { noteId, txId } of transactionIds) { - if (signal.aborted) break; + // Wait for the single batch transaction to complete + if (!signal.aborted) { try { await waitForConsumeTx(txId, signal); - succeeded++; } catch (err) { - if (err instanceof DOMException && err.name === 'AbortError') { - break; + if (!(err instanceof DOMException && err.name === 'AbortError')) { + console.error('Error waiting for batch transaction:', txId, err); + // Mark all notes as failed + setFailedNoteIds(new Set(noteIds)); } - console.error('Error waiting for transaction:', txId, err); - failed++; - // Mark this note as failed - setFailedNoteIds(prev => new Set(prev).add(noteId)); } - // Note: Don't remove from claimingNoteIds here - keep spinner visible - // until mutateClaimableNotes() refreshes the list and removes the note } // Refresh the list - this will remove successfully claimed notes await mutateClaimableNotes(); - // Navigate to home on mobile after claiming all notes (only if all succeeded) - failed += queueFailed; - if (isMobile() && failed === 0) { - navigate('/', HistoryAction.Replace); + // Navigate to home on mobile after claiming all notes + if (isMobile()) { + const remaining = await mutateClaimableNotes(); + if (!remaining || remaining.length === 0) { + navigate('/', HistoryAction.Replace); + } } + } catch (err) { + console.error('Error initiating batch consume:', err); + setFailedNoteIds(new Set(noteIds)); } finally { setClaimingNoteIds(new Set()); } diff --git a/src/lib/miden/activity/transactions.ts b/src/lib/miden/activity/transactions.ts index fa318da4..dab23b67 100644 --- a/src/lib/miden/activity/transactions.ts +++ b/src/lib/miden/activity/transactions.ts @@ -36,7 +36,7 @@ import { compareAccountIds } from './utils'; // On mobile, use a shorter timeout since there's no background processing // On desktop extension, transactions can run in background tabs -export const MAX_WAIT_BEFORE_CANCEL = isMobile() ? 2 * 60_000 : 30 * 60_000; // 2 mins on mobile, 30 mins on desktop +export const MAX_WAIT_BEFORE_CANCEL = isMobile() ? 2 * 60_000 : 5 * 60_000; // 2 mins on mobile, 5 mins on desktop export const requestCustomTransaction = async ( accountId: string, @@ -155,6 +155,39 @@ export const initiateConsumeTransaction = async ( return dbTransaction.id; }; +/** + * Initiate a batch consume transaction for multiple notes at once. + * Creates a single transaction that consumes all notes in one go. + */ +export const initiateBatchConsumeTransaction = async ( + accountId: string, + notes: ConsumableNote[], + delegateTransaction?: boolean +): Promise => { + if (notes.length === 0) throw new Error('No notes to consume'); + if (notes.length === 1) return initiateConsumeTransaction(accountId, notes[0], delegateTransaction); + + const uncompletedTransactions = await getUncompletedTransactions(accountId); + const uncompletedNoteIds = new Set( + uncompletedTransactions + .filter((tx): tx is ConsumeTransaction => tx.type === 'consume') + .flatMap(tx => (tx as ConsumeTransaction).noteIds ?? [tx.noteId]) + ); + + // Filter out notes that are already being consumed + const newNotes = notes.filter(n => !uncompletedNoteIds.has(n.id)); + if (newNotes.length === 0) { + // All notes already have transactions, return the first existing one + const existing = uncompletedTransactions.find(tx => tx.type === 'consume'); + return existing?.id ?? ''; + } + + const dbTransaction = new ConsumeTransaction(accountId, newNotes, delegateTransaction); + await Repo.transactions.add(dbTransaction); + + return dbTransaction.id; +}; + // Timeout for waiting on consume transactions (5 minutes) const WAIT_FOR_CONSUME_TX_TIMEOUT = 5 * 60_000; @@ -201,26 +234,46 @@ export const waitForConsumeTx = async (id: string, signal?: AbortSignal): Promis }; export const completeConsumeTransaction = async (id: string, result: TransactionResult) => { - const note = result.executedTransaction().inputNotes().notes()[0].note(); - const sender = getBech32AddressFromAccountId(note.metadata().sender()); const executedTransaction = result.executedTransaction(); + const inputNotes = executedTransaction.inputNotes().notes(); + const noteCount = inputNotes.length; + const firstNote = inputNotes[0].note(); + const sender = getBech32AddressFromAccountId(firstNote.metadata().sender()); const dbTransaction = await Repo.transactions.where({ id }).first(); const reclaimed = compareAccountIds(dbTransaction?.accountId ?? '', sender); - const displayMessage = reclaimed ? 'Reclaimed' : 'Received'; + + // For batch consume, sum up all amounts + let totalAmount = 0n; + let faucetId = ''; + for (let i = 0; i < noteCount; i++) { + const note = inputNotes[i].note(); + const assets = note.assets().fungibleAssets(); + if (assets.length > 0) { + totalAmount += assets[0].amount(); + if (!faucetId) { + faucetId = getBech32AddressFromAccountId(assets[0].faucetId()); + } + } + } + + const displayMessage = reclaimed + ? noteCount > 1 + ? `Reclaimed ${noteCount} notes` + : 'Reclaimed' + : noteCount > 1 + ? `Received ${noteCount} notes` + : 'Received'; const secondaryAccountId = reclaimed ? undefined : sender; - const asset = note.assets().fungibleAssets()[0]; - const faucetId = getBech32AddressFromAccountId(asset.faucetId()); - const amount = asset.amount(); await updateTransactionStatus(id, ITransactionStatus.Completed, { displayMessage, transactionId: executedTransaction.id().toHex(), secondaryAccountId, faucetId, - amount, - noteType: toNoteTypeString(note.metadata().noteType()), - completedAt: Date.now() / 1000, // Convert to seconds. + amount: totalAmount, + noteType: toNoteTypeString(firstNote.metadata().noteType()), + completedAt: Date.now() / 1000, resultBytes: result.serialize() }); }; @@ -504,10 +557,12 @@ export const verifyStuckTransactionsFromNode = async (): Promise => { // Check each stuck consume transaction (AutoSync handles syncState separately) for (const tx of consumeTransactions) { try { + // Support batch consume: check all noteIds (fallback to single noteId) + const txNoteIds = (tx as ConsumeTransaction).noteIds ?? [tx.noteId]; const noteDetails = await withWasmClientLock(async () => { const midenClient = await getMidenClient(); - const noteId = NoteId.fromHex(tx.noteId); - const noteFilter = new NoteFilter(NoteFilterTypes.List, [noteId]); + const noteIdObjects = txNoteIds.map(id => NoteId.fromHex(id)); + const noteFilter = new NoteFilter(NoteFilterTypes.List, noteIdObjects); return await midenClient.getInputNoteDetails(noteFilter); }); @@ -515,26 +570,30 @@ export const verifyStuckTransactionsFromNode = async (): Promise => { continue; } - const note = noteDetails[0]; - - if (CONSUMED_NOTE_STATES.includes(note.state)) { - // Note has been consumed on-chain - mark transaction as completed + // For batch consume, check all notes' states + const allConsumed = noteDetails.every(n => CONSUMED_NOTE_STATES.includes(n.state)); + const anyInvalid = noteDetails.some(n => n.state === InputNoteState.Invalid); + const allClaimable = noteDetails.every( + n => + n.state === InputNoteState.Committed || + n.state === InputNoteState.Expected || + n.state === InputNoteState.Unverified + ); + + if (allConsumed) { + // All notes consumed on-chain - mark transaction as completed + const noteCount = noteDetails.length; await updateTransactionStatus(tx.id, ITransactionStatus.Completed, { - displayMessage: 'Received', + displayMessage: noteCount > 1 ? `Received ${noteCount} notes` : 'Received', completedAt: Date.now() / 1000 }); resolvedCount++; - } else if (note.state === InputNoteState.Invalid) { - // Note is invalid - mark transaction as failed - await cancelTransaction(tx, 'Note is invalid'); + } else if (anyInvalid) { + // At least one note is invalid - mark transaction as failed + await cancelTransaction(tx, 'One or more notes are invalid'); resolvedCount++; - } else if ( - note.state === InputNoteState.Committed || - note.state === InputNoteState.Expected || - note.state === InputNoteState.Unverified - ) { - // Note is still claimable - only cancel if tx has been processing for a while - // This prevents cancelling transactions that are actively being processed + } else if (allClaimable) { + // Notes still claimable - only cancel if tx has been processing for a while const processingTime = tx.processingStartedAt ? Date.now() - tx.processingStartedAt : 0; if (processingTime > MIN_PROCESSING_TIME_BEFORE_STUCK) { await cancelTransaction(tx, 'Transaction was interrupted'); @@ -656,10 +715,20 @@ export const generateTransactionsLoop = async ( // Import any notes needed for queued transactions await importAllNotes(); - // Wait for other in progress transactions + // Wait for other in progress transactions, but auto-cancel stuck ones const inProgressTransactions = await getTransactionsInProgress(); if (inProgressTransactions.length > 0) { - return; + const stuckTxs = inProgressTransactions.filter( + tx => tx.processingStartedAt && Date.now() - tx.processingStartedAt > MIN_PROCESSING_TIME_BEFORE_STUCK + ); + if (stuckTxs.length > 0) { + for (const tx of stuckTxs) { + logger.warning('Auto-cancelling stuck transaction', tx.id); + await cancelTransaction(tx, 'Transaction was stuck and auto-cancelled'); + } + } else { + return; // Transactions are actively processing, wait + } } // Find transactions waiting to process diff --git a/src/lib/miden/db/types.ts b/src/lib/miden/db/types.ts index 45eaea16..5d804197 100644 --- a/src/lib/miden/db/types.ts +++ b/src/lib/miden/db/types.ts @@ -143,6 +143,7 @@ export class ConsumeTransaction implements ITransaction { accountId: string; amount?: bigint; noteId: string; + noteIds: string[]; secondaryAccountId?: string; faucetId: string; transactionId?: string; @@ -154,18 +155,24 @@ export class ConsumeTransaction implements ITransaction { displayIcon: ITransactionIcon; delegateTransaction?: boolean; - constructor(accountId: string, note: ConsumableNote, delegateTransaction?: boolean) { + constructor(accountId: string, notes: ConsumableNote | ConsumableNote[], delegateTransaction?: boolean) { + const noteArray = Array.isArray(notes) ? notes : [notes]; + const firstNote = noteArray[0]; this.id = uuid(); this.type = 'consume'; this.accountId = accountId; - this.noteId = note.id; - this.faucetId = note.faucetId; - this.secondaryAccountId = note.senderAddress; - this.amount = note.amount !== '' ? BigInt(note.amount) : undefined; + this.noteId = firstNote.id; + this.noteIds = noteArray.map(n => n.id); + this.faucetId = firstNote.faucetId; + this.secondaryAccountId = firstNote.senderAddress; + this.amount = noteArray.reduce((sum, n) => { + const noteAmount = n.amount !== '' ? BigInt(n.amount) : 0n; + return sum + noteAmount; + }, 0n); this.status = ITransactionStatus.Queued; this.initiatedAt = Date.now(); this.displayIcon = 'RECEIVE'; - this.displayMessage = 'Consuming'; + this.displayMessage = noteArray.length > 1 ? `Consuming ${noteArray.length} notes` : 'Consuming'; this.delegateTransaction = delegateTransaction; } } diff --git a/src/lib/miden/front/autoSync.ts b/src/lib/miden/front/autoSync.ts index 3bff7a05..c80e4c0e 100644 --- a/src/lib/miden/front/autoSync.ts +++ b/src/lib/miden/front/autoSync.ts @@ -1,4 +1,3 @@ -import { isMobile } from 'lib/platform'; import { WalletState, WalletStatus } from 'lib/shared/types'; import { useWalletStore } from 'lib/store'; @@ -75,8 +74,8 @@ export class Sync { return; } - // On mobile, don't sync while transaction modal is open to avoid lock contention - if (isMobile() && storeState.isTransactionModalOpen) { + // Don't sync while transaction modal is open to avoid WASM mutex contention with consume + if (storeState.isTransactionModalOpen) { console.log('[AutoSync] Skipping sync while transaction modal is open'); await sleep(3000); await this.sync(); diff --git a/src/lib/miden/front/claimable-notes.ts b/src/lib/miden/front/claimable-notes.ts index 15bb6794..d503ba5b 100644 --- a/src/lib/miden/front/claimable-notes.ts +++ b/src/lib/miden/front/claimable-notes.ts @@ -2,6 +2,7 @@ import { useCallback, useRef } from 'react'; import { getUncompletedTransactions } from 'lib/miden/activity'; import { isIOS } from 'lib/platform'; +import { useWalletStore } from 'lib/store'; import { useRetryableSWR } from 'lib/swr'; import { isMidenFaucet } from '../assets'; @@ -166,7 +167,13 @@ export function useClaimableNotes(publicAddress: string, enabled: boolean = true const uncompletedTxs = await getUncompletedTransactions(publicAddress); const notesBeingClaimed = new Set( - uncompletedTxs.filter(tx => tx.type === 'consume' && tx.noteId != null).map(tx => tx.noteId!) + uncompletedTxs + .filter(tx => tx.type === 'consume') + .flatMap(tx => { + const noteIds = (tx as any).noteIds; + if (Array.isArray(noteIds)) return noteIds; + return tx.noteId ? [tx.noteId] : []; + }) ); // 1) Parse notes and collect faucet ids @@ -213,11 +220,14 @@ export function useClaimableNotes(publicAddress: string, enabled: boolean = true return result; }, [publicAddress, allTokensBaseMetadataRef, fetchMetadata, setTokensBaseMetadata]); + const isTransactionModalOpen = useWalletStore(s => s.isTransactionModalOpen); + const key = enabled ? ['claimable-notes', publicAddress] : null; const swrResult = useRetryableSWR(key, enabled ? fetchClaimableNotes : null, { revalidateOnFocus: false, dedupingInterval: 10_000, - refreshInterval: 5_000, + // Stop polling during consume to avoid WASM mutex contention + refreshInterval: isTransactionModalOpen ? 0 : 5_000, onError: e => { console.error('Error fetching claimable notes:', e); debugInfoRef.current = { diff --git a/src/lib/miden/sdk/miden-client-interface.ts b/src/lib/miden/sdk/miden-client-interface.ts index 224d7793..237b5f7e 100644 --- a/src/lib/miden/sdk/miden-client-interface.ts +++ b/src/lib/miden/sdk/miden-client-interface.ts @@ -148,9 +148,10 @@ export class MidenClientInterface { } async consumeNoteId(transaction: ConsumeTransaction): Promise { - const { accountId, noteId } = transaction; + const { accountId } = transaction; + const noteIds = transaction.noteIds ?? [transaction.noteId]; - const notes = await this.getNotesByIds([noteId]); + const notes = await this.getNotesByIds(noteIds); const consumeTransactionRequest = this.webClient.newConsumeTransactionRequest(notes); let consumeTransactionResult = await this.webClient.executeTransaction( accountIdStringToSdk(accountId),