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
13 changes: 12 additions & 1 deletion app/vibenet/explorer/address/[addr]/AccountShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export function useSectionParam({
type AccountShellProps = {
name: string;
address: string;
/** Line under the title, above the address. Token pages use it for the token name. */
subtitle?: ReactNode;
/** Replaces the account avatar. Token pages pass a symbol mark. */
avatar?: ReactNode;
avatarVariant?: 'default' | 'spending';
badges?: ReactNode;
actions?: ReactNode;
Expand All @@ -65,6 +69,8 @@ type AccountShellProps = {
export function AccountShell({
name,
address,
subtitle,
avatar,
avatarVariant = 'default',
badges,
actions,
Expand All @@ -77,14 +83,19 @@ export function AccountShell({
<div className="animate-in -mb-20 flex min-w-0 flex-1 flex-col gap-6 pb-12 text-foreground">
<header className="flex flex-col gap-5 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-4">
<AccountAvatar variant={avatarVariant} size={52} />
{avatar ?? <AccountAvatar variant={avatarVariant} size={52} />}
<div className="flex min-w-0 flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<Text as="h1" variant="title2" className="truncate">
{name}
</Text>
{badges}
</div>
{subtitle ? (
<Text as="p" variant="label.regular" tone="muted">
{subtitle}
</Text>
) : null}
<CopyableValue value={address} className="text-bds-gray-60 dark:text-bds-gray-40" />
</div>
</div>
Expand Down
155 changes: 68 additions & 87 deletions app/vibenet/explorer/address/[addr]/PublicAddressView.tsx
Original file line number Diff line number Diff line change
@@ -1,125 +1,106 @@
'use client';

// Read-only inspector for an address that is not one of your local accounts:
// assets, actors (owners + session keys as indexed), and activity. No `@aa` —
// this is the light public path. Shares the assets + activity components with
// the owned management view and renders inside the shared AccountShell.
// assets and activity. Contract addresses that implement ERC-20 swap in a
// token profile and a transfer list. No `@aa` — this is the light public path.
// Shares the assets + activity components with the owned management view and
// renders inside AccountShell.

import { useMemo } from 'react';
import { useEffect, useState } from 'react';

import { Card } from '../../../../components/ui/Card';
import { Text } from '../../../../components/ui/Text';
import { DetailList, DetailRow } from '../../../components/DetailList';
import { ExplorerLink } from '../../../components/ExplorerLink';
import { Spinner } from '../../../../components/ui/Spinner';
import { Badge } from '../../../demos/_shared/primitives';
import type { ActorEntry, ExplorerAddressResponse } from '../../../library/api-types';
import { authLabel, expiryLabel, K1_AUTHENTICATOR, scopeChips } from '../../../library/explorer';
import { shortAddress } from '../../../library/format';
import type { ExplorerAddressResponse } from '../../../library/api-types';
import { fetchErc20Profile, type Erc20Profile } from '../../../library/token';
import { AccountShell, useSectionParam, type ShellSection } from './AccountShell';
import { ActivityTable } from './ActivityTable';
import { AssetsCard } from './AssetsCard';

const CHIP =
'inline-flex items-center rounded-full border border-bds-gray-10 px-2.5 py-1 text-[11px] leading-none text-bds-gray-60 dark:border-white/10 dark:text-bds-gray-40';
import { TokenMark, TokenOverview } from './TokenOverview';
import { TokenTransfers } from './TokenTransfers';

const SECTIONS: ShellSection[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'actors', label: 'Actors' },
{ id: 'activity', label: 'Activity' },
];

function ActorCard({ actor }: { actor: ActorEntry }) {
const TOKEN_SECTIONS: ShellSection[] = [
{ id: 'overview', label: 'Overview' },
{ id: 'activity', label: 'Transfers' },
];

function SectionSpinner() {
return (
<Card className="bg-background p-4 dark:bg-white/5">
<div className="flex flex-wrap items-center gap-2">
<code className="font-mono text-[13px]" title={actor.actorId}>
{shortAddress(actor.actorId, 14, 4)}
</code>
{actor.isSelf ? <span className={CHIP}>self</span> : null}
<Badge>{authLabel(actor.authenticator)}</Badge>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
{scopeChips(actor.scope).map((chip) => (
<span key={chip} className={CHIP}>
{chip}
</span>
))}
<span className={CHIP}>{expiryLabel(actor.expiry)}</span>
{actor.policyType !== 0 ? <span className={CHIP}>policy · type {actor.policyType}</span> : null}
</div>
{actor.policyManager ? (
<DetailList className="mt-3">
<DetailRow label="policy manager">
<ExplorerLink kind="address" value={actor.policyManager} label={actor.policyManager} className="break-all" />
</DetailRow>
{actor.policyCommitment ? (
<DetailRow label="commitment">
<code className="break-all font-mono" title={actor.policyCommitment}>
{shortAddress(actor.policyCommitment, 14, 4)}
</code>
</DetailRow>
) : null}
</DetailList>
) : null}
</Card>
<div className="flex min-h-40 items-center justify-center">
<Spinner className="h-6 w-6 text-bds-gray-50" />
</div>
);
}

export function PublicAddressView({ address, data }: { address: string; data: ExplorerAddressResponse }) {
// undefined while a contract's ERC-20 probe is in flight, null when it isn't a token.
const [profile, setProfile] = useState<Erc20Profile | null | undefined>(data.is_contract ? undefined : null);

useEffect(() => {
if (!data.is_contract) {
setProfile(null);
return;
}
let cancelled = false;
setProfile(undefined);
fetchErc20Profile(address).then((next) => {
if (!cancelled) setProfile(next);
});
return () => {
cancelled = true;
};
}, [address, data.is_contract]);

useEffect(() => {
if (!profile) return;
const previous = document.title;
document.title = `${profile.symbol} · Vibenet Explorer`;
return () => {
document.title = previous;
};
}, [profile]);

const sections = profile ? TOKEN_SECTIONS : SECTIONS;
const [section, selectSection] = useSectionParam({
valid: SECTIONS.map((s) => s.id),
valid: sections.map((s) => s.id),
fallback: 'overview',
});

const typeBadge = data.is_contract ? 'Contract' : data.is_aa ? 'Smart account' : 'EOA';

// Implicit secp256k1 self key when no AccountConfiguration events are indexed.
const selfActor = useMemo<ActorEntry>(
() => ({
actorId: data.self_actor_id ?? '',
authenticator: K1_AUTHENTICATOR,
scope: 0,
expiry: 0,
policyType: 0,
policyManager: null,
policyCommitment: null,
isSelf: true,
}),
[data.self_actor_id],
);
const tokenName = profile && profile.name.toLowerCase() !== profile.symbol.toLowerCase() ? profile.name : null;

return (
<AccountShell
name="Address"
name={profile ? profile.symbol : 'Address'}
subtitle={tokenName}
avatar={profile ? <TokenMark symbol={profile.symbol} /> : undefined}
address={address}
badges={<Badge>{typeBadge}</Badge>}
sections={SECTIONS}
badges={profile ? <Badge tone="blue">ERC-20</Badge> : <Badge>{typeBadge}</Badge>}
sections={sections}
activeSection={section}
onSelectSection={selectSection}
>
{section === 'overview' ? (
<AssetsCard address={address} activity={data.activity} />
) : section === 'actors' ? (
data.actors_indexed ? (
data.actors.length === 0 ? (
<Text variant="label.regular" tone="muted">
No active actors — every registered actor has been revoked.
</Text>
) : (
<div className="flex flex-col gap-3">
{data.actors.map((actor) => (
<ActorCard key={actor.actorId} actor={actor} />
))}
</div>
)
profile ? (
<TokenOverview profile={profile} codeSize={data.code_size} />
) : profile === undefined ? (
<SectionSpinner />
) : (
<div className="flex flex-col gap-2">
<ActorCard actor={selfActor} />
<Text variant="footnote" tone="muted">
Implicit secp256k1 self key — no AccountConfiguration events indexed for this address yet.
</Text>
</div>
<AssetsCard address={address} activity={data.activity} />
)
) : profile ? (
<TokenTransfers
token={address}
symbol={profile.symbol}
decimals={profile.decimals}
activity={data.activity}
/>
) : profile === undefined ? (
<SectionSpinner />
) : (
<ActivityTable activity={data.activity} />
)}
Expand Down
41 changes: 41 additions & 0 deletions app/vibenet/explorer/address/[addr]/TokenOverview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';

// ERC-20 identity for a contract address: the facts the address index doesn't
// store (name, decimals, supply). Shared by the public inspector's overview.

import { Card } from '../../../../components/ui/Card';
import { Text } from '../../../../components/ui/Text';
import { DetailList, DetailRow } from '../../../components/DetailList';
import { fmtTokenAmount } from '../../../library/explorer';
import type { Erc20Profile } from '../../../library/token';

export function TokenMark({ symbol, size = 52 }: { symbol: string; size?: number }) {
return (
<span
className="flex shrink-0 items-center justify-center rounded-full bg-bds-gray-10 font-medium text-bds-gray-70 dark:bg-white/10 dark:text-bds-gray-30"
style={{ width: size, height: size, fontSize: Math.round(size * 0.34) }}
aria-hidden="true"
>
{symbol.slice(0, 1).toUpperCase()}
</span>
);
}

export function TokenOverview({ profile, codeSize }: { profile: Erc20Profile; codeSize: number }) {
return (
<Card className="bg-background p-6 dark:bg-white/[0.03]">
<Text as="p" variant="label.regular" tone="muted">
Total supply
</Text>
<Text as="p" variant="title2" className="mt-1 tabular-nums">
{fmtTokenAmount(profile.totalSupply, profile.decimals)} {profile.symbol}
</Text>
<DetailList className="mt-6">
<DetailRow label="Name">{profile.name}</DetailRow>
<DetailRow label="Symbol">{profile.symbol}</DetailRow>
<DetailRow label="Decimals">{profile.decimals}</DetailRow>
<DetailRow label="Code">{codeSize.toLocaleString()} bytes</DetailRow>
</DetailList>
</Card>
);
}
Loading
Loading