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: 3 additions & 2 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
Zero-Clause BSD
===============
BSD Zero Clause License

Copyright (c) 2026 QuickMythril and Qortium Minting contributors

Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
Expand Down
8 changes: 5 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ import { Blocks, type BlockOnlineState } from './views/Blocks';
import { Minters } from './views/Minters';
import { MyMinting, type SelectedMintingDetails } from './views/MyMinting';
import { Reference } from './views/Reference';
import { NodeSyncPill, Notice } from './ui';
import { AvatarActionsProvider, NodeSyncPill, Notice } from './ui';
import type {
AccountEnrichment,
BlockSummary,
Expand Down Expand Up @@ -787,7 +787,8 @@ export default function App() {
const canRemove = writeAvailable && hasAction(actions, 'REMOVE_MINTING_ACCOUNT');

return (
<main className="app-shell">
<AvatarActionsProvider actions={actions}>
<main className="app-shell">
<header className="topbar">
<div>
<p className="eyebrow">Minting <span className="app-version">{APP_VERSION}</span></p>
Expand Down Expand Up @@ -924,6 +925,7 @@ export default function App() {
) : null}

{tab === 'reference' ? <Reference /> : null}
</main>
</main>
</AvatarActionsProvider>
);
}
79 changes: 79 additions & 0 deletions src/avatarClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AVATAR_MAX_BYTES, fetchAccountAvatar, revokeAvatarObjectUrl } from './avatarClient';
import { qdnRequest } from './qdnRequest';

vi.mock('./qdnRequest', () => ({
hasAction: (actions: string[], ...candidates: string[]) =>
candidates.some((candidate) => actions.some((action) => action.toUpperCase() === candidate.toUpperCase())),
qdnRequest: vi.fn(),
}));

const ADDRESS = `Q${'1'.repeat(33)}`;

describe('pointer-aware account avatar client', () => {
const qdnRequestMock = vi.mocked(qdnRequest);
const createObjectURLMock = vi.fn((blob: Blob) => `blob:mock/${blob.type}`);
const revokeObjectURLMock = vi.fn();

beforeEach(() => {
qdnRequestMock.mockReset();
createObjectURLMock.mockClear();
revokeObjectURLMock.mockClear();
(URL as unknown as { createObjectURL: typeof createObjectURLMock }).createObjectURL = createObjectURLMock;
(URL as unknown as { revokeObjectURL: typeof revokeObjectURLMock }).revokeObjectURL = revokeObjectURLMock;
});

it('feature-gates avatar reads and validates the address before requesting', async () => {
await expect(fetchAccountAvatar(ADDRESS, ['FETCH_NODE_API'])).resolves.toEqual({ kind: 'unavailable' });
await expect(fetchAccountAvatar('not-an-address', ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ kind: 'unavailable' });
expect(qdnRequestMock).not.toHaveBeenCalled();
});

it.each([
['POINTER', { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }],
['LEGACY', null],
] as const)('creates a safe Blob URL for a ready %s avatar', async (source, descriptor) => {
qdnRequestMock.mockResolvedValueOnce({
address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor, encoding: 'base64', source,
});

await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({
kind: 'ready', source, src: 'blob:mock/image/png',
});
expect(qdnRequestMock).toHaveBeenCalledWith({ action: 'FETCH_ACCOUNT_AVATAR', address: ADDRESS, maxBytes: AVATAR_MAX_BYTES });
});

it('accepts BMP images and exposes explicit pending retries', async () => {
qdnRequestMock
.mockResolvedValueOnce({
address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/bmp',
descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' }, encoding: 'base64', source: 'POINTER',
})
.mockResolvedValueOnce({
address: ADDRESS, descriptor: { identifier: 'avatar', name: 'alice', service: 'THUMBNAIL' },
retryAfterSeconds: 99, source: 'POINTER', status: 'PENDING',
});

await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toMatchObject({ kind: 'ready', src: 'blob:mock/image/bmp' });
await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ kind: 'pending', retryAfterSeconds: 30, source: 'POINTER' });
});

