Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
fc16d3f
feat: add extensions tab
rogaldh Mar 27, 2025
10fe9ae
add refresh button
rogaldh Mar 27, 2025
52eadb1
chore: disable no non-null assertions rule to increase visibility for…
rogaldh Mar 27, 2025
8c92d84
feat: add tools to generate ui components
rogaldh Mar 28, 2025
00dc068
prettify accordion
rogaldh Mar 28, 2025
4edc7ba
chore: improve prettier configuration
rogaldh Mar 28, 2025
072407b
feat: add generated accordeon with proper prefix resolving
rogaldh Mar 28, 2025
ae31c74
WIP
rogaldh Mar 31, 2025
1eb6204
WIP
rogaldh Apr 1, 2025
6c9198e
fix layout for extension
rogaldh Apr 2, 2025
529dc3e
chore: use correct prefix for generated components
rogaldh Apr 3, 2025
b90a826
downgrade to tailwind3 to cover responsive layout
rogaldh Apr 3, 2025
41fe9c9
improve adaptive layout for extensions
rogaldh Apr 3, 2025
9fcd405
parse t22 most common extensions
rogaldh Apr 3, 2025
520f6e2
wip
rogaldh Apr 3, 2025
126c1af
feat: add components
rogaldh Apr 3, 2025
b17c4cf
Downgrade to prettier@2
rogaldh Apr 4, 2025
15dc1a2
chore: improve prettier config to support plugin for tailwind
rogaldh Apr 4, 2025
954b991
sort classes
rogaldh Apr 4, 2025
f6e14f5
chore: update node engine according version at Vercel
rogaldh Apr 4, 2025
04b3d4c
feat: write test-cases for TokenExtensionRow implemetation
rogaldh Apr 4, 2025
33e3405
replace inner component with existing one to render extension data in…
rogaldh Apr 7, 2025
a2e87a5
feat: complete skeleton for extension data
rogaldh Apr 7, 2025
cdc544a
pass symbol to extensions
rogaldh Apr 7, 2025
61f20ae
remove extensions from main section
rogaldh Apr 7, 2025
327fbdb
fix accordion rotation
rogaldh Apr 7, 2025
8c1d5d1
fix typo
rogaldh Apr 7, 2025
70095dd
update version of shadcn to latest version
rogaldh Apr 8, 2025
f49ec04
feat: add active state for raw link and make it similar to other raw …
rogaldh Apr 8, 2025
5ade1b2
feat: add descriptions for extensions
rogaldh Apr 8, 2025
0310ac6
add deps to hooks to satisfy dependencies
rogaldh Apr 8, 2025
634ab6d
fix issue with overlapping columns for small displays
rogaldh Apr 8, 2025
ca33555
Merge branch 'master' into feat/t22-extensions-rework
rogaldh Apr 8, 2025
2da6609
fix ui issues
rogaldh Apr 8, 2025
a8aa778
fix issue with 404 images & remove obsolete images & optimize existin…
rogaldh Apr 8, 2025
6dd4235
reuse existong types for token extensions
rogaldh Apr 8, 2025
d26c78d
move comment inside css block
rogaldh Apr 8, 2025
1f534f0
remove unreachable code
rogaldh Apr 8, 2025
2e394c5
remove duplicated modifiers
rogaldh Apr 8, 2025
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
4 changes: 3 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"semi": ["error", "always"],
// Temporary rule to ignore any explicit any type warnings in TypeScript files
"@typescript-eslint/no-explicit-any": "off",
"object-curly-spacing": ["error", "always"]
"object-curly-spacing": ["error", "always"],
// Do not rely on non-null assertions, please. Make it off to see other errors.
"@typescript-eslint/no-non-null-assertion": "off"
},
"overrides": [
// Only uses Testing Library lint rules in test files
Expand Down
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,4 @@ next-env.d.ts
.swc/

# vim
*.sw*
.editorconfig
.editorconfig
7 changes: 7 additions & 0 deletions .prettierrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const prettierConfigSolana = require('@solana/prettier-config-solana');

module.exports = {
...prettierConfigSolana,
plugins: [prettierConfigSolana.plugins ?? []].concat(['prettier-plugin-tailwindcss']),
endOfLine: 'lf',
};
39 changes: 38 additions & 1 deletion app/address/[address]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { CacheEntry, FetchStatus } from '@providers/cache';
import { useCluster } from '@providers/cluster';
import { PROGRAM_ID as ACCOUNT_COMPRESSION_ID } from '@solana/spl-account-compression';
import { PublicKey } from '@solana/web3.js';
import { TOKEN_2022_PROGRAM_ADDRESS } from '@solana-program/token-2022';
import { Cluster, ClusterStatus } from '@utils/cluster';
import { FEATURE_PROGRAM_ID } from '@utils/parseFeatureAccount';
import { useClusterPath } from '@utils/url';
Expand Down Expand Up @@ -569,7 +570,8 @@ export type MoreTabs =
| 'compression'
| 'verified-build'
| 'program-multisig'
| 'feature-gate';
| 'feature-gate'
| 'token-extensions';

function MoreSection({ children, tabs }: { children: React.ReactNode; tabs: (JSX.Element | null)[] }) {
return (
Expand Down Expand Up @@ -724,6 +726,27 @@ function getCustomLinkedTabs(pubkey: PublicKey, account: Account) {
tab: programMultisigTab,
});

// Add extensions tab for Token Extensions program accounts
if (account.owner.toBase58() === TOKEN_2022_PROGRAM_ADDRESS) {
const tokenAccount = account.data.parsed; // as TokenProgramData;

console.log({ tokenAccount });

const extensionsTab: Tab = {
path: 'token-extensions',
slug: 'token-extensions',
title: 'Extensions',
};
tabComponents.push({
component: (
<React.Suspense key={extensionsTab.slug} fallback={<></>}>
<TokenExtensionsLink tab={extensionsTab} address={pubkey.toString()} />
</React.Suspense>
),
tab: extensionsTab,
});
}

const anchorProgramTab: Tab = {
path: 'anchor-program',
slug: 'anchor-program',
Expand Down Expand Up @@ -874,3 +897,17 @@ function ProgramMultisigLink({
</li>
);
}

function TokenExtensionsLink({ address, tab }: { address: string; tab: Tab }) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component duplicates the existing Tab component. Consider using Tab instead, passing tab.path and tab.title as separate parameters.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's wrong. Tab has different logic for isActive inside it. Accepting this will lead to incorrect state for tabs

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might be a good idea to rename Tab to something more specific or to bypass isActive through props

const accountDataPath = useClusterPath({ pathname: `/address/${address}/${tab.path}` });
const selectedLayoutSegment = useSelectedLayoutSegment();
const isActive = selectedLayoutSegment === tab.path;

return (
<li key={tab.slug} className="nav-item">
<Link className={`${isActive ? 'active ' : ''}nav-link`} href={accountDataPath}>
{tab.title}
</Link>
</li>
);
}
22 changes: 22 additions & 0 deletions app/address/[address]/token-extensions/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import './styles.css';

import { TokenExtensionsCard } from '@components/account/TokenExtensionsCard';
import getReadableTitleFromAddress, { AddressPageMetadataProps } from '@utils/get-readable-title-from-address';
import { Metadata } from 'next/types';

type Props = Readonly<{
params: {
address: string;
};
}>;

export async function generateMetadata(props: AddressPageMetadataProps): Promise<Metadata> {
return {
description: `Token extensions information for address ${props.params.address} on Solana`,
title: `Token Extensions | ${await getReadableTitleFromAddress(props)} | Solana`,
};
}

export default function TokenExtensionsPage({ params: { address: _address } }: Props) {
return <TokenExtensionsCard address={_address} />;
}
1 change: 1 addition & 0 deletions app/address/[address]/token-extensions/styles.css
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
@import "../../../styles.css";
193 changes: 193 additions & 0 deletions app/components/account/TokenExtensionsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
'use client';

import { PublicKey } from '@solana/web3.js';
import { AccountHeader } from '@components/common/Account';
import { useFetchAccountInfo, useMintAccountInfo } from '@providers/accounts';

import { StatusType } from '@/app/components/shared/StatusBadge';

import { TokenExtensionsSection } from './TokenExtensionsSection';

export type ExtensionStatus = StatusType;

export interface TokenExtension {
id: string;
name: string;
tooltip?: string;
description?: string;
status: ExtensionStatus;
externalLinks: { label: string; url: string }[];
}

export type ParsedTokenExtensionWithRawData = TokenExtension & {
parsed?: NonNullable<unknown> | NonNullable<unknown>[];
raw?: string;
};

export function TokenExtensionsCard({ address }: { address: string }) {
const refresh = useFetchAccountInfo();
const mintInfo = useMintAccountInfo(address);
console.log({ mintInfo: mintInfo?.extensions }, address);

if (!mintInfo) return null;

const extensions = populateTokenExtensions(mintInfo.extensions ?? []);

return (
<div className="card">
<AccountHeader title="Extensions" refresh={() => refresh(new PublicKey(address), 'parsed')} />
<div className="card-body p-0">
<TokenExtensionsSection extensions={extensions} />
</div>
</div>
);
}

type ParsedExtension = { extension: string; state?: NonNullable<unknown> };

function populateTokenExtensions(extensions: ParsedExtension[]): ParsedTokenExtensionWithRawData[] {
function populateExternalLinks(url: string) {
return [{ label: 'Docs', url }];
}

function findExtensionByKeyword(keyword: string, extensions: ParsedExtension[]) {
return extensions.find(ext => ext.extension === keyword);
}

/**
* Sample.
* TODO: To be removed upon editing all the details for each extension

id: 'permanent-delegate',
name: 'Permanent Delegate',
tooltip: 'Delegates permanent authority to a specific address that can transfer or burn tokens from any account holding this token, providing centralized administrative control over the token ecosystem.',
description: 'Delegates permanent authority to a specific address that can transfer or burn tokens from any account holding this token, providing centralized administrative control over the token ecosystem.',
status: 'active',
externalLinks: populateExternalLinks('https://solana.com/developers/guides/token-extensions/permanent-delegate'),
*/
const result = extensions.reduce((acc, { extension, state }) => {
switch (extension) {
case 'mintCloseAuthority': {
acc.set(extension, {
id: extension,
name: 'Mint Close Authority',
tooltip: undefined,
description: undefined,
status: 'active',
externalLinks: populateExternalLinks(
'https://solana.com/developers/guides/token-extensions/mint-close-authority'
),
parsed: state,
raw: state,
});
break;
}
case 'permanentDelegate': {
acc.set(extension, {
id: extension,
name: 'Permanent Delegate',
tooltip:
'Delegates permanent authority to a specific address that can transfer or burn tokens from any account holding this token, providing centralized administrative control over the token ecosystem.',
description:
'Delegates permanent authority to a specific address that can transfer or burn tokens from any account holding this token, providing centralized administrative control over the token ecosystem.',
status: 'active',
externalLinks: populateExternalLinks(
'https://solana.com/developers/guides/token-extensions/permanent-delegate'
),
parsed: state,
raw: state,
});
break;
}
case 'transferFeeConfig': {
acc.set(extension, {
id: extension,
name: 'Transfer Fee',
tooltip: undefined,
description: undefined,
status: 'active',
externalLinks: populateExternalLinks(
'https://solana.com/developers/guides/token-extensions/transfer-fee'
),
parsed: state,
raw: state,
});
break;
}
case 'transferHook': {
acc.set(extension, {
id: extension,
name: 'Transfer Hook',
tooltip: undefined,
description: undefined,
status: 'active',
externalLinks: populateExternalLinks(
'https://solana.com/developers/guides/token-extensions/transfer-hook'
),
parsed: state,
raw: state,
});
break;
}
case 'confidentialTransferMint':
case 'confidentialTransferFeeConfig': {
const EXTENSION_NAME = 'confidentialTransfer';

// find confidentialTransfer parts by searching for the index. The are not much extensions so it won't be a bottleneck

if (!acc.has(EXTENSION_NAME)) {
const data = [
['Mint', findExtensionByKeyword('confidentialTransferMint', extensions)?.state ?? {}],
[
'Fee Config',
findExtensionByKeyword('confidentialTransferFeeConfig', extensions)?.state ?? {},
],
];
acc.set(EXTENSION_NAME, {
id: EXTENSION_NAME,
name: 'Confidential Transfer',
tooltip: undefined,
description: undefined,
status: 'active',
externalLinks: populateExternalLinks('https://spl.solana.com/confidential-token/quickstart'),
parsed: data,
raw: data,
});
}
break;
}
case 'metadataPointer':
case 'tokenMetadata': {
const EXTENSION_NAME = 'metadataPointer';

// find confidentialTransfer parts by searching for the index. The are not much extensions so it won't be a bottleneck

if (!acc.has(EXTENSION_NAME)) {
const data = [
['Metadata Pointer', findExtensionByKeyword('metadataPointer', extensions)?.state ?? {}],
['Token Metadata', findExtensionByKeyword('tokenMetadata', extensions)?.state ?? {}],
];

acc.set(EXTENSION_NAME, {
id: EXTENSION_NAME,
name: 'Metadata & Metadata Pointer',
tooltip: undefined,
description: undefined,
status: 'active',
externalLinks: populateExternalLinks(
'https://solana.com/developers/guides/token-extensions/metadata-pointer'
),
parsed: data,
raw: data,
});
}
break;
}
default:
break;
}
return acc;
}, new Map<string, ParsedTokenExtensionWithRawData>());

return Array.from(result.values());
}
Loading
Loading