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
74 changes: 32 additions & 42 deletions src/app/pages/Receive.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { formatBigInt } from 'lib/i18n/numbers';
import {
getFailedTransactions,
getUncompletedTransactions,
initiateBatchConsumeTransaction,
initiateConsumeTransaction,
verifyStuckTransactionsFromNode,
waitForConsumeTx
Expand Down Expand Up @@ -122,8 +123,12 @@ export const Receive: React.FC<ReceiveProps> = () => {
// 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);
}
}
}

Expand Down Expand Up @@ -184,65 +189,50 @@ export const Receive: React.FC<ReceiveProps> = () => {
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());
}
Expand Down
127 changes: 98 additions & 29 deletions src/lib/miden/activity/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string> => {
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;

Expand Down Expand Up @@ -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()
});
};
Expand Down Expand Up @@ -504,37 +557,43 @@ export const verifyStuckTransactionsFromNode = async (): Promise<number> => {
// 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);
});

if (noteDetails.length === 0) {
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');
Expand Down Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions src/lib/miden/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export class ConsumeTransaction implements ITransaction {
accountId: string;
amount?: bigint;
noteId: string;
noteIds: string[];
secondaryAccountId?: string;
faucetId: string;
transactionId?: string;
Expand All @@ -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;
}
}
Expand Down
5 changes: 2 additions & 3 deletions src/lib/miden/front/autoSync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { isMobile } from 'lib/platform';
import { WalletState, WalletStatus } from 'lib/shared/types';
import { useWalletStore } from 'lib/store';

Expand Down Expand Up @@ -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();
Expand Down
14 changes: 12 additions & 2 deletions src/lib/miden/front/claimable-notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
Loading