it.each([
{ address: `Q${'2'.repeat(33)}`, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: ADDRESS, body: 'not base64!', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 9, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/svg+xml', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: AVATAR_MAX_BYTES + 1, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'LEGACY' },
{ address: ADDRESS, body: 'iVBORw0KGgo=', contentLength: 8, contentType: 'image/png', descriptor: null, encoding: 'base64', source: 'POINTER' },
])('rejects malformed or unsafe avatar responses', async (response) => {
qdnRequestMock.mockResolvedValueOnce(response);
await expect(fetchAccountAvatar(ADDRESS, ['FETCH_ACCOUNT_AVATAR'])).resolves.toEqual({ kind: 'unavailable' });
expect(createObjectURLMock).not.toHaveBeenCalled();
});

it('revokes only Blob URLs', () => {
revokeAvatarObjectUrl('https://node.example/avatar.png');
revokeAvatarObjectUrl('blob:mock/image/png');
expect(revokeObjectURLMock).toHaveBeenCalledTimes(1);
});
});
141 changes: 141 additions & 0 deletions src/avatarClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { useEffect, useState } from 'react';
import { hasAction, qdnRequest } from './qdnRequest';
import type { QdnAction } from './types';

export const AVATAR_MAX_BYTES = 500 * 1024;

const SAFE_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/bmp', 'image/webp']);
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/;

type AvatarSource = 'POINTER' | 'LEGACY';
type AvatarDescriptor = { identifier: string; name: string; service: string };

export type AccountAvatarFetch =
| { kind: 'pending'; retryAfterSeconds: number; source: AvatarSource }
| { kind: 'ready'; source: AvatarSource; src: string }
| { kind: 'unavailable' };

function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}

function text(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function isAccountAddress(value: string) {
return /^Q[1-9A-HJ-NP-Za-km-z]{20,80}$/.test(value);
}

function parseDescriptor(value: unknown): AvatarDescriptor | null {
if (!isRecord(value)) return null;
const service = text(value.service);
const name = text(value.name);
return service && name && typeof value.identifier === 'string' ? { identifier: value.identifier, name, service } : null;
}

function decodeBase64(value: string) {
if (!value || !BASE64_PATTERN.test(value)) return null;
try {
const binary = atob(value);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
} catch {
return null;
}
}

function parseAccountAvatar(value: unknown, expectedAddress: string): AccountAvatarFetch {
if (!isRecord(value) || value.address !== expectedAddress || (value.source !== 'POINTER' && value.source !== 'LEGACY')) {
return { kind: 'unavailable' };
}

const source = value.source;
if (source === 'POINTER' && !parseDescriptor(value.descriptor)) return { kind: 'unavailable' };

if (value.status === 'PENDING') {
const requestedDelay = typeof value.retryAfterSeconds === 'number' && Number.isFinite(value.retryAfterSeconds)
? value.retryAfterSeconds
: 5;
return { kind: 'pending', retryAfterSeconds: Math.min(Math.max(Math.floor(requestedDelay), 1), 30), source };
}

if (value.encoding !== 'base64' || typeof value.body !== 'string' || typeof value.contentType !== 'string') {
return { kind: 'unavailable' };
}

const contentType = value.contentType.toLowerCase().split(';', 1)[0];
const contentLength = value.contentLength;
const bytes = decodeBase64(value.body);

if (
!SAFE_IMAGE_MIME_TYPES.has(contentType)
|| typeof contentLength !== 'number'
|| !Number.isSafeInteger(contentLength)
|| contentLength < 1
|| contentLength > AVATAR_MAX_BYTES
|| !bytes
|| bytes.byteLength !== contentLength
) return { kind: 'unavailable' };

return { kind: 'ready', source, src: URL.createObjectURL(new Blob([bytes.buffer], { type: contentType })) };
}

/** Read one avatar through Home. The host owns legacy fallback; apps never make thumbnail URLs. */
export async function fetchAccountAvatar(address: string, actions?: QdnAction[]): Promise<AccountAvatarFetch> {
if (!isAccountAddress(address) || !hasAction(actions ?? [], 'FETCH_ACCOUNT_AVATAR')) return { kind: 'unavailable' };
try {
return parseAccountAvatar(
await qdnRequest<unknown>({ action: 'FETCH_ACCOUNT_AVATAR', address, maxBytes: AVATAR_MAX_BYTES }),
address,
);
} catch {
return { kind: 'unavailable' };
}
}

export function revokeAvatarObjectUrl(src: string | null | undefined) {
if (typeof src === 'string' && src.startsWith('blob:')) URL.revokeObjectURL(src);
}

/** Only mounted avatar controls call this hook, so list loading cannot batch-fetch image bytes. */
export function useAccountAvatar(address: string, actions?: QdnAction[]) {
const [src, setSrc] = useState<string | null>(null);

useEffect(() => {
let active = true;
let retryTimer: number | undefined;

const replace = (next: string | null) => {
setSrc((current) => {
if (current && current !== next) revokeAvatarObjectUrl(current);
return next;
});
};

const load = () => {
void fetchAccountAvatar(address, actions).then((result) => {
if (!active) {
if (result.kind === 'ready') revokeAvatarObjectUrl(result.src);
return;
}
if (result.kind === 'ready') {
replace(result.src);
} else if (result.kind === 'pending') {
retryTimer = window.setTimeout(load, result.retryAfterSeconds * 1_000);
} else {
replace(null);
}
});
};

replace(null);
load();
return () => {
active = false;
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
};
}, [actions, address]);

useEffect(() => () => revokeAvatarObjectUrl(src), [src]);
return src;
}
2 changes: 1 addition & 1 deletion src/coreApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('core API bridge helpers', () => {
]);

await expect(resolveIdentities(['Qabc', 'Qabc', ''], ['RESOLVE_IDENTITIES'])).resolves.toEqual([
{ address: 'Qabc', avatarSrc: 'http://node/avatar', name: 'alice' },
{ address: 'Qabc', name: 'alice' },
]);
expect(qdnRequestMock).toHaveBeenCalledWith({
action: 'RESOLVE_IDENTITIES',
Expand Down
38 changes: 16 additions & 22 deletions src/coreApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ const DEFAULT_RECENT_BLOCKS = 10;
// FETCH_NODE_API calls at once.
const BLOCK_FETCH_CONCURRENCY = 8;

// Cross-call caches for name/avatar lookups, which almost never change between
// Cross-call caches for name lookups, which almost never change between
// refreshes. Shared across getRecentBlocks, getBlockOnlineAccounts, and
// enrichMintingAccount so repeated refreshes/expands hit the cache instead of
// refetching. Entries store the in-flight Promise so concurrent lookups for the
// same address share a single request (true dedupe of the initial burst).
const AVATAR_CACHE_MAX_ENTRIES = 500;
const avatarProfileCache = new Map<string, Promise<IdentityProfile>>();
const IDENTITY_CACHE_MAX_ENTRIES = 500;
const identityProfileCache = new Map<string, Promise<IdentityProfile>>();

// Resolve a list of tasks with a bounded concurrency pool while preserving the
// input order in the returned array.
Expand Down Expand Up @@ -199,9 +199,8 @@ export async function getAccountNames(address: string, actions?: QdnAction[]) {

export const RESOLVE_IDENTITIES_LIMIT = 500;

// Home resolves account display identity (registered name + avatar URL) in one
// read-only bridge call. This keeps selected-account UI consistent with other
// QDN apps and avoids separate name/avatar requests.
// Home resolves account display identity in one read-only bridge call. Apps use
// its name field only; any legacy image hint never reaches UI state.
export async function resolveIdentities(addresses: string[], actions?: QdnAction[]): Promise<ResolvedIdentity[]> {
if (!hasBridgeAction(actions, 'RESOLVE_IDENTITIES')) {
throw new Error('RESOLVE_IDENTITIES is not available in this Home build.');
Expand All @@ -217,7 +216,9 @@ export async function resolveIdentities(addresses: string[], actions?: QdnAction
});

if (Array.isArray(batch)) {
resolved.push(...batch);
resolved.push(...batch
.filter((identity): identity is ResolvedIdentity => !!identity && typeof identity.address === 'string')
.map((identity) => ({ address: identity.address, name: normalizeRegisteredName(identity.name) })));
}
}

Expand Down Expand Up @@ -368,24 +369,24 @@ function resolveMintingAccountAddress(account: NodeMintingAccount) {
return normalizeRegisteredName(account.address) ?? normalizeRegisteredName(account.mintingAccount);
}

// Avatar/name resolution is expensive (QDN resource fetch) and effectively
// static, so share one in-flight Promise per address+actions across refreshes.
function resolveAvatarProfile(address: string, actions?: QdnAction[]): Promise<IdentityProfile> {
// Name resolution is shared across refreshes. Avatar bytes are intentionally
// not part of this cache: mounted Avatar controls fetch those through Home.
function resolveIdentityProfile(address: string, actions?: QdnAction[]): Promise<IdentityProfile> {
const cacheKey = `${address}\n${(actions ?? []).join(',')}`;
const cached = avatarProfileCache.get(cacheKey);
const cached = identityProfileCache.get(cacheKey);

if (cached !== undefined) {
return cached;
}

const pending = loadIdentityProfile(address, actions).catch((error: unknown) => {
// Do not cache failures; allow a later refresh to retry.
avatarProfileCache.delete(cacheKey);
identityProfileCache.delete(cacheKey);

throw error;
});

setCapped(avatarProfileCache, cacheKey, pending, AVATAR_CACHE_MAX_ENTRIES);
setCapped(identityProfileCache, cacheKey, pending, IDENTITY_CACHE_MAX_ENTRIES);

return pending;
}
Expand Down Expand Up @@ -414,20 +415,16 @@ async function enrichMintingAccount(account: NodeMintingAccount, actions?: QdnAc
}

let name: string | null = null;
let avatarSrc: string | null = null;

try {
const profile = await resolveAvatarProfile(address, actions);
const profile = await resolveIdentityProfile(address, actions);

name = profile.name;
avatarSrc = profile.avatarSrc;
} catch {
// Name/avatar resolution is best-effort.
// Name resolution is best-effort.
}

return {
address,
avatarSrc,
blocksMinted,
level,
name,
Expand Down Expand Up @@ -478,7 +475,6 @@ function toBlockSummary(
): BlockSummary {
return {
height,
minterAvatarSrc: identity?.avatarSrc ?? null,
minterAddress: normalizeRegisteredName(mintingInfo?.minterAddress),
minterLevel: typeof mintingInfo?.minterLevel === 'number' ? mintingInfo.minterLevel : null,
minterName: identity?.name ?? null,
Expand Down Expand Up @@ -590,7 +586,6 @@ export async function getBlockOnlineAccounts(
return raw.map((entry) => {
const profile = identitiesByAddress.get(entry.minter);
return {
avatarSrc: profile?.avatarSrc ?? null,
level: typeof entry.level === 'number' ? entry.level : null,
minter: entry.minter,
name: profile?.name ?? normalizeRegisteredName(entry.name),
Expand Down Expand Up @@ -640,7 +635,6 @@ export async function getCurrentOnlineAccounts(actions?: QdnAction[]): Promise<O
const minter = entry.minterAddress ?? '';
const profile = identitiesByAddress.get(minter);
return {
avatarSrc: profile?.avatarSrc ?? null,
level: typeof entry.minterLevel === 'number' ? entry.minterLevel : null,
minter,
name: profile?.name ?? null,
Expand Down
Loading