diff --git a/app/components/account/TokenAccountSection.tsx b/app/components/account/TokenAccountSection.tsx index a27b0315..b446e16f 100644 --- a/app/components/account/TokenAccountSection.tsx +++ b/app/components/account/TokenAccountSection.tsx @@ -9,10 +9,33 @@ import { useCluster } from '@providers/cluster'; import { PublicKey } from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; import { CoingeckoStatus, useCoinGecko } from '@utils/coingecko'; -import { displayTimestampWithoutDate } from '@utils/date'; +import { displayTimestamp, displayTimestampWithoutDate } from '@utils/date'; import { abbreviatedNumber, normalizeTokenAmount } from '@utils/index'; import { addressLabel } from '@utils/tx'; import { MintAccountInfo, MultisigAccountInfo, TokenAccount, TokenAccountInfo } from '@validators/accounts/token'; +import { + ConfidentialTransferAccount, + ConfidentialTransferFeeAmount, + ConfidentialTransferFeeConfig, + ConfidentialTransferMint, + CpiGuard, + DefaultAccountState, + GroupMemberPointer, + GroupPointer, + InterestBearingConfig, + MemoTransfer, + MetadataPointer, + MintCloseAuthority, + PermanentDelegate, + TokenExtension, + TokenGroup, + TokenGroupMember, + TokenMetadata, + TransferFeeAmount, + TransferFeeConfig, + TransferHook, + TransferHookAccount, +} from '@validators/accounts/token-extension'; import { BigNumber } from 'bignumber.js'; import { useEffect, useMemo, useState } from 'react'; import { ExternalLink, RefreshCw } from 'react-feather'; @@ -36,7 +59,15 @@ const getEthAddress = (link?: string) => { return address; }; -export function TokenAccountSection({ account, tokenAccount, tokenInfo }: { account: Account; tokenAccount: TokenAccount, tokenInfo?: FullLegacyTokenInfo }) { +export function TokenAccountSection({ + account, + tokenAccount, + tokenInfo, +}: { + account: Account; + tokenAccount: TokenAccount; + tokenInfo?: FullLegacyTokenInfo; +}) { const { cluster } = useCluster(); try { @@ -75,14 +106,26 @@ export function TokenAccountSection({ account, tokenAccount, tokenInfo }: { acco return ; } -function FungibleTokenMintAccountCard({ account, mintInfo, tokenInfo }: { account: Account; mintInfo: MintAccountInfo, tokenInfo?: FullLegacyTokenInfo }) { +function FungibleTokenMintAccountCard({ + account, + mintInfo, + tokenInfo, +}: { + account: Account; + mintInfo: MintAccountInfo; + tokenInfo?: FullLegacyTokenInfo; +}) { const fetchInfo = useFetchAccountInfo(); + const { clusterInfo } = useCluster(); + const epoch = clusterInfo?.epochInfo.epoch; const refresh = () => fetchInfo(account.pubkey, 'parsed'); const bridgeContractAddress = getEthAddress(tokenInfo?.extensions?.bridgeContract); const assetContractAddress = getEthAddress(tokenInfo?.extensions?.assetContract); const coinInfo = useCoinGecko(tokenInfo?.extensions?.coingeckoId); + const mintExtensions = mintInfo.extensions?.slice(); + mintExtensions?.sort(cmpExtension); let tokenPriceInfo; let tokenPriceDecimals = 2; @@ -152,7 +195,11 @@ function FungibleTokenMintAccountCard({ account, mintInfo, tokenInfo }: { accoun

- {tokenInfo ? 'Overview' : account.owner.toBase58() === TOKEN_2022_PROGRAM_ID.toBase58() ? 'Token-2022 Mint' : 'Token Mint'} + {tokenInfo + ? 'Overview' + : account.owner.toBase58() === TOKEN_2022_PROGRAM_ID.toBase58() + ? 'Token-2022 Mint' + : 'Token Mint'}

@@ -348,30 +398,38 @@ async function fetchTokenInfo([_, address, cluster, url]: ['get-token-info', str function TokenAccountCard({ account, info }: { account: Account; info: TokenAccountInfo }) { const refresh = useFetchAccountInfo(); - const { cluster, url } = useCluster(); + const { cluster, clusterInfo, url } = useCluster(); + const epoch = clusterInfo?.epochInfo.epoch; const label = addressLabel(account.pubkey.toBase58(), cluster); const swrKey = useMemo(() => getTokenInfoSwrKey(info.mint.toString(), cluster, url), [cluster, url]); const { data: tokenInfo } = useSWR(swrKey, fetchTokenInfo); const [symbol, setSymbol] = useState(undefined); + const accountExtensions = info.extensions?.slice(); + accountExtensions?.sort(cmpExtension); const balance = info.isNative ? ( <> - {'\u25ce'}{new BigNumber(info.tokenAmount.uiAmountString).toFormat(9)} + {'\u25ce'} + {new BigNumber(info.tokenAmount.uiAmountString).toFormat(9)} - ) : <>{info.tokenAmount.uiAmountString}; + ) : ( + <>{info.tokenAmount.uiAmountString} + ); useEffect(() => { if (info.isNative) { setSymbol('SOL'); } else { - setSymbol(tokenInfo?.symbol) + setSymbol(tokenInfo?.symbol); } - }, [tokenInfo]) + }, [tokenInfo]); return (
-

Token{ account.owner.toBase58() === TOKEN_2022_PROGRAM_ID.toBase58() && "-2022" } Account

+

+ Token{account.owner.toBase58() === TOKEN_2022_PROGRAM_ID.toBase58() && '-2022'} Account +

); @@ -497,3 +565,603 @@ function MultisigAccountCard({ account, info }: { account: Account; info: Multis
); } + +function cmpExtension(a: TokenExtension, b: TokenExtension) { + // be sure that extensions with a header row always come later + const sortedExtensionTypes = [ + 'transferFeeAmount', + 'mintCloseAuthority', + 'defaultAccountState', + 'immutableOwner', + 'memoTransfer', + 'nonTransferable', + 'nonTransferableAccount', + 'cpiGuard', + 'permanentDelegate', + 'transferHook', + 'transferHookAccount', + 'metadataPointer', + 'groupPointer', + 'groupMemberPointer', + // everything below this comment includes a header row + 'confidentialTransferAccount', + 'confidentialTransferFeeConfig', + 'confidentialTransferFeeAmount', + 'confidentialTransferMint', + 'interestBearingConfig', + 'transferFeeConfig', + 'tokenGroup', + 'tokenGroupMember', + 'tokenMetadata', + // always keep this last + 'unparseableExtension', + ]; + return sortedExtensionTypes.indexOf(a.extension) - sortedExtensionTypes.indexOf(b.extension); +} + +function TokenExtensionRows( + tokenExtension: TokenExtension, + maybeEpoch: bigint | undefined, + decimals: number, + symbol: string | undefined +) { + const epoch = maybeEpoch || 0n; // fallback to 0 if not provided + switch (tokenExtension.extension) { + case 'mintCloseAuthority': { + const extension = create(tokenExtension.state, MintCloseAuthority); + if (extension.closeAuthority) { + return ( + + Close Authority + +
+ + + ); + } else { + return <>; + } + } + case 'transferFeeAmount': { + const extension = create(tokenExtension.state, TransferFeeAmount); + return ( + + Withheld Amount {typeof symbol === 'string' && `(${symbol})`} + + {normalizeTokenAmount(extension.withheldAmount, decimals).toLocaleString('en-US', { + maximumFractionDigits: 20, + })} + + + ); + } + case 'transferFeeConfig': { + const extension = create(tokenExtension.state, TransferFeeConfig); + return ( + <> + +

Transfer Fee Config

+ + {extension.transferFeeConfigAuthority && ( + + Transfer Fee Authority + +
+ + + )} + + {extension.newerTransferFee.epoch > epoch ? 'Current' : 'Previous'} Fee Epoch + {extension.olderTransferFee.epoch} + + + + {extension.newerTransferFee.epoch > epoch ? 'Current' : 'Previous'} Maximum Fee{' '} + {typeof symbol === 'string' && `(${symbol})`} + + + {normalizeTokenAmount(extension.olderTransferFee.maximumFee, decimals).toLocaleString( + 'en-US', + { + maximumFractionDigits: 20, + } + )} + + + + {extension.newerTransferFee.epoch > epoch ? 'Current' : 'Previous'} Fee Rate + {`${extension.olderTransferFee.transferFeeBasisPoints / 100}%`} + + + {extension.newerTransferFee.epoch > epoch ? 'Future' : 'Current'} Fee Epoch + {extension.newerTransferFee.epoch} + + + + {extension.newerTransferFee.epoch > epoch ? 'Future' : 'Current'} Maximum Fee{' '} + {typeof symbol === 'string' && `(${symbol})`} + + + {normalizeTokenAmount(extension.newerTransferFee.maximumFee, decimals).toLocaleString( + 'en-US', + { + maximumFractionDigits: 20, + } + )} + + + + {extension.newerTransferFee.epoch > epoch ? 'Future' : 'Current'} Fee Rate + {`${extension.newerTransferFee.transferFeeBasisPoints / 100}%`} + + {extension.withdrawWithheldAuthority && ( + + Withdraw Withheld Fees Authority + +
+ + + )} + + Withheld Amount {typeof symbol === 'string' && `(${symbol})`} + + {normalizeTokenAmount(extension.withheldAmount, decimals).toLocaleString('en-US', { + maximumFractionDigits: 20, + })} + + + + ); + } + case 'confidentialTransferMint': { + const extension = create(tokenExtension.state, ConfidentialTransferMint); + return ( + <> + +

Confidential Transfer

+ + {extension.authority && ( + + Authority + +
+ + + )} + {extension.auditorElgamalPubkey && ( + + Auditor Elgamal Pubkey + {extension.auditorElgamalPubkey} + + )} + + New Account Approval Policy + {extension.autoApproveNewAccounts ? 'auto' : 'manual'} + + + ); + } + case 'confidentialTransferFeeConfig': { + const extension = create(tokenExtension.state, ConfidentialTransferFeeConfig); + return ( + <> + +

Confidential Transfer Fee

+ + {extension.authority && ( + + Authority + +
+ + + )} + {extension.withdrawWithheldAuthorityElgamalPubkey && ( + + Auditor Elgamal Pubkey + {extension.withdrawWithheldAuthorityElgamalPubkey} + + )} + + Harvest to Mint + {extension.harvestToMintEnabled ? 'enabled' : 'disabled'} + + + Encrypted Withheld Amount {typeof symbol === 'string' && `(${symbol})`} + {extension.withheldAmount} + + + ); + } + case 'defaultAccountState': { + const extension = create(tokenExtension.state, DefaultAccountState); + return ( + + DefaultAccountState + {extension.accountState} + + ); + } + case 'nonTransferable': { + return ( + + Non-Transferable + enabled + + ); + } + case 'interestBearingConfig': { + const extension = create(tokenExtension.state, InterestBearingConfig); + return ( + <> + +

Interest-Bearing

+ + {extension.rateAuthority && ( + + Authority + +
+ + + )} + + Current Rate + {`${extension.currentRate / 100}%`} + + + Pre-Current Average Rate + {`${extension.preUpdateAverageRate / 100}%`} + + + Last Update Timestamp + {displayTimestamp(extension.lastUpdateTimestamp * 1000)} + + + Initialization Timestamp + {displayTimestamp(extension.initializationTimestamp * 1000)} + + + ); + } + case 'permanentDelegate': { + const extension = create(tokenExtension.state, PermanentDelegate); + if (extension.delegate) { + return ( + + Permanent Delegate + +
+ + + ); + } else { + return <>; + } + } + case 'transferHook': { + const extension = create(tokenExtension.state, TransferHook); + return ( + <> + {extension.programId && ( + + Transfer Hook Program Id + +
+ + + )} + {extension.authority && ( + + Transfer Hook Authority + +
+ + + )} + + ); + } + case 'metadataPointer': { + const extension = create(tokenExtension.state, MetadataPointer); + return ( + <> + {extension.metadataAddress && ( + + Metadata + +
+ + + )} + {extension.authority && ( + + Metadata Pointer Authority + +
+ + + )} + + ); + } + case 'groupPointer': { + const extension = create(tokenExtension.state, GroupPointer); + return ( + <> + {extension.groupAddress && ( + + Token Group + +
+ + + )} + {extension.authority && ( + + Group Pointer Authority + +
+ + + )} + + ); + } + case 'groupMemberPointer': { + const extension = create(tokenExtension.state, GroupMemberPointer); + return ( + <> + {extension.memberAddress && ( + + Token Group Member + +
+ + + )} + {extension.authority && ( + + Member Pointer Authority + +
+ + + )} + + ); + } + case 'tokenMetadata': { + const extension = create(tokenExtension.state, TokenMetadata); + return ( + <> + +

Metadata

+ + + Mint + +
+ + + {extension.updateAuthority && ( + + Update Authority + +
+ + + )} + + Name + {extension.name} + + + Symbol + {extension.symbol} + + + URI + + + {extension.uri} + + + + + {extension.additionalMetadata?.length > 0 && ( + <> + +
Additional Metadata
+ + {extension.additionalMetadata?.map(keyValuePair => ( + + {keyValuePair[0]} + {keyValuePair[1]} + + ))} + + )} + + ); + } + case 'cpiGuard': { + const extension = create(tokenExtension.state, CpiGuard); + return ( + + CPI Guard + {extension.lockCpi ? 'enabled' : 'disabled'} + + ); + } + case 'confidentialTransferAccount': { + const extension = create(tokenExtension.state, ConfidentialTransferAccount); + return ( + <> + +

Confidential Transfer

+ + + Status + {!extension.approved && 'not '}approved + + + Elgamal Pubkey + {extension.elgamalPubkey} + + + Confidential Credits + {extension.allowConfidentialCredits ? 'enabled' : 'disabled'} + + + Non-confidential Credits + + {extension.allowNonConfidentialCredits ? 'enabled' : 'disabled'} + + + + Available Balance + {extension.availableBalance} + + + Decryptable Available Balance + {extension.decryptableAvailableBalance} + + + Pending Balance, Low Bits + {extension.pendingBalanceLo} + + + Pending Balance, High Bits + {extension.pendingBalanceHi} + + + Pending Balance Credit Counter + {extension.pendingBalanceCreditCounter} + + + Expected Pending Balance Credit Counter + {extension.expectedPendingBalanceCreditCounter} + + + Actual Pending Balance Credit Counter + {extension.actualPendingBalanceCreditCounter} + + + Maximum Pending Balance Credit Counter + {extension.maximumPendingBalanceCreditCounter} + + + ); + } + case 'immutableOwner': { + return ( + + Immutable Owner + enabled + + ); + } + case 'memoTransfer': { + const extension = create(tokenExtension.state, MemoTransfer); + return ( + + Require Memo on Incoming Transfers + {extension.requireIncomingTransferMemos ? 'enabled' : 'disabled'} + + ); + } + case 'transferHookAccount': { + const extension = create(tokenExtension.state, TransferHookAccount); + return ( + + Transfer Hook Status + {!extension.transferring && 'not '}transferring + + ); + } + case 'nonTransferableAccount': { + return ( + + Non-Transferable + enabled + + ); + } + case 'confidentialTransferFeeAmount': { + const extension = create(tokenExtension.state, ConfidentialTransferFeeAmount); + return ( + + Encrypted Withheld Amount {typeof symbol === 'string' && `(${symbol})`} + {extension.withheldAmount} + + ); + } + case 'tokenGroup': { + const extension = create(tokenExtension.state, TokenGroup); + return ( + <> + +

Group

+ + + Mint + +
+ + + {extension.updateAuthority && ( + + Update Authority + +
+ + + )} + + Current Size + {extension.size} + + + Max Size + {extension.maxSize} + + + ); + } + case 'tokenGroupMember': { + const extension = create(tokenExtension.state, TokenGroupMember); + return ( + <> + +

Group Member

+ + + Mint + +
+ + + + Group + +
+ + + + Member Number + {extension.memberNumber} + + + ); + } + case 'unparseableExtension': + default: + return ( + + Unknown Extension + unparseable + + ); + } +} diff --git a/app/validators/accounts/token-extension.ts b/app/validators/accounts/token-extension.ts new file mode 100644 index 00000000..c8973012 --- /dev/null +++ b/app/validators/accounts/token-extension.ts @@ -0,0 +1,161 @@ +import { PublicKeyFromString } from '@validators/pubkey'; +import { any, array, boolean, enums, Infer, nullable, number, string, type } from 'superstruct'; + +export type TokenExtensionType = Infer; +const ExtensionType = enums([ + 'transferFeeConfig', + 'transferFeeAmount', + 'mintCloseAuthority', + 'confidentialTransferMint', + 'confidentialTransferAccount', + 'defaultAccountState', + 'immutableOwner', + 'memoTransfer', + 'nonTransferable', + 'interestBearingConfig', + 'cpiGuard', + 'permanentDelegate', + 'nonTransferableAccount', + 'confidentialTransferFeeConfig', + 'confidentialTransferFeeAmount', + 'transferHook', + 'transferHookAccount', + 'metadataPointer', + 'tokenMetadata', + 'groupPointer', + 'groupMemberPointer', + 'tokenGroup', + 'tokenGroupMember', + 'unparseableExtension', +]); + +export type TokenExtension = Infer; +export const TokenExtension = type({ + extension: ExtensionType, + state: any(), +}); + +const TransferFee = type({ + epoch: number(), + maximumFee: number(), + transferFeeBasisPoints: number(), +}); + +export const TransferFeeConfig = type({ + newerTransferFee: TransferFee, + olderTransferFee: TransferFee, + transferFeeConfigAuthority: nullable(PublicKeyFromString), + withdrawWithheldAuthority: nullable(PublicKeyFromString), + withheldAmount: number(), +}); + +export const TransferFeeAmount = type({ + withheldAmount: number(), +}); + +export const MintCloseAuthority = type({ + closeAuthority: nullable(PublicKeyFromString), +}); + +const AccountState = enums(['initialized', 'frozen']); +export const DefaultAccountState = type({ + accountState: AccountState, +}); + +export const MemoTransfer = type({ + requireIncomingTransferMemos: boolean(), +}); + +export const CpiGuard = type({ + lockCpi: boolean(), +}); + +export const PermanentDelegate = type({ + delegate: nullable(PublicKeyFromString), +}); + +export const InterestBearingConfig = type({ + currentRate: number(), + initializationTimestamp: number(), + lastUpdateTimestamp: number(), + preUpdateAverageRate: number(), + rateAuthority: nullable(PublicKeyFromString), +}); + +export const ConfidentialTransferMint = type({ + auditorElgamalPubkey: nullable(string()), + authority: nullable(PublicKeyFromString), + autoApproveNewAccounts: boolean(), +}); + +export const ConfidentialTransferFeeConfig = type({ + authority: nullable(PublicKeyFromString), + harvestToMintEnabled: boolean(), + withdrawWithheldAuthorityElgamalPubkey: nullable(string()), + withheldAmount: string(), +}); + +export const ConfidentialTransferAccount = type({ + actualPendingBalanceCreditCounter: number(), + allowConfidentialCredits: boolean(), + allowNonConfidentialCredits: boolean(), + approved: boolean(), + availableBalance: string(), + decryptableAvailableBalance: string(), + elgamalPubkey: string(), + expectedPendingBalanceCreditCounter: number(), + maximumPendingBalanceCreditCounter: number(), + pendingBalanceCreditCounter: number(), + pendingBalanceHi: string(), + pendingBalanceLo: string(), +}); + +export const ConfidentialTransferFeeAmount = type({ + withheldAmount: string(), +}); + +export const MetadataPointer = type({ + authority: nullable(PublicKeyFromString), + metadataAddress: nullable(PublicKeyFromString), +}); + +export const TokenMetadata = type({ + additionalMetadata: array(array(string())), + mint: PublicKeyFromString, + name: string(), + symbol: string(), + updateAuthority: nullable(PublicKeyFromString), + uri: string(), +}); + +export const TransferHook = type({ + authority: nullable(PublicKeyFromString), + programId: nullable(PublicKeyFromString), +}); + +export const TransferHookAccount = type({ + transferring: boolean(), +}); + +export const GroupPointer = type({ + authority: nullable(PublicKeyFromString), + groupAddress: nullable(PublicKeyFromString), +}); + +export const GroupMemberPointer = type({ + authority: nullable(PublicKeyFromString), + memberAddress: nullable(PublicKeyFromString), +}); + +export const TokenGroup = type({ + maxSize: number(), + mint: PublicKeyFromString, + size: number(), + updateAuthority: nullable(PublicKeyFromString), +}); + +export const TokenGroupMember = type({ + group: PublicKeyFromString, + memberNumber: number(), + mint: PublicKeyFromString, +}); diff --git a/app/validators/accounts/token.ts b/app/validators/accounts/token.ts index 1daaa504..686f4d39 100644 --- a/app/validators/accounts/token.ts +++ b/app/validators/accounts/token.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-redeclare */ +import { TokenExtension } from '@validators/accounts/token-extension'; import { PublicKeyFromString } from '@validators/pubkey'; import { any, array, boolean, enums, Infer, nullable, number, optional, string, type } from 'superstruct'; @@ -20,6 +21,7 @@ export const TokenAccountInfo = type({ closeAuthority: optional(PublicKeyFromString), delegate: optional(PublicKeyFromString), delegatedAmount: optional(TokenAmount), + extensions: optional(array(TokenExtension)), isNative: boolean(), mint: PublicKeyFromString, owner: PublicKeyFromString, @@ -31,6 +33,7 @@ export const TokenAccountInfo = type({ export type MintAccountInfo = Infer; export const MintAccountInfo = type({ decimals: number(), + extensions: optional(array(TokenExtension)), freezeAuthority: nullable(PublicKeyFromString), isInitialized: boolean(), mintAuthority: nullable(PublicKeyFromString),