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
2 changes: 1 addition & 1 deletion src/lib/miden/metadata/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isExtension } from 'lib/platform';
import { AssetMetadata } from './types';

// Get asset URL that works on extension, mobile, and desktop
function getAssetUrl(path: string): string {
export function getAssetUrl(path: string): string {
if (!isExtension()) {
// On mobile/desktop, use relative URL from web root
return `/${path}`;
Expand Down
92 changes: 81 additions & 11 deletions src/lib/store/utils/fetchBalances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import { fetchBalances } from './fetchBalances';

// Mock dependencies
const mockGetAccount = jest.fn();
const mockImportAccountById = jest.fn();
const mockSyncState = jest.fn();
const mockGetMidenClient = jest.fn(() => ({
getAccount: mockGetAccount,
importAccountById: mockImportAccountById,
syncState: mockSyncState
}));

Expand All @@ -25,9 +27,18 @@ jest.mock('lib/miden/sdk/helpers', () => ({
getBech32AddressFromAccountId: jest.fn((id: string) => `bech32-${id}`)
}));

// Mock BasicFungibleFaucetComponent for inline metadata extraction
const mockFromAccount = jest.fn();
jest.mock('@demox-labs/miden-sdk', () => ({
BasicFungibleFaucetComponent: {
fromAccount: (account: unknown) => mockFromAccount(account)
}
}));

jest.mock('lib/miden/metadata', () => ({
fetchTokenMetadata: jest.fn(),
MIDEN_METADATA: { name: 'Miden', symbol: 'MIDEN', decimals: 8 }
MIDEN_METADATA: { name: 'Miden', symbol: 'MIDEN', decimals: 8 },
DEFAULT_TOKEN_METADATA: { name: 'Unknown', symbol: 'Unknown', decimals: 6 },
getAssetUrl: jest.fn((path: string) => `/${path}`)
}));

jest.mock('../../miden/front/assets', () => ({
Expand All @@ -39,7 +50,9 @@ describe('fetchBalances', () => {

beforeEach(() => {
mockGetAccount.mockReset();
mockImportAccountById.mockReset();
mockSyncState.mockReset();
mockFromAccount.mockReset();
});

beforeAll(() => {
Expand Down Expand Up @@ -137,30 +150,87 @@ describe('fetchBalances', () => {
expect(result[0].tokenSlug).toBe('MIDEN');
});

it('calls setAssetsMetadata when provided', async () => {
it('fetches metadata inline and calls setAssetsMetadata', async () => {
const mockSetAssetsMetadata = jest.fn();
const { fetchTokenMetadata } = jest.requireMock('lib/miden/metadata');
fetchTokenMetadata.mockResolvedValueOnce({
base: { name: 'New Token', symbol: 'NEW', decimals: 6 }
const { setTokensBaseMetadata } = jest.requireMock('../../miden/front/assets');

const mockFaucetAccount = { id: 'faucet-account' };
const mockAssets = [
{
faucetId: () => 'new-faucet',
amount: () => ({ toString: () => '1000000' })
}
];

// First call returns user account, second call returns faucet account for metadata
mockGetAccount
.mockResolvedValueOnce({
vault: () => ({
fungibleAssets: () => mockAssets
})
})
.mockResolvedValueOnce(mockFaucetAccount);

// Mock the faucet metadata extraction
mockFromAccount.mockReturnValueOnce({
decimals: () => 6,
symbol: () => ({ toString: () => 'NEW' })
});

await fetchBalances('my-address', {}, { setAssetsMetadata: mockSetAssetsMetadata });

// Should fetch faucet account to get metadata
expect(mockGetAccount).toHaveBeenCalledWith('bech32-new-faucet');
// Should call setAssetsMetadata with fetched metadata
expect(mockSetAssetsMetadata).toHaveBeenCalledWith({
'bech32-new-faucet': expect.objectContaining({
symbol: 'NEW',
decimals: 6
})
});
// Should persist metadata
expect(setTokensBaseMetadata).toHaveBeenCalled();
});

it('calls importAccountById when faucet account not found locally', async () => {
const mockSetAssetsMetadata = jest.fn();
const mockFaucetAccount = { id: 'faucet-account' };
const mockAssets = [
{
faucetId: () => 'new-faucet',
amount: () => ({ toString: () => '1000000' })
}
];

mockGetAccount.mockResolvedValueOnce({
vault: () => ({
fungibleAssets: () => mockAssets
// First call returns user account, second returns null (not found), third returns imported account
mockGetAccount
.mockResolvedValueOnce({
vault: () => ({
fungibleAssets: () => mockAssets
})
})
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(mockFaucetAccount);

mockImportAccountById.mockResolvedValueOnce(undefined);

// Mock the faucet metadata extraction
mockFromAccount.mockReturnValueOnce({
decimals: () => 8,
symbol: () => ({ toString: () => 'IMPORTED' })
});

await fetchBalances('my-address', {}, { setAssetsMetadata: mockSetAssetsMetadata });

// Metadata is now fetched inline, so callback should have been called
expect(fetchTokenMetadata).toHaveBeenCalledWith('bech32-new-faucet');
// Should try to import the faucet account when not found locally
expect(mockImportAccountById).toHaveBeenCalledWith('bech32-new-faucet');
// Should call setAssetsMetadata with imported metadata
expect(mockSetAssetsMetadata).toHaveBeenCalledWith({
'bech32-new-faucet': expect.objectContaining({
symbol: 'IMPORTED',
decimals: 8
})
});
});

it('reads from IndexedDB (getAccount) without calling syncState', async () => {
Expand Down
94 changes: 69 additions & 25 deletions src/lib/store/utils/fetchBalances.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { FungibleAsset } from '@demox-labs/miden-sdk';
import { BasicFungibleFaucetComponent, FungibleAsset } from '@demox-labs/miden-sdk';
import BigNumber from 'bignumber.js';

import { getFaucetIdSetting } from 'lib/miden/assets';
import { TokenBalanceData } from 'lib/miden/front/balance';
import { AssetMetadata, fetchTokenMetadata, MIDEN_METADATA } from 'lib/miden/metadata';
import { AssetMetadata, DEFAULT_TOKEN_METADATA, getAssetUrl, MIDEN_METADATA } from 'lib/miden/metadata';
import { getBech32AddressFromAccountId } from 'lib/miden/sdk/helpers';
import { getMidenClient, withWasmClientLock } from 'lib/miden/sdk/miden-client';

Expand All @@ -21,6 +21,12 @@ export interface FetchBalancesOptions {
*
* This is the single source of truth for balance fetching logic.
* Used by both the useAllBalances hook and the Zustand store action.
*
* IMPORTANT: All WASM client operations (getAccount, importAccountById) are done
* in a single lock acquisition to prevent AutoSync from blocking metadata fetches.
* Previously, metadata was fetched in a separate lock after releasing the initial lock,
* which caused non-MIDEN tokens to appear 30+ seconds late when AutoSync grabbed
* the lock in between.
*/
export async function fetchBalances(
address: string,
Expand All @@ -33,24 +39,73 @@ export async function fetchBalances(
// Local copy of metadata that we can add to during this fetch
const localMetadatas = { ...tokenMetadatas };

// Wrap all WASM client operations in a lock to prevent concurrent access
const { account, assets } = await withWasmClientLock(async () => {
// Get midenFaucetId early so we can use it inside the lock
const midenFaucetId = await getFaucetIdSetting();

// Wrap ALL WASM client operations in a single lock to prevent AutoSync from
// grabbing the lock between getAccount and metadata fetches
const { account, assets, fetchedMetadatas } = await withWasmClientLock(async () => {
const midenClient = await getMidenClient();
const acc = await midenClient.getAccount(address);

// Handle case where account doesn't exist
if (!acc) {
return { account: null, assets: [] as FungibleAsset[] };
return {
account: null,
assets: [] as FungibleAsset[],
fetchedMetadatas: {} as Record<string, AssetMetadata>
};
}

const acctAssets = acc.vault().fungibleAssets() as FungibleAsset[];
return { account: acc, assets: acctAssets };

// Fetch missing metadata INSIDE the lock to prevent race with AutoSync
const newMetadatas: Record<string, AssetMetadata> = {};

if (fetchMissingMetadata) {
for (const asset of acctAssets) {
const id = getBech32AddressFromAccountId(asset.faucetId());
// Skip MIDEN token and tokens we already have metadata for
if (id === midenFaucetId || localMetadatas[id]) {
continue;
}

try {
// Inline the logic from fetchTokenMetadata to avoid separate lock acquisition
let faucetAccount = await midenClient.getAccount(id);
if (!faucetAccount) {
await midenClient.importAccountById(id);
faucetAccount = await midenClient.getAccount(id);
}

if (faucetAccount) {
const faucetDetails = BasicFungibleFaucetComponent.fromAccount(faucetAccount);
const symbol = faucetDetails.symbol().toString();
newMetadatas[id] = {
decimals: faucetDetails.decimals(),
symbol,
name: symbol,
shouldPreferSymbol: true,
thumbnailUri: getAssetUrl('misc/token-logos/default.svg')
};
} else {
// Fallback if we couldn't get faucet account
newMetadatas[id] = DEFAULT_TOKEN_METADATA;
}
} catch (e) {
console.warn('Failed to fetch metadata for', id, e);
// Use default metadata on failure so token still appears
newMetadatas[id] = DEFAULT_TOKEN_METADATA;
}
}
}

return { account: acc, assets: acctAssets, fetchedMetadatas: newMetadatas };
});

// Handle case where account doesn't exist (outside the lock)
if (!account) {
console.warn(`Account not found: ${address}`);
const midenFaucetId = await getFaucetIdSetting();
return [
{
tokenId: midenFaucetId,
Expand All @@ -61,27 +116,16 @@ export async function fetchBalances(
}
];
}
const midenFaucetId = await getFaucetIdSetting();
let hasMiden = false;

// First pass: fetch missing metadata INLINE (so all tokens appear together)
if (fetchMissingMetadata) {
for (const asset of assets) {
const id = getBech32AddressFromAccountId(asset.faucetId());
if (id !== midenFaucetId && !localMetadatas[id]) {
try {
const { base } = await fetchTokenMetadata(id);
localMetadatas[id] = base;
await setTokensBaseMetadata({ [id]: base });
setAssetsMetadata?.({ [id]: base });
} catch (e) {
console.warn('Failed to fetch metadata for', id, e);
}
}
}
// Update metadata stores with newly fetched metadata (outside the lock)
for (const [id, metadata] of Object.entries(fetchedMetadatas)) {
localMetadatas[id] = metadata;
await setTokensBaseMetadata({ [id]: metadata });
setAssetsMetadata?.({ [id]: metadata });
}

// Second pass: build balance list
// Build balance list
let hasMiden = false;
for (const asset of assets) {
const tokenId = getBech32AddressFromAccountId(asset.faucetId());
const isMiden = tokenId === midenFaucetId;
Expand Down
Loading