diff --git a/.storybook/decorators.tsx b/.storybook/decorators.tsx index 83eb1e0f2..e7b6f4f00 100644 --- a/.storybook/decorators.tsx +++ b/.storybook/decorators.tsx @@ -1,4 +1,5 @@ import { ClusterProvider } from '@providers/cluster'; +import { TransactionsProvider } from '@providers/transactions'; import type { Decorator, Parameters } from '@storybook/react'; import React from 'react'; import { fn } from 'storybook/test'; @@ -40,6 +41,17 @@ export const withCardTableField: Decorator = Story => ( ); +/** Wraps stories with ClusterProvider, TransactionsProvider, and MockAccountsProvider. Usage: `decorators: [withTransactions]` */ +export const withTransactions: Decorator = Story => ( + + + + + + + +); + /** Wraps stories with MockTokenInfoBatchProvider. Usage: `decorators: [withTokenInfoBatch]` */ export const withTokenInfoBatch: Decorator = Story => ( diff --git a/app/components/common/Address.tsx b/app/components/common/Address.tsx index fd4970169..6caf238fb 100644 --- a/app/components/common/Address.tsx +++ b/app/components/common/Address.tsx @@ -29,6 +29,7 @@ type Props = { overrideText?: string; tokenLabelInfo?: TokenLabelInfo; fetchTokenLabelInfo?: boolean; + 'aria-label'?: string; }; export function Address({ @@ -43,6 +44,7 @@ export function Address({ overrideText, tokenLabelInfo, fetchTokenLabelInfo, + 'aria-label': ariaLabel, }: Props) { const address = pubkey.toBase58(); const { cluster, clusterInfo } = useCluster(); @@ -95,7 +97,7 @@ export function Address({ }; const content = ( -
+
void; + // Extra buttons rendered in the card header next to Raw + headerButtons?: React.ReactNode; + // Show a Collapse/Expand button that hides all card content + collapsible?: boolean; }; export function BaseInstructionCard({ @@ -37,9 +41,12 @@ export function BaseInstructionCard({ childIndex, raw, onRequestRaw, + headerButtons, + collapsible = false, }: InstructionProps) { const [resultClass] = ixResult(result, index); const [showRaw, setShowRaw] = React.useState(defaultRaw || false); + const [expanded, setExpanded] = React.useState(true); const rawClickHandler = () => { if (!defaultRaw && !showRaw && !raw) { // trigger handler to simulate behaviour for the InstructionCard for the transcation which contains logic in it to fetch raw transaction data @@ -62,63 +69,82 @@ export function BaseInstructionCard({ {title} - -
-
- - - {showRaw ? ( - <> - - - - - {'parsed' in ix ? ( - - {raw ? : null} - - ) : ( - - )} - - ) : ( - children - )} - {innerCards && innerCards.length > 0 && ( - <> - - - - - - - - )} - {eventCards && eventCards.length > 0 && ( - <> - - - - - - - +
+ {headerButtons} + {collapsible && ( + + )} +
Program -
-
Inner Instructions
-
{innerCards}
-
Events
-
{eventCards}
-
+ onClick={rawClickHandler} + > + Raw + +
+ {expanded && ( +
+ + + {showRaw ? ( + <> + + + + + {'parsed' in ix ? ( + + {raw ? : null} + + ) : ( + + )} + + ) : ( + children + )} + {innerCards && innerCards.length > 0 && ( + <> + + + + + + + + )} + {eventCards && eventCards.length > 0 && ( + <> + + + + + + + + )} + +
Program +
+
Inner Instructions
+ {/* !e-m-0 overrides the 1.5rem margin from inner-cards + so the card aligns with the "Inner Instructions" label above */} +
{innerCards}
+
Events
+
{eventCards}
+
+
+ )} ); } diff --git a/app/components/common/HexData.tsx b/app/components/common/HexData.tsx index 069389934..e56a2ddb3 100644 --- a/app/components/common/HexData.tsx +++ b/app/components/common/HexData.tsx @@ -1,66 +1,2 @@ -import React, { ReactNode } from 'react'; - -import { ByteArray, toHex } from '@/app/shared/lib/bytes'; - -import { cn } from '../shared/utils'; -import { Copyable } from './Copyable'; - -export function HexData({ - raw, - className, - copyableRaw, -}: { - raw: ByteArray; - copyableRaw?: ByteArray; - className?: string; -}) { - if (!raw || raw.length === 0) { - return No data; - } - - const chunks = []; - const hexString = toHex(raw); - for (let i = 0; i < hexString.length; i += 2) { - chunks.push(hexString.slice(i, i + 2)); - } - - const SPAN_SIZE = 4; - const ROW_SIZE = 4 * SPAN_SIZE; - - const divs: ReactNode[] = []; - let spans: ReactNode[] = []; - for (let i = 0; i < chunks.length; i += SPAN_SIZE) { - const color = i % (2 * SPAN_SIZE) === 0 ? 'text-white' : 'text-gray-500'; - spans.push( - - {chunks.slice(i, i + SPAN_SIZE).join(' ')}  - , - ); - - if (i % ROW_SIZE === ROW_SIZE - SPAN_SIZE || i >= chunks.length - SPAN_SIZE) { - divs.push(
{spans}
); - - // clear spans - spans = []; - } - } - - function Content() { - return ( - -
{divs}
-
- ); - } - - return ( - <> -
- -
-
- -
- - ); -} +// @deprecated — Use `@shared/HexData` instead. This re-export exists for backward compatibility. +export { HexData } from '@shared/HexData'; diff --git a/app/components/instruction/InstructionCard.tsx b/app/components/instruction/InstructionCard.tsx index 32c476716..dc3e4b107 100644 --- a/app/components/instruction/InstructionCard.tsx +++ b/app/components/instruction/InstructionCard.tsx @@ -17,6 +17,8 @@ type InstructionProps = { childIndex?: number; // Raw instruction for displaying accounts and hex data in raw mode (used by inspector) raw?: TransactionInstruction; + headerButtons?: React.ReactNode; + collapsible?: boolean; }; export function InstructionCard({ @@ -30,6 +32,8 @@ export function InstructionCard({ eventCards, childIndex, raw: rawProp, + headerButtons, + collapsible, }: InstructionProps) { const signature = useContext(SignatureContext); const rawDetails = useRawTransactionDetails(signature); @@ -58,6 +62,8 @@ export function InstructionCard({ childIndex={childIndex} raw={raw} onRequestRaw={canFetchRaw ? fetchRawTrigger : undefined} + headerButtons={headerButtons} + collapsible={collapsible} > {children} diff --git a/app/components/shared/HexData.tsx b/app/components/shared/HexData.tsx new file mode 100644 index 000000000..7922900e5 --- /dev/null +++ b/app/components/shared/HexData.tsx @@ -0,0 +1,209 @@ +import { Copyable } from '@components/common/Copyable'; +import { cva } from 'class-variance-authority'; +import React from 'react'; + +import { ByteArray, toHex } from '@/app/shared/lib/bytes'; + +import { cn } from './utils'; + +export type HexSpan = { text: string; variant: 'primary' | 'secondary' | 'secondary-old' }; +export type HexRow = HexSpan[]; + +const SPAN_SIZE = 4; +const ROW_SIZE = 4 * SPAN_SIZE; +const TRUNCATE_EDGE_BYTES = 8; + +export function splitHexPairs(hex: string): string[] { + const pairs: string[] = []; + for (let i = 0; i < hex.length; i += 2) { + pairs.push(hex.slice(i, i + 2)); + } + return pairs; +} + +// Truncate pairs to head … tail, inserting an ellipsis marker. +// Returns the original pairs unchanged if below threshold. +export function truncateHexPairs(pairs: string[]): { pairs: string[]; truncated: boolean } { + if (pairs.length <= TRUNCATE_EDGE_BYTES * 2) { + return { pairs, truncated: false }; + } + return { + pairs: [...pairs.slice(0, TRUNCATE_EDGE_BYTES), '\u2026', ...pairs.slice(-TRUNCATE_EDGE_BYTES)], + truncated: true, + }; +} + +// Group pairs into alternating-color spans of SPAN_SIZE. +// The ellipsis marker (\u2026) gets its own span. +// When inverted, the first span is secondary-old and the second is primary (greenish first, white second). +export function formatHexSpans(pairs: string[], options: { inverted?: boolean } = {}): HexSpan[] { + const first: HexSpan['variant'] = options.inverted ? 'secondary-old' : 'primary'; + const second: HexSpan['variant'] = options.inverted ? 'primary' : 'secondary-old'; + const spans: HexSpan[] = []; + let pairIndex = 0; + + for (let i = 0; i < pairs.length; ) { + if (pairs[i] === '\u2026') { + spans.push({ text: '\u2026', variant: second }); + i++; + continue; + } + + const variant = pairIndex % (2 * SPAN_SIZE) === 0 ? first : second; + const chunk = pairs.slice(i, i + SPAN_SIZE).filter(p => p !== '\u2026'); + spans.push({ text: chunk.join(' '), variant }); + pairIndex += SPAN_SIZE; + i += chunk.length; + } + + return spans; +} + +// Group spans into rows of ROW_SIZE / SPAN_SIZE for the full hex dump view. +export function groupHexRows(spans: HexSpan[]): HexRow[] { + const spansPerRow = ROW_SIZE / SPAN_SIZE; + const rows: HexRow[] = []; + for (let i = 0; i < spans.length; i += spansPerRow) { + rows.push(spans.slice(i, i + spansPerRow)); + } + return rows; +} + +export function HexData({ + raw, + className, + copyableRaw, + truncate = false, + inverted = false, + align = 'end', +}: { + raw: ByteArray; + copyableRaw?: ByteArray; + className?: string; + truncate?: boolean; + inverted?: boolean; + // 'end' is the legacy default (right-aligned in table cells). + align?: 'start' | 'end'; +}) { + if (!raw || raw.length === 0) { + return No data; + } + + const hexString = toHex(raw); + const copyText = copyableRaw ? toHex(copyableRaw) : hexString; + + if (truncate) { + return ; + } + + return ( + + ); +} + +const hexSpanVariants = cva('', { + variants: { + tone: { + primary: 'e-text-white', + secondary: 'e-text-gray-500', + // Dashkit's text-gray-500 is rgb(171,213,198) — a teal-tinted gray. + // Keep for backward compat until dashkit is fully removed. + 'secondary-old': 'e-text-[rgb(171,213,198)]', + }, + }, +}); + +function ColoredSpans({ spans }: { spans: HexSpan[] }) { + return ( + <> + {spans.map((span, i) => ( + + {span.text}{' '} + + ))} + + ); +} + +function TruncatedContent({ + hexString, + copyText, + raw, + inverted, +}: { + hexString: string; + copyText: string; + raw: ByteArray; + inverted: boolean; +}) { + const { pairs: truncatedPairs, truncated } = truncateHexPairs(splitHexPairs(hexString)); + const spans = formatHexSpans(truncatedPairs, { inverted }); + + return ( + + + + + + + {truncated && ({raw.length} bytes)} + + ); +} + +const fullContentVariants = cva('e-items-center', { + variants: { + align: { + end: 'e-justify-end', + start: 'e-justify-start', + }, + }, +}); + +function FullContent({ + hexString, + copyText, + className, + inverted, + align, +}: { + hexString: string; + copyText: string; + className?: string; + inverted: boolean; + align: 'start' | 'end'; +}) { + const spans = formatHexSpans(splitHexPairs(hexString), { inverted }); + const rows = groupHexRows(spans); + + const divs = rows.map((row, rowIdx) => ( +
+ {row.map((span, spanIdx) => ( + + {span.text}  + + ))} +
+ )); + + return ( + <> +
+ +
{divs}
+
+
+
+ +
{divs}
+
+
+ + ); +} diff --git a/app/components/shared/__stories__/HexData.stories.tsx b/app/components/shared/__stories__/HexData.stories.tsx new file mode 100644 index 000000000..28445485e --- /dev/null +++ b/app/components/shared/__stories__/HexData.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { expect, within } from 'storybook/test'; + +import { HexData } from '../HexData'; + +const meta = { + component: HexData, + parameters: { layout: 'padded' }, + tags: ['autodocs', 'test'], + title: 'Shared/HexData', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// ── Shared data ────────────────────────────────────────────────────── + +const shortData = new Uint8Array([0x03, 0xe8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); +const multiRowData = new Uint8Array(Array.from({ length: 32 }, (_, i) => i)); +const truncateShortData = new Uint8Array([0xfe, 0xab, 0xcd]); +const truncateLongData = new Uint8Array(Array.from({ length: 67 }, (_, i) => i)); +const atThresholdData = new Uint8Array(Array.from({ length: 16 }, (_, i) => i + 0xa0)); + +// Centered default for autodocs preview +export const Default: Story = { + args: { align: 'start', raw: multiRowData }, + parameters: { layout: 'centered' }, +}; + +// ── Empty ──────────────────────────────────────────────────────────── + +export const Empty: Story = { + args: { raw: new Uint8Array(0) }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('No data')).toBeInTheDocument(); + }, +}; + +// ── Full mode ──────────────────────────────────────────────────────── + +export const FullShort: Story = { + args: { align: 'start', raw: shortData }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('03 e8 00 00').length).toBeGreaterThan(0); + }, +}; + +export const FullMultiRow: Story = { + args: { align: 'start', raw: multiRowData }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('00 01 02 03').length).toBeGreaterThan(0); + await expect(canvas.getAllByText('04 05 06 07').length).toBeGreaterThan(0); + await expect(canvas.getAllByText('10 11 12 13').length).toBeGreaterThan(0); + }, +}; + +// Legacy right-aligned (align="end", the default prop value) +export const FullLegacyAligned: Story = { + args: { raw: multiRowData }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('00 01 02 03').length).toBeGreaterThan(0); + }, +}; + +// ── Truncated mode ─────────────────────────────────────────────────── + +export const TruncatedShort: Story = { + args: { raw: truncateShortData, truncate: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('fe ab cd')).toBeInTheDocument(); + // eslint-disable-next-line no-restricted-syntax -- Testing Library partial text match requires regexp + expect(canvas.queryByText(/bytes/)).toBeNull(); + }, +}; + +export const TruncatedLong: Story = { + args: { raw: truncateLongData, truncate: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('00 01 02 03').length).toBeGreaterThan(0); + await expect(canvas.getAllByText('04 05 06 07').length).toBeGreaterThan(0); + // eslint-disable-next-line no-restricted-syntax -- Testing Library partial text match requires regexp + await expect(canvas.getByText(/\u2026/)).toBeInTheDocument(); + await expect(canvas.getByText('(67 bytes)')).toBeInTheDocument(); + }, +}; + +export const TruncatedAtThreshold: Story = { + args: { raw: atThresholdData, truncate: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('a0 a1 a2 a3').length).toBeGreaterThan(0); + await expect(canvas.getAllByText('ac ad ae af').length).toBeGreaterThan(0); + // eslint-disable-next-line no-restricted-syntax -- Testing Library partial text match requires regexp + expect(canvas.queryByText(/\u2026/)).toBeNull(); + // eslint-disable-next-line no-restricted-syntax -- Testing Library partial text match requires regexp + expect(canvas.queryByText(/bytes/)).toBeNull(); + }, +}; + +export const TruncatedInverted: Story = { + args: { inverted: true, raw: truncateLongData, truncate: true }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getAllByText('00 01 02 03').length).toBeGreaterThan(0); + await expect(canvas.getByText('(67 bytes)')).toBeInTheDocument(); + }, +}; diff --git a/app/components/shared/__tests__/hex-format.spec.ts b/app/components/shared/__tests__/hex-format.spec.ts new file mode 100644 index 000000000..892dfbbea --- /dev/null +++ b/app/components/shared/__tests__/hex-format.spec.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; + +import { formatHexSpans, groupHexRows, splitHexPairs, truncateHexPairs } from '../HexData'; + +describe('splitHexPairs', () => { + it.each([ + { expected: [], hex: '', label: 'empty string' }, + { expected: ['ab'], hex: 'ab', label: 'single byte' }, + { expected: ['ab', 'cd', 'ef'], hex: 'abcdef', label: 'three bytes' }, + { expected: ['01', '02', '03', '04'], hex: '01020304', label: 'four bytes' }, + ])('should split $label into pairs', ({ hex, expected }) => { + expect(splitHexPairs(hex)).toEqual(expected); + }); +}); + +describe('truncateHexPairs', () => { + it('should return all pairs when at threshold (16)', () => { + const pairs = Array.from({ length: 16 }, (_, i) => i.toString(16).padStart(2, '0')); + const result = truncateHexPairs(pairs); + expect(result.truncated).toBe(false); + expect(result.pairs).toEqual(pairs); + }); + + it('should truncate when above threshold', () => { + const pairs = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(2, '0')); + const result = truncateHexPairs(pairs); + expect(result.truncated).toBe(true); + expect(result.pairs).toHaveLength(17); // 8 head + ellipsis + 8 tail + expect(result.pairs[8]).toBe('\u2026'); + }); + + it('should return empty pairs unchanged', () => { + expect(truncateHexPairs([])).toEqual({ pairs: [], truncated: false }); + }); +}); + +describe('formatHexSpans', () => { + it('should return empty array for no pairs', () => { + expect(formatHexSpans([])).toEqual([]); + }); + + it('should group into a single primary span for short data', () => { + const spans = formatHexSpans(['ab']); + expect(spans).toEqual([{ text: 'ab', variant: 'primary' }]); + }); + + it('should alternate primary/secondary-old for spans of 4', () => { + const pairs = ['01', '02', '03', '04', '05', '06', '07', '08']; + const spans = formatHexSpans(pairs); + expect(spans).toHaveLength(2); + expect(spans[0].variant).toBe('primary'); + expect(spans[1].variant).toBe('secondary-old'); + }); + + it('should join pairs within a span with spaces', () => { + const spans = formatHexSpans(['aa', 'bb', 'cc']); + expect(spans[0].text).toBe('aa bb cc'); + }); + + it('should handle ellipsis marker as its own span', () => { + const pairs = ['01', '02', '03', '04', '\u2026', '05', '06', '07', '08']; + const spans = formatHexSpans(pairs); + const ellipsis = spans.find(s => s.text === '\u2026'); + expect(ellipsis).toBeDefined(); + expect(ellipsis?.variant).toBe('secondary-old'); + }); + + it('should compose with truncateHexPairs: formatHexSpans(truncateHexPairs(pairs))', () => { + const pairs = Array.from({ length: 20 }, (_, i) => i.toString(16).padStart(2, '0')); + const { pairs: truncated } = truncateHexPairs(pairs); + const spans = formatHexSpans(truncated); + + // Head spans + ellipsis + tail spans + expect(spans.some(s => s.text === '\u2026')).toBe(true); + expect(spans[0].variant).toBe('primary'); + expect(spans[0].text).toBe('00 01 02 03'); + }); +}); + +describe('groupHexRows', () => { + it('should return empty array for no spans', () => { + expect(groupHexRows([])).toEqual([]); + }); + + it('should group into rows of 4 spans', () => { + const pairs = Array.from({ length: 17 }, (_, i) => i.toString(16).padStart(2, '0')); + const spans = formatHexSpans(pairs); + const rows = groupHexRows(spans); + expect(rows).toHaveLength(2); + expect(rows[0]).toHaveLength(4); + expect(rows[1]).toHaveLength(1); + }); + + it('should restart alternation at each row', () => { + const pairs = Array.from({ length: 32 }, () => 'ff'); + const spans = formatHexSpans(pairs); + const rows = groupHexRows(spans); + expect(rows).toHaveLength(2); + expect(rows[0][0].variant).toBe('primary'); + expect(rows[1][0].variant).toBe('primary'); + }); +}); diff --git a/app/components/transaction/InstructionsSection.tsx b/app/components/transaction/InstructionsSection.tsx index aebbbbaf6..bcccc7135 100644 --- a/app/components/transaction/InstructionsSection.tsx +++ b/app/components/transaction/InstructionsSection.tsx @@ -23,6 +23,7 @@ import { VoteDetailsCard } from '@components/instruction/vote/VoteDetailsCard'; import { isWormholeInstruction } from '@components/instruction/wormhole/types'; import { WormholeDetailsCard } from '@components/instruction/WormholeDetailsCard'; import { useAnchorProgram } from '@entities/idl'; +import { isTokenBatchInstruction, TokenBatchCard } from '@features/token-batch'; import { useCluster } from '@providers/cluster'; import { useTransactionDetails, useTransactionStatus } from '@providers/transactions'; import { useFetchTransactionDetails } from '@providers/transactions/parsed'; @@ -235,37 +236,55 @@ function InstructionCard({ if (isEd25519Instruction(transactionIx)) { return ; - } else if (isMangoInstruction(transactionIx)) { + } + if (isMangoInstruction(transactionIx)) { return ; - } else if (isSerumInstruction(transactionIx)) { + } + if (isSerumInstruction(transactionIx)) { return ; - } else if (isTokenSwapInstruction(transactionIx)) { + } + if (isTokenSwapInstruction(transactionIx)) { return ; - } else if (isTokenLendingInstruction(transactionIx)) { + } + if (isTokenLendingInstruction(transactionIx)) { return ; - } else if (isWormholeInstruction(transactionIx)) { + } + if (isWormholeInstruction(transactionIx)) { return ; - } else if (isPythInstruction(transactionIx)) { + } + if (isPythInstruction(transactionIx)) { return ; - } else if (ComputeBudgetProgram.programId.equals(transactionIx.programId)) { + } + if (ComputeBudgetProgram.programId.equals(transactionIx.programId)) { return ; - } else if (isLighthouseInstruction(transactionIx)) { + } + if (isLighthouseInstruction(transactionIx)) { return ; - } else if (isSolanaAttestationInstruction(transactionIx)) { + } + if (isTokenBatchInstruction(transactionIx)) { + return ( + } key={key}> + + + ); + } + if (isSolanaAttestationInstruction(transactionIx)) { return ( } key={key}> ); - } else if (programMetadataIdl) { + } + if (programMetadataIdl) { return ; - } else if (anchorProgram) { + } + if (anchorProgram) { return ( } key={key}> ); - } else { - return ; } + + return ; } diff --git a/app/entities/account/index.ts b/app/entities/account/index.ts index 1cf0c2529..b6f7a6a7c 100644 --- a/app/entities/account/index.ts +++ b/app/entities/account/index.ts @@ -1,3 +1,5 @@ +export { selectMintDecimals, selectTokenAccountMint } from './model/selectors'; +export { useAccountQuery } from './model/use-account-query'; export { useAccountsInfo } from './model/use-accounts-info'; export type { AccountInfo } from './model/use-accounts-info'; export { useRawAccountData } from './model/use-raw-account-data'; diff --git a/app/entities/account/model/__tests__/selectors.spec.ts b/app/entities/account/model/__tests__/selectors.spec.ts new file mode 100644 index 000000000..afdf74d52 --- /dev/null +++ b/app/entities/account/model/__tests__/selectors.spec.ts @@ -0,0 +1,82 @@ +import type { Account } from '@providers/accounts'; +import { Keypair, SystemProgram } from '@solana/web3.js'; +import { describe, expect, it } from 'vitest'; + +import { selectMintDecimals, selectTokenAccountMint } from '../selectors'; + +const MINT_PUBKEY = Keypair.generate().publicKey; + +function makeBaseAccount(): Account { + return { + data: {}, + executable: false, + lamports: 0, + owner: SystemProgram.programId, + pubkey: Keypair.generate().publicKey, + }; +} + +function makeMintAccount(decimals: number): Account { + return { + ...makeBaseAccount(), + data: { + parsed: { + parsed: { + info: { + decimals, + freezeAuthority: null, + isInitialized: true, + mintAuthority: Keypair.generate().publicKey.toBase58(), + supply: '1000000', + }, + type: 'mint' as const, + }, + program: 'spl-token' as const, + }, + }, + }; +} + +function makeTokenAccount(mint: string): Account { + return { + ...makeBaseAccount(), + data: { + parsed: { + parsed: { + info: { + isNative: false, + mint, + owner: Keypair.generate().publicKey.toBase58(), + state: 'initialized', + tokenAmount: { amount: '1000000', decimals: 6, uiAmountString: '1' }, + }, + type: 'account' as const, + }, + program: 'spl-token' as const, + }, + }, + }; +} + +const MINT_ADDRESS = MINT_PUBKEY.toBase58(); + +describe('selectMintDecimals', () => { + it.each([ + { account: makeMintAccount(9), expected: 9, label: 'mint with 9 decimals' }, + { account: makeMintAccount(0), expected: 0, label: 'mint with 0 decimals' }, + { account: makeTokenAccount(MINT_ADDRESS), expected: undefined, label: 'token account' }, + { account: makeBaseAccount(), expected: undefined, label: 'unparsed account' }, + ])('should return $expected for $label', ({ account, expected }) => { + expect(selectMintDecimals(account)).toBe(expected); + }); +}); + +describe('selectTokenAccountMint', () => { + it.each([ + { account: makeTokenAccount(MINT_ADDRESS), expected: MINT_ADDRESS, label: 'valid token account' }, + { account: makeMintAccount(6), expected: undefined, label: 'mint account' }, + { account: makeBaseAccount(), expected: undefined, label: 'unparsed account' }, + ])('should return $expected for $label', ({ account, expected }) => { + expect(selectTokenAccountMint(account)).toBe(expected); + }); +}); diff --git a/app/entities/account/model/__tests__/use-account-query.spec.ts b/app/entities/account/model/__tests__/use-account-query.spec.ts new file mode 100644 index 000000000..f69d2e6af --- /dev/null +++ b/app/entities/account/model/__tests__/use-account-query.spec.ts @@ -0,0 +1,123 @@ +import { Account, FetchersContext, StateContext } from '@providers/accounts'; +import { FetchStatus } from '@providers/cache'; +import { NATIVE_MINT } from '@solana/spl-token'; +import { PublicKey, SystemProgram } from '@solana/web3.js'; +import { renderHook } from '@testing-library/react'; +import React from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import { MAINNET_BETA_URL } from '@/app/utils/cluster'; + +import { useAccountQuery } from '../use-account-query'; + +const TEST_ADDRESS = NATIVE_MINT.toBase58(); + +function makeAccount(pubkey: string): Account { + return { + data: {}, + executable: false, + lamports: 0, + owner: SystemProgram.programId, + pubkey: new PublicKey(pubkey), + }; +} + +function createWrapper(entries: Record = {}, fetchSpy = vi.fn()) { + const state = { entries, url: MAINNET_BETA_URL }; + const fetchers = { + parsed: { fetch: fetchSpy }, + raw: { fetch: vi.fn() }, + skip: { fetch: vi.fn() }, + }; + return { + fetchSpy, + wrapper: ({ children }: { children: React.ReactNode }) => + React.createElement( + StateContext.Provider, + { value: state }, + React.createElement(FetchersContext.Provider, { value: fetchers as any }, children), + ), + }; +} + +const selectLamports = (account: Account) => account.lamports; + +describe('useAccountQuery', () => { + it('should return disabled state when key is undefined', () => { + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useAccountQuery(undefined, { select: selectLamports }), { wrapper }); + + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); + }); + + it('should trigger fetch when cache entry is missing', () => { + const { wrapper, fetchSpy } = createWrapper(); + renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectLamports }), { wrapper }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + expect(fetchSpy.mock.calls[0][0].toBase58()).toBe(TEST_ADDRESS); + }); + + it('should not trigger fetch when cache entry exists', () => { + const { wrapper, fetchSpy } = createWrapper({ + [TEST_ADDRESS]: { data: makeAccount(TEST_ADDRESS), status: FetchStatus.Fetched }, + }); + renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectLamports }), { wrapper }); + + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('should return selected data when cache entry is fetched', () => { + const account = makeAccount(TEST_ADDRESS); + account.lamports = 42; + const { wrapper } = createWrapper({ + [TEST_ADDRESS]: { data: account, status: FetchStatus.Fetched }, + }); + const { result } = renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectLamports }), { wrapper }); + + expect(result.current.data).toBe(42); + expect(result.current.isLoading).toBe(false); + expect(result.current.isError).toBe(false); + }); + + it('should return isLoading true when entry is in Fetching status', () => { + const { wrapper } = createWrapper({ + [TEST_ADDRESS]: { status: FetchStatus.Fetching }, + }); + const { result } = renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectLamports }), { wrapper }); + + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(true); + }); + + it('should return isError true when fetch failed', () => { + const { wrapper } = createWrapper({ + [TEST_ADDRESS]: { status: FetchStatus.FetchFailed }, + }); + const { result } = renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectLamports }), { wrapper }); + + expect(result.current.isError).toBe(true); + expect(result.current.isLoading).toBe(false); + }); + + it('should return isLoading true when entry does not exist yet', () => { + const { wrapper } = createWrapper(); + const { result } = renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectLamports }), { wrapper }); + + expect(result.current.isLoading).toBe(true); + }); + + it('should return undefined from select when data does not match', () => { + const account = makeAccount(TEST_ADDRESS); + const { wrapper } = createWrapper({ + [TEST_ADDRESS]: { data: account, status: FetchStatus.Fetched }, + }); + const selectNothing = () => undefined; + const { result } = renderHook(() => useAccountQuery([TEST_ADDRESS], { select: selectNothing }), { wrapper }); + + expect(result.current.data).toBeUndefined(); + expect(result.current.isLoading).toBe(false); + }); +}); diff --git a/app/entities/account/model/selectors.ts b/app/entities/account/model/selectors.ts new file mode 100644 index 000000000..fb51e9e46 --- /dev/null +++ b/app/entities/account/model/selectors.ts @@ -0,0 +1,31 @@ +import { type Account, isTokenProgramData } from '@providers/accounts'; +import { MintAccountInfo, TokenAccountInfo } from '@validators/accounts/token'; +import { create } from 'superstruct'; + +// Extract typed fields from a parsed Account for use with useAccountQuery's `select` option. + +export function selectTokenAccountMint(account: Account): string | undefined { + const parsedData = account.data.parsed; + if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'account') { + return undefined; + } + try { + const info = create(parsedData.parsed.info, TokenAccountInfo); + return info.mint.toBase58(); + } catch { + return undefined; + } +} + +export function selectMintDecimals(account: Account): number | undefined { + const parsedData = account.data.parsed; + if (!parsedData || !isTokenProgramData(parsedData) || parsedData.parsed.type !== 'mint') { + return undefined; + } + try { + const info = create(parsedData.parsed.info, MintAccountInfo); + return info.decimals; + } catch { + return undefined; + } +} diff --git a/app/entities/account/model/use-account-query.ts b/app/entities/account/model/use-account-query.ts new file mode 100644 index 000000000..a24e2139a --- /dev/null +++ b/app/entities/account/model/use-account-query.ts @@ -0,0 +1,59 @@ +'use client'; + +import type { Account, FetchAccountDataMode } from '@providers/accounts'; +import { FetchersContext, StateContext } from '@providers/accounts'; +import { FetchStatus } from '@providers/cache'; +import { PublicKey } from '@solana/web3.js'; +import { useContext, useEffect, useMemo } from 'react'; + +type UseAccountQueryOptions = { + select: (account: Account) => TData | undefined; + dataMode?: FetchAccountDataMode; +}; + +type UseAccountQueryResult = { + data: TData | undefined; + isLoading: boolean; + isError: boolean; +}; + +// Thin wrapper over AccountsProvider with a react-query-like API. +// key[0] is always the account address. key = undefined disables the query. +export function useAccountQuery( + key: readonly [address: string, ...unknown[]] | undefined, + options: UseAccountQueryOptions, +): UseAccountQueryResult { + const state = useContext(StateContext); + const fetchers = useContext(FetchersContext); + + if (!state || !fetchers) { + throw new Error('useAccountQuery must be used within an AccountsProvider'); + } + + const address = key?.[0]; + const dataMode = options.dataMode ?? 'parsed'; + const { select } = options; + + const cacheEntry = address ? state.entries[address] : undefined; + + useEffect(() => { + if (!address) return; + if (cacheEntry) return; + fetchers[dataMode].fetch(new PublicKey(address)); + }, [address, cacheEntry, dataMode, fetchers]); + + const data = useMemo(() => { + if (!cacheEntry?.data) return undefined; + return select(cacheEntry.data); + }, [cacheEntry?.data, select]); + + if (!key) { + return { data: undefined, isError: false, isLoading: false }; + } + + return { + data, + isError: cacheEntry?.status === FetchStatus.FetchFailed, + isLoading: !cacheEntry || cacheEntry.status === FetchStatus.Fetching, + }; +} diff --git a/app/entities/token-amount/index.ts b/app/entities/token-amount/index.ts new file mode 100644 index 000000000..dc5dfde4d --- /dev/null +++ b/app/entities/token-amount/index.ts @@ -0,0 +1,3 @@ +export { formatTokenAmount, tokenAmountToFiat, tokenAmountToNumber } from './lib/format'; +export type { FormatOptions } from './lib/format'; +export type { TokenAmount } from './lib/types'; diff --git a/app/entities/token-amount/lib/__tests__/format.spec.ts b/app/entities/token-amount/lib/__tests__/format.spec.ts new file mode 100644 index 000000000..3ae6fc54c --- /dev/null +++ b/app/entities/token-amount/lib/__tests__/format.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { formatTokenAmount, tokenAmountToFiat, tokenAmountToNumber } from '../format'; + +describe('formatTokenAmount', () => { + it.each([ + // wSOL (9 decimals) + { amount: 1_000_000_000n, decimals: 9, expected: '1', label: 'wSOL: 1 SOL' }, + { amount: 1_500_000_000n, decimals: 9, expected: '1.5', label: 'wSOL: 1.5 SOL' }, + { amount: 1n, decimals: 9, expected: '0.000000001', label: 'wSOL: smallest unit (1 lamport)' }, + // USDC (6 decimals) + { amount: 1_000_000n, decimals: 6, expected: '1', label: 'USDC: 1.0' }, + { amount: 1_500_000n, decimals: 6, expected: '1.5', label: 'USDC: 1.5' }, + { amount: 1n, decimals: 6, expected: '0.000001', label: 'USDC: smallest unit' }, + { amount: 100n, decimals: 6, expected: '0.0001', label: 'USDC: 0.0001' }, + // Edge cases + { amount: 42n, decimals: 0, expected: '42', label: 'zero-decimal token' }, + { amount: 0n, decimals: 6, expected: '0', label: 'zero amount' }, + ])('should format $label → $expected', ({ amount, decimals, expected }) => { + expect(formatTokenAmount({ amount, decimals })).toBe(expected); + }); + + it('should produce different results for same raw amount with different decimals', () => { + const raw = 1_000_000n; + expect(formatTokenAmount({ amount: raw, decimals: 6 })).toBe('1'); + expect(formatTokenAmount({ amount: raw, decimals: 9 })).toBe('0.001'); + }); + + // Number amounts + it.each([ + { amount: 1_000_000, decimals: 6, expected: '1', label: 'number: USDC 1.0' }, + { amount: 1_500_000, decimals: 6, expected: '1.5', label: 'number: USDC 1.5' }, + { amount: 42, decimals: 0, expected: '42', label: 'number: zero-decimal' }, + { amount: 0, decimals: 6, expected: '0', label: 'number: zero amount' }, + ])('should format $label → $expected', ({ amount, decimals, expected }) => { + expect(formatTokenAmount({ amount, decimals })).toBe(expected); + }); +}); + +describe('tokenAmountToNumber', () => { + it.each([ + { amount: 1_500_000_000n, decimals: 9, expected: 1.5, label: 'wSOL: 1.5 SOL' }, + { amount: 1_000_000n, decimals: 6, expected: 1, label: 'USDC: 1.0' }, + { amount: 42n, decimals: 0, expected: 42, label: 'zero-decimal token' }, + { amount: 0n, decimals: 6, expected: 0, label: 'zero amount' }, + ])('should convert $label → $expected', ({ amount, decimals, expected }) => { + expect(tokenAmountToNumber({ amount, decimals })).toBe(expected); + }); + + it('should produce different results for same raw amount with different decimals', () => { + const raw = 1_000_000n; + expect(tokenAmountToNumber({ amount: raw, decimals: 6 })).toBe(1); + expect(tokenAmountToNumber({ amount: raw, decimals: 9 })).toBe(0.001); + }); + + it.each([ + { amount: 1_500_000, decimals: 6, expected: 1.5, label: 'number: USDC 1.5' }, + { amount: 42, decimals: 0, expected: 42, label: 'number: zero-decimal' }, + ])('should convert number $label → $expected', ({ amount, decimals, expected }) => { + expect(tokenAmountToNumber({ amount, decimals })).toBe(expected); + }); +}); + +describe('tokenAmountToFiat', () => { + it.each([ + // 1.5 SOL at $150/SOL = $225 + { amount: 1_500_000_000n, decimals: 9, expected: 225, label: 'wSOL at 150', price: 150 }, + // 1 USDC at $1/USDC = $1 + { amount: 1_000_000n, decimals: 6, expected: 1, label: 'USDC at 1', price: 1 }, + // 0.5 USDC at $1/USDC = $0.5 + { amount: 500_000n, decimals: 6, expected: 0.5, label: 'USDC at 1 (half)', price: 1 }, + ])('should convert $label → $expected', ({ amount, decimals, price, expected }) => { + expect(tokenAmountToFiat({ amount, decimals }, price)).toBe(expected); + }); + + it('should apply decimals before price — not multiply raw amount', () => { + // The bug: 1_500_000_000 * 150 = 225_000_000_000 (wrong) + // Correct: 1.5 * 150 = 225 + const sol = { amount: 1_500_000_000n, decimals: 9 }; + expect(tokenAmountToFiat(sol, 150)).toBe(225); + expect(Number(sol.amount) * 150).toBe(225_000_000_000); // raw multiplication is wrong + }); +}); diff --git a/app/entities/token-amount/lib/format.ts b/app/entities/token-amount/lib/format.ts new file mode 100644 index 000000000..302a9a23c --- /dev/null +++ b/app/entities/token-amount/lib/format.ts @@ -0,0 +1,53 @@ +import type { TokenAmount } from './types'; + +export type FormatOptions = { + locale?: string; + intl?: Intl.NumberFormatOptions; +}; + +export function formatTokenAmount({ amount, decimals }: TokenAmount, options: FormatOptions = {}): string { + if (typeof amount === 'bigint' && !options.locale && !options.intl) { + return formatBigint(amount, decimals); + } + return formatWithIntl({ amount, decimals }, options); +} + +// Note: precision loss is possible for bigint amounts exceeding Number.MAX_SAFE_INTEGER. +export function tokenAmountToNumber({ amount, decimals }: TokenAmount): number { + const num = typeof amount === 'bigint' ? Number(amount) : amount; + if (decimals === 0) return num; + return num / 10 ** decimals; +} + +export function tokenAmountToFiat(tokenAmount: TokenAmount, pricePerToken: number): number { + return tokenAmountToNumber(tokenAmount) * pricePerToken; +} + +function formatBigint(amount: bigint, decimals: number): string { + if (decimals === 0) return amount.toString(); + + const divisor = 10n ** BigInt(decimals); + const whole = amount / divisor; + const fractional = amount % divisor; + + if (fractional === 0n) return whole.toString(); + + const padded = fractional.toString().padStart(decimals, '0'); + return `${whole}.${trimTrailingZeros(padded)}`; +} + +function trimTrailingZeros(str: string): string { + let end = str.length; + while (end > 0 && str[end - 1] === '0') end--; + return str.slice(0, end); +} + +function formatWithIntl({ amount, decimals }: TokenAmount, options: FormatOptions = {}): string { + const { locale = 'en-US', intl } = options; + const value = tokenAmountToNumber({ amount, decimals }); + return new Intl.NumberFormat(locale, { + maximumFractionDigits: decimals, + useGrouping: false, + ...intl, + }).format(value); +} diff --git a/app/entities/token-amount/lib/types.ts b/app/entities/token-amount/lib/types.ts new file mode 100644 index 000000000..962371d2b --- /dev/null +++ b/app/entities/token-amount/lib/types.ts @@ -0,0 +1,4 @@ +export type TokenAmount = { + amount: bigint | number; + decimals: number; +}; diff --git a/app/features/account/ui/__tests__/AccountDownloadDropdown.test.tsx b/app/features/account/ui/__tests__/AccountDownloadDropdown.test.tsx index 16304b162..9724a4870 100644 --- a/app/features/account/ui/__tests__/AccountDownloadDropdown.test.tsx +++ b/app/features/account/ui/__tests__/AccountDownloadDropdown.test.tsx @@ -1,5 +1,5 @@ import { PublicKey } from '@solana/web3.js'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import { AccountDownloadDropdown } from '../AccountDownloadDropdown'; @@ -48,7 +48,7 @@ describe('AccountDownloadDropdown', () => { it('should fetch raw data when dropdown opens', () => { render(); - screen.getByText('Download').click(); + fireEvent.click(screen.getByText('Download')); expect(mockMutate).toHaveBeenCalled(); }); diff --git a/app/features/token-batch/index.ts b/app/features/token-batch/index.ts new file mode 100644 index 000000000..39ef27014 --- /dev/null +++ b/app/features/token-batch/index.ts @@ -0,0 +1,2 @@ +export { isTokenBatchInstruction } from './lib/batch-parser'; +export { TokenBatchCard } from './ui/TokenBatchCard'; diff --git a/app/features/token-batch/lib/__tests__/batch-parser.spec.ts b/app/features/token-batch/lib/__tests__/batch-parser.spec.ts new file mode 100644 index 000000000..250a702fb --- /dev/null +++ b/app/features/token-batch/lib/__tests__/batch-parser.spec.ts @@ -0,0 +1,367 @@ +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@providers/accounts/tokens'; +import { Keypair } from '@solana/web3.js'; +import { describe, expect, it } from 'vitest'; + +import { concatBytes, writeU64LE } from '@/app/shared/lib/bytes'; + +import { isTokenBatchInstruction, parseBatchInstruction } from '../batch-parser'; +import { BATCH_DISCRIMINATOR } from '../const'; +import { decodeSubInstructionParams } from '../decode-sub-instruction'; +import { + buildBatchData, + makeAccount, + makeApproveCheckedData, + makeApproveData, + makeBurnCheckedData, + makeMintToCheckedData, + makeSetAuthorityData, + makeTransferCheckedData, + makeTransferData, +} from './test-utils'; + +describe('isTokenBatchInstruction', () => { + it.each([ + { + data: new Uint8Array([0xff, 3, 9, 3, 0, 0, 0, 0, 0, 0, 0, 1]), + expected: true, + label: 'Token Program batch', + programId: TOKEN_PROGRAM_ID, + }, + { data: new Uint8Array([0xff]), expected: true, label: 'Token-2022 batch', programId: TOKEN_2022_PROGRAM_ID }, + { + data: new Uint8Array([3, 0, 0, 0, 0, 0, 0, 0, 1]), + expected: false, + label: 'non-batch discriminator', + programId: TOKEN_PROGRAM_ID, + }, + { + data: new Uint8Array([0xff]), + expected: false, + label: 'non-token program', + programId: Keypair.generate().publicKey, + }, + { data: new Uint8Array(0), expected: false, label: 'empty data', programId: TOKEN_PROGRAM_ID }, + ])('should return $expected for $label', ({ data, programId, expected }) => { + expect(isTokenBatchInstruction({ data, keys: [], programId })).toBe(expected); + }); +}); + +describe('parseBatchInstruction', () => { + it('should parse a single Transfer sub-instruction', () => { + const transferData = makeTransferData(1000n); + const data = buildBatchData([{ data: transferData, numAccounts: 3 }]); + const accounts = [makeAccount(), makeAccount(), makeAccount(false, true)]; + + const result = parseBatchInstruction(data, accounts); + + expect(result).toHaveLength(1); + expect(result[0].discriminator).toBe(3); + expect(result[0].typeName).toBe('Transfer'); + expect(result[0].accounts).toHaveLength(3); + expect(result[0].index).toBe(0); + }); + + it('should parse multiple sub-instructions', () => { + const transfer1 = makeTransferData(100n); + const transfer2 = makeTransferData(200n); + const transfer3 = makeTransferData(300n); + + const data = buildBatchData([ + { data: transfer1, numAccounts: 3 }, + { data: transfer2, numAccounts: 3 }, + { data: transfer3, numAccounts: 3 }, + ]); + + const accounts = Array.from({ length: 9 }, () => makeAccount()); + + const result = parseBatchInstruction(data, accounts); + + expect(result).toHaveLength(3); + expect(result[0].index).toBe(0); + expect(result[1].index).toBe(1); + expect(result[2].index).toBe(2); + }); + + it('should consume accounts sequentially', () => { + const transfer1 = makeTransferData(100n); + const transfer2 = makeTransferData(200n); + + const data = buildBatchData([ + { data: transfer1, numAccounts: 3 }, + { data: transfer2, numAccounts: 3 }, + ]); + + const accounts = Array.from({ length: 6 }, () => ({ + isSigner: false, + isWritable: true, + pubkey: Keypair.generate().publicKey, + })); + + const result = parseBatchInstruction(data, accounts); + + expect(result[0].accounts).toEqual(accounts.slice(0, 3)); + expect(result[1].accounts).toEqual(accounts.slice(3, 6)); + }); + + it('should handle empty batch', () => { + const data = new Uint8Array([BATCH_DISCRIMINATOR]); + const result = parseBatchInstruction(data, []); + + expect(result).toHaveLength(0); + }); + + it('should throw on truncated data (missing data_len)', () => { + const data = new Uint8Array([BATCH_DISCRIMINATOR, 3]); + const accounts = Array.from({ length: 3 }, () => makeAccount()); + + expect(() => parseBatchInstruction(data, accounts)).toThrow('Truncated data'); + }); + + it('should throw on truncated sub-instruction data', () => { + const data = new Uint8Array([BATCH_DISCRIMINATOR, 3, 9, 3]); // says 9 bytes but only 1 follows + const accounts = Array.from({ length: 3 }, () => makeAccount()); + + expect(() => parseBatchInstruction(data, accounts)).toThrow('Truncated data'); + }); + + it('should throw on insufficient accounts', () => { + const transferData = makeTransferData(100n); + const data = buildBatchData([{ data: transferData, numAccounts: 3 }]); + const accounts = [makeAccount()]; // only 1 account, needs 3 + + expect(() => parseBatchInstruction(data, accounts)).toThrow('Insufficient accounts'); + }); + + it('should throw on non-batch data', () => { + const data = new Uint8Array([3, 0, 0, 0]); + + expect(() => parseBatchInstruction(data, [])).toThrow('Not a batch instruction'); + }); + + it('should identify unknown discriminators', () => { + const unknownData = new Uint8Array([0xfe, 1, 2, 3]); + const data = buildBatchData([{ data: unknownData, numAccounts: 1 }]); + const accounts = [makeAccount()]; + + const result = parseBatchInstruction(data, accounts); + + expect(result[0].typeName).toBe('Unknown'); + }); + + it('should parse mixed instruction types', () => { + const transfer = makeTransferData(100n); + const transferChecked = makeTransferCheckedData(200n, 6); + + const data = buildBatchData([ + { data: transfer, numAccounts: 3 }, + { data: transferChecked, numAccounts: 4 }, + ]); + + const accounts = Array.from({ length: 7 }, () => makeAccount()); + + const result = parseBatchInstruction(data, accounts); + + expect(result).toHaveLength(2); + expect(result[0].typeName).toBe('Transfer'); + expect(result[1].typeName).toBe('TransferChecked'); + }); +}); + +describe('decodeSubInstructionParams', () => { + // Unchecked amount instructions — raw amount, no decimals in data + it.each([ + { + amount: 42000n, + data: makeTransferData(42000n), + expected: '42000', + labels: ['Source', 'Destination', 'Owner/Delegate'], + numAccounts: 3, + type: 'Transfer' as const, + }, + { + amount: 500n, + data: makeApproveData(500n), + expected: '500', + labels: ['Source', 'Delegate', 'Owner'], + numAccounts: 3, + type: 'Approve' as const, + }, + { + amount: 5000n, + data: concatBytes(new Uint8Array([7]), writeU64LE(5000n)), + expected: '5000', + labels: ['Mint', 'Destination', 'Mint Authority'], + numAccounts: 3, + type: 'MintTo' as const, + }, + { + amount: 3000n, + data: concatBytes(new Uint8Array([8]), writeU64LE(3000n)), + expected: '3000', + labels: ['Account', 'Mint', 'Owner/Delegate'], + numAccounts: 3, + type: 'Burn' as const, + }, + ])('should decode $type with amount $expected', ({ type, data, numAccounts, expected, labels }) => { + const accounts = Array.from({ length: numAccounts }, (_, i) => + makeAccount(i < numAccounts - 1, i === numAccounts - 1), + ); + const decoded = decodeSubInstructionParams(type, data, accounts); + + if (!decoded) throw new Error(`Expected ${type} to decode`); + expect(decoded.fields).toEqual([{ label: 'Amount', value: expected }]); + expect(decoded.accounts.map(a => a.label)).toEqual(labels); + }); + + // Checked amount instructions — amount + decimals in data + it.each([ + { + data: makeTransferCheckedData(1000000n, 9), + expectedAmount: '0.001', + expectedDecimals: '9', + labels: ['Source', 'Mint', 'Destination', 'Owner/Delegate'], + numAccounts: 4, + type: 'TransferChecked' as const, + }, + { + data: makeApproveCheckedData(2000000n, 6), + expectedAmount: '2', + expectedDecimals: '6', + labels: ['Source', 'Mint', 'Delegate', 'Owner'], + numAccounts: 4, + type: 'ApproveChecked' as const, + }, + { + data: makeMintToCheckedData(50000000n, 8), + expectedAmount: '0.5', + expectedDecimals: '8', + labels: ['Mint', 'Destination', 'Mint Authority'], + numAccounts: 3, + type: 'MintToChecked' as const, + }, + { + data: makeBurnCheckedData(1500000000n, 9), + expectedAmount: '1.5', + expectedDecimals: '9', + labels: ['Account', 'Mint', 'Owner/Delegate'], + numAccounts: 3, + type: 'BurnChecked' as const, + }, + ])( + 'should decode $type with amount $expectedAmount', + ({ type, data, numAccounts, expectedAmount, expectedDecimals, labels }) => { + const accounts = Array.from({ length: numAccounts }, (_, i) => + makeAccount(i < numAccounts - 1, i === numAccounts - 1), + ); + const decoded = decodeSubInstructionParams(type, data, accounts); + + if (!decoded) throw new Error(`Expected ${type} to decode`); + expect(decoded.fields).toEqual([ + { label: 'Decimals', value: expectedDecimals }, + { label: 'Amount', value: expectedAmount }, + ]); + expect(decoded.accounts.map(a => a.label)).toEqual(labels); + }, + ); + + it('should decode CloseAccount params', () => { + const data = new Uint8Array([9]); + const accounts = [makeAccount(), makeAccount(), makeAccount(false, true)]; + const decoded = decodeSubInstructionParams('CloseAccount', data, accounts); + + if (!decoded) throw new Error('Expected CloseAccount to decode'); + expect(decoded.fields).toEqual([]); + expect(decoded.accounts.map(a => a.label)).toEqual(['Account', 'Destination', 'Owner']); + }); + + it('should include signer labels for multisig', () => { + const data = makeTransferData(100n); + const accounts = [ + makeAccount(), + makeAccount(), + makeAccount(false, true), + makeAccount(false, true), + makeAccount(false, true), + ]; + const decoded = decodeSubInstructionParams('Transfer', data, accounts); + + if (!decoded) throw new Error('Expected Transfer to decode'); + expect(decoded.accounts.map(a => a.label)).toEqual([ + 'Source', + 'Destination', + 'Owner/Delegate', + 'Signer 1', + 'Signer 2', + ]); + }); + + it('should decode SetAuthority with new authority set to None', () => { + const data = makeSetAuthorityData(1); + const accounts = [makeAccount(), makeAccount(false, true)]; + const decoded = decodeSubInstructionParams('SetAuthority', data, accounts); + + if (!decoded) throw new Error('Expected SetAuthority to decode'); + expect(decoded.fields).toEqual([ + { label: 'Authority Type', value: 'FreezeAccount' }, + { label: 'New Authority', value: '(none)' }, + ]); + expect(decoded.accounts.map(a => a.label)).toEqual(['Account', 'Current Authority']); + }); + + it('should decode SetAuthority with new authority set to Some', () => { + const newAuth = Keypair.generate().publicKey; + const data = makeSetAuthorityData(0, newAuth); + const accounts = [makeAccount(), makeAccount(false, true)]; + const decoded = decodeSubInstructionParams('SetAuthority', data, accounts); + + if (!decoded) throw new Error('Expected SetAuthority to decode'); + expect(decoded.fields).toEqual([ + { label: 'Authority Type', value: 'MintTokens' }, + { isAddress: true, label: 'New Authority', value: newAuth.toBase58() }, + ]); + }); + + // Truncated / malformed data — all return undefined + it.each([ + { data: new Uint8Array([0xfe, 1, 2, 3]), label: 'unknown discriminator', type: 'Unknown' as const }, + { data: new Uint8Array([3, 0, 0, 0]), label: 'truncated Transfer', type: 'Transfer' as const }, + { + data: concatBytes(new Uint8Array([12]), writeU64LE(100n)), + label: 'truncated TransferChecked', + type: 'TransferChecked' as const, + }, + { data: new Uint8Array([6]), label: 'truncated SetAuthority', type: 'SetAuthority' as const }, + { + data: new Uint8Array([6, 0, 1]), + label: 'SetAuthority with Some but missing pubkey', + type: 'SetAuthority' as const, + }, + { data: new Uint8Array([]), label: 'empty CloseAccount', type: 'CloseAccount' as const }, + ])('should return undefined for $label', ({ type, data }) => { + expect(decodeSubInstructionParams(type, data, [makeAccount(), makeAccount(false, true)])).toBeUndefined(); + }); + + // External mintInfo — formats unchecked amount with provided decimals + it.each([ + { amount: 1500000n, data: makeTransferData(1500000n), decimals: 6, expected: '1.5', type: 'Transfer' as const }, + { + amount: 25000000n, + data: makeApproveData(25000000n), + decimals: 8, + expected: '0.25', + type: 'Approve' as const, + }, + ])( + 'should format $type amount as $expected when mintInfo provides $decimals decimals', + ({ type, data, decimals, expected }) => { + const accounts = [makeAccount(), makeAccount(), makeAccount(false, true)]; + const mint = Keypair.generate().publicKey.toBase58(); + const decoded = decodeSubInstructionParams(type, data, accounts, { decimals, mint }); + + if (!decoded) throw new Error(`Expected ${type} to decode`); + expect(decoded.fields).toEqual([{ label: 'Amount', value: expected }]); + expect(decoded.accounts[1].label).toBe('Mint*'); + expect(decoded.accounts[1].pubkey.toBase58()).toBe(mint); + }, + ); +}); diff --git a/app/features/token-batch/lib/__tests__/test-utils.ts b/app/features/token-batch/lib/__tests__/test-utils.ts new file mode 100644 index 000000000..30370437e --- /dev/null +++ b/app/features/token-batch/lib/__tests__/test-utils.ts @@ -0,0 +1,104 @@ +import { TOKEN_2022_PROGRAM_ID } from '@providers/accounts/tokens'; +import { Keypair, PublicKey, TransactionInstruction } from '@solana/web3.js'; + +import { concatBytes, toBuffer, writeU64LE } from '@/app/shared/lib/bytes'; + +import { BATCH_DISCRIMINATOR } from '../const'; + +export function makeAccount(writable = true, signer = false) { + return { + isSigner: signer, + isWritable: writable, + pubkey: Keypair.generate().publicKey, + }; +} + +export function buildBatchData(subInstructions: { numAccounts: number; data: Uint8Array }[]): Uint8Array { + const parts: Uint8Array[] = [new Uint8Array([BATCH_DISCRIMINATOR])]; + for (const sub of subInstructions) { + if (sub.data.length > 255) throw new Error(`Sub-instruction data exceeds u8 max (${sub.data.length} bytes)`); + parts.push(new Uint8Array([sub.numAccounts, sub.data.length])); + parts.push(sub.data); + } + return concatBytes(...parts); +} + +export function makeTransferData(amount: bigint): Uint8Array { + return concatBytes(new Uint8Array([3]), writeU64LE(amount)); +} + +export function makeTransferCheckedData(amount: bigint, decimals: number): Uint8Array { + return concatBytes(new Uint8Array([12]), writeU64LE(amount), new Uint8Array([decimals])); +} + +export function makeApproveData(amount: bigint): Uint8Array { + return concatBytes(new Uint8Array([4]), writeU64LE(amount)); +} + +export function makeApproveCheckedData(amount: bigint, decimals: number): Uint8Array { + return concatBytes(new Uint8Array([13]), writeU64LE(amount), new Uint8Array([decimals])); +} + +export function makeMintToCheckedData(amount: bigint, decimals: number): Uint8Array { + return concatBytes(new Uint8Array([14]), writeU64LE(amount), new Uint8Array([decimals])); +} + +export function makeBurnData(amount: bigint): Uint8Array { + return concatBytes(new Uint8Array([8]), writeU64LE(amount)); +} + +export function makeBurnCheckedData(amount: bigint, decimals: number): Uint8Array { + return concatBytes(new Uint8Array([15]), writeU64LE(amount), new Uint8Array([decimals])); +} + +// SetAuthority: [discriminator(6), authority_type(u8), option_tag(u8), ?new_authority(32)] +export function makeSetAuthorityData(authorityType: number, newAuthority?: PublicKey): Uint8Array { + if (newAuthority) { + return concatBytes(new Uint8Array([6, authorityType, 1]), newAuthority.toBytes()); + } + return new Uint8Array([6, authorityType, 0]); +} + +export function encodeSubIx(numAccounts: number, data: Uint8Array): Uint8Array { + if (data.length > 255) throw new Error(`Sub-instruction data exceeds u8 max (${data.length} bytes)`); + const out = new Uint8Array(2 + data.length); + out[0] = numAccounts; + out[1] = data.length; + out.set(data, 2); + return out; +} + +export function makeBatchIx( + subIxs: { numAccounts: number; data: Uint8Array }[], + totalAccounts: number, +): TransactionInstruction { + const body = concatBytes( + new Uint8Array([BATCH_DISCRIMINATOR]), + ...subIxs.map(s => encodeSubIx(s.numAccounts, s.data)), + ); + const keys = Array.from({ length: totalAccounts }, (_, i) => makeAccount(i % 2 === 0, i === totalAccounts - 1)); + + // toBuffer required by @solana/web3.js TransactionInstruction constructor + return new TransactionInstruction({ + data: toBuffer(body), + keys, + programId: TOKEN_2022_PROGRAM_ID, + }); +} + +export function makeBatchIxWithKeys( + subIxs: { numAccounts: number; data: Uint8Array }[], + keys: ReturnType[], +): TransactionInstruction { + const body = concatBytes( + new Uint8Array([BATCH_DISCRIMINATOR]), + ...subIxs.map(s => encodeSubIx(s.numAccounts, s.data)), + ); + + // toBuffer required by @solana/web3.js TransactionInstruction constructor + return new TransactionInstruction({ + data: toBuffer(body), + keys, + programId: TOKEN_2022_PROGRAM_ID, + }); +} diff --git a/app/features/token-batch/lib/batch-parser.ts b/app/features/token-batch/lib/batch-parser.ts new file mode 100644 index 000000000..97f6afb73 --- /dev/null +++ b/app/features/token-batch/lib/batch-parser.ts @@ -0,0 +1,99 @@ +// Manual parser for SPL Token batched instructions (discriminator 0xff). +// +// Why not use @solana/spl-token or @solana-program/token? +// Neither package exposes batch instruction decoding. The batch wire format +// (a sequence of packed sub-instructions inside a single instruction's data) +// is a runtime feature of the Token / Token-2022 programs but has no +// corresponding JS decoder in any published SDK version. See also the comment +// in decode-sub-instruction.ts for per-sub-instruction decoding rationale. +// +// Wire format reference: +// https://github.com/solana-program/token/blob/065786e/pinocchio/interface/src/instruction.rs#L552 + +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from '@providers/accounts/tokens'; +import type { AccountMeta, PublicKey } from '@solana/web3.js'; + +import { readU8 } from '@/app/shared/lib/bytes'; +import { Logger } from '@/app/shared/lib/logger'; + +import { BATCH_DISCRIMINATOR, type TokenInstructionName, typeNameByDiscriminator } from './const'; + +export type ParsedBatchSubInstruction = { + index: number; + accounts: T[]; + data: Uint8Array; + discriminator: number | undefined; + typeName: TokenInstructionName | 'Unknown'; +}; + +export type ParsedSubInstruction = ParsedBatchSubInstruction; + +// Uses a structural type instead of TransactionInstruction so callers don't +// need to construct a full web3.js object in tests — only programId and the +// first byte of data are inspected. +export function isTokenBatchInstruction(ix: { programId: PublicKey; data: { length: number; 0?: number } }): boolean { + const isTokenProgram = ix.programId.equals(TOKEN_PROGRAM_ID) || ix.programId.equals(TOKEN_2022_PROGRAM_ID); + if (!isTokenProgram) return false; + + return hasBatchDiscriminator(ix.data); +} + +export function parseBatchInstruction(data: Uint8Array, accounts: T[]): ParsedBatchSubInstruction[] { + if (!hasBatchDiscriminator(data)) { + throw new Error('Not a batch instruction'); + } + + const subInstructions: ParsedBatchSubInstruction[] = []; + let offset = 1; // skip batch discriminator + let accountOffset = 0; + let subIndex = 0; + + while (offset < data.length) { + if (offset + 2 > data.length) { + throw new Error(`Truncated data: expected num_accounts and data_len at offset ${offset}`); + } + // Wire format packs both fields as single u8 values (see header comment link) + const numAccounts = readU8(data, offset); + const dataLen = readU8(data, offset + 1); + offset += 2; + + // Read sub-instruction data + if (offset + dataLen > data.length) { + throw new Error( + `Truncated data: expected ${dataLen} bytes at offset ${offset}, but only ${data.length - offset} remain`, + ); + } + const subData = data.slice(offset, offset + dataLen); + offset += dataLen; + + // Slice accounts + if (accountOffset + numAccounts > accounts.length) { + throw new Error(`Insufficient accounts: need ${accountOffset + numAccounts}, have ${accounts.length}`); + } + const subAccounts = accounts.slice(accountOffset, accountOffset + numAccounts); + accountOffset += numAccounts; + + const discriminator = subData.length > 0 ? subData[0] : undefined; + const typeName = (discriminator !== undefined && typeNameByDiscriminator[discriminator]) || 'Unknown'; + + if (typeName === 'Unknown') { + Logger.warn('[token-batch] Unknown sub-instruction discriminator', { discriminator, index: subIndex }); + } + + subInstructions.push({ + accounts: subAccounts, + data: subData, + discriminator, + index: subIndex, + typeName, + }); + + subIndex++; + } + + return subInstructions; +} + +function hasBatchDiscriminator(data: { length: number; 0?: number }): boolean { + return data.length >= 1 && data[0] === BATCH_DISCRIMINATOR; +} diff --git a/app/features/token-batch/lib/const.ts b/app/features/token-batch/lib/const.ts new file mode 100644 index 000000000..9456daf99 --- /dev/null +++ b/app/features/token-batch/lib/const.ts @@ -0,0 +1,70 @@ +// `TokenInstruction::Batch` (discriminator 255) — executes a sequence of sub-instructions +// packed into a single instruction's data. Each sub-instruction is encoded as: +// u8 num_accounts | u8 data_len | u8 discriminator | [u8] data +// https://github.com/solana-program/token/blob/065786e/pinocchio/interface/src/instruction.rs#L552 +export const BATCH_DISCRIMINATOR = 0xff; + +// Maps SPL Token sub-instruction discriminators to human-readable names. +// Base instructions (0–24, 38, 45): +// https://github.com/solana-program/token/blob/065786e/pinocchio/interface/src/instruction.rs#L9-L551 +// Token-2022 extension instructions (25–37, 39–44): +// https://github.com/solana-program/token-2022/blob/main/interface/src/instruction.rs +/* eslint-disable sort-keys-fix/sort-keys-fix */ +const DISCRIMINATOR_TO_TYPE_NAME = { + 0: 'InitializeMint', + 1: 'InitializeAccount', + 2: 'InitializeMultisig', + 3: 'Transfer', + 4: 'Approve', + 5: 'Revoke', + 6: 'SetAuthority', + 7: 'MintTo', + 8: 'Burn', + 9: 'CloseAccount', + 10: 'FreezeAccount', + 11: 'ThawAccount', + 12: 'TransferChecked', + 13: 'ApproveChecked', + 14: 'MintToChecked', + 15: 'BurnChecked', + 16: 'InitializeAccount2', + 17: 'SyncNative', + 18: 'InitializeAccount3', + 19: 'InitializeMultisig2', + 20: 'InitializeMint2', + 21: 'GetAccountDataSize', + 22: 'InitializeImmutableOwner', + 23: 'AmountToUiAmount', + 24: 'UiAmountToAmount', + // Token-2022 extension instructions (discriminators 25–37, 39–44). + // Each extension group uses a sub-discriminator in the second data byte + // to distinguish individual operations within the group. + 25: 'InitializeMintCloseAuthority', + 26: 'TransferFeeExtension', + 27: 'ConfidentialTransferExtension', + 28: 'DefaultAccountStateExtension', + 29: 'Reallocate', + 30: 'MemoTransferExtension', + 31: 'CreateNativeMint', + 32: 'InitializeNonTransferableMint', + 33: 'InterestBearingMintExtension', + 34: 'CpiGuardExtension', + 35: 'InitializePermanentDelegate', + 36: 'TransferHookExtension', + 37: 'ConfidentialTransferFeeExtension', + 38: 'WithdrawExcessLamports', + 39: 'MetadataPointerExtension', + 40: 'GroupPointerExtension', + 41: 'GroupMemberPointerExtension', + 42: 'ConfidentialMintBurnExtension', + 43: 'ScaledUiAmountExtension', + 44: 'PausableExtension', + 45: 'UnwrapLamports', +} as const; +/* eslint-enable sort-keys-fix/sort-keys-fix */ + +export type TokenInstructionName = (typeof DISCRIMINATOR_TO_TYPE_NAME)[keyof typeof DISCRIMINATOR_TO_TYPE_NAME]; + +// Intentional type widening (not a typecast) so that arbitrary numeric keys +// return `TokenInstructionName | undefined` instead of requiring a known literal. +export const typeNameByDiscriminator: Record = DISCRIMINATOR_TO_TYPE_NAME; diff --git a/app/features/token-batch/lib/decode-sub-instruction.ts b/app/features/token-batch/lib/decode-sub-instruction.ts new file mode 100644 index 000000000..ff8d71271 --- /dev/null +++ b/app/features/token-batch/lib/decode-sub-instruction.ts @@ -0,0 +1,181 @@ +// Decoder for SPL Token sub-instructions embedded in a batched token instruction. +// +// Uses @solana-program/token-2022 instruction data decoders for field extraction. +// The batch wire format itself has no SDK decoder — only individual sub-instruction +// data payloads are decoded here. + +import { + getApproveCheckedInstructionDataDecoder, + getApproveInstructionDataDecoder, + getBurnCheckedInstructionDataDecoder, + getBurnInstructionDataDecoder, + getInitializeAccount3InstructionDataDecoder, + getInitializeMint2InstructionDataDecoder, + getMintToCheckedInstructionDataDecoder, + getMintToInstructionDataDecoder, + getSetAuthorityInstructionDataDecoder, + getTransferCheckedInstructionDataDecoder, + getTransferInstructionDataDecoder, +} from '@solana-program/token-2022'; + +import type { TokenInstructionName } from './const'; +import { formatDecoded } from './format-sub-instruction'; +import type { + AccountEntry, + DecodedParams, + MintInfo, + RawAccountsOnly, + RawAmount, + RawCheckedAmount, + RawCloseAccount, + RawDecoded, + RawInitializeAccount3, + RawInitializeMint2, + RawSetAuthority, +} from './types'; + +export type { DecodedParams, LabeledAccount } from './types'; + +export function decodeSubInstructionParams( + typeName: TokenInstructionName | 'Unknown', + data: Uint8Array, + accounts: AccountEntry[], + mintInfo?: MintInfo, +): DecodedParams | undefined { + try { + const raw = decodeByType(typeName, data, accounts); + if (!raw) return undefined; + return formatDecoded(raw, mintInfo); + } catch { + // Decoder throws on truncated/malformed data — fall back to raw hex. + return undefined; + } +} + +// Token-2022 extension instructions use a nested sub-discriminator scheme +// that varies per extension, so we only decode the common base instructions +// where the wire format is straightforward. +function decodeByType( + typeName: TokenInstructionName | 'Unknown', + data: Uint8Array, + accounts: AccountEntry[], +): RawDecoded | undefined { + switch (typeName) { + case 'Transfer': + return decodeTransfer(data, accounts); + case 'Approve': + return decodeApprove(data, accounts); + case 'MintTo': + return decodeMintTo(data, accounts); + case 'Burn': + return decodeBurn(data, accounts); + case 'CloseAccount': + return decodeCloseAccount(data, accounts); + case 'SetAuthority': + return decodeSetAuthority(data, accounts); + case 'TransferChecked': + return decodeTransferChecked(data, accounts); + case 'ApproveChecked': + return decodeApproveChecked(data, accounts); + case 'MintToChecked': + return decodeMintToChecked(data, accounts); + case 'BurnChecked': + return decodeBurnChecked(data, accounts); + case 'FreezeAccount': + return decodeAccountsOnly(data, accounts, 'freezeAccount'); + case 'ThawAccount': + return decodeAccountsOnly(data, accounts, 'thawAccount'); + case 'Revoke': + return decodeAccountsOnly(data, accounts, 'revoke'); + case 'InitializeMint2': + return decodeInitializeMint2(data, accounts); + case 'InitializeAccount3': + return decodeInitializeAccount3(data, accounts); + default: + return undefined; + } +} + +// ── Per-instruction decoders ───────────────────────────────────────── + +function decodeTransfer(data: Uint8Array, accounts: AccountEntry[]): RawAmount { + const { amount } = getTransferInstructionDataDecoder().decode(data); + return { accounts, amount, type: 'transfer' }; +} + +function decodeApprove(data: Uint8Array, accounts: AccountEntry[]): RawAmount { + const { amount } = getApproveInstructionDataDecoder().decode(data); + return { accounts, amount, type: 'approve' }; +} + +function decodeMintTo(data: Uint8Array, accounts: AccountEntry[]): RawAmount { + const { amount } = getMintToInstructionDataDecoder().decode(data); + return { accounts, amount, type: 'mintTo' }; +} + +function decodeBurn(data: Uint8Array, accounts: AccountEntry[]): RawAmount { + const { amount } = getBurnInstructionDataDecoder().decode(data); + return { accounts, amount, type: 'burn' }; +} + +// CloseAccount has no payload beyond the discriminator, but we still +// need at least 1 byte (the discriminator itself) to consider it valid. +function decodeCloseAccount(data: Uint8Array, accounts: AccountEntry[]): RawCloseAccount | undefined { + if (data.length < 1) return undefined; + return { accounts, type: 'closeAccount' }; +} + +function decodeSetAuthority(data: Uint8Array, accounts: AccountEntry[]): RawSetAuthority { + const { authorityType, newAuthority } = getSetAuthorityInstructionDataDecoder().decode(data); + return { + accounts, + authorityType, + newAuthority: newAuthority.__option === 'Some' ? newAuthority.value : undefined, + type: 'setAuthority', + }; +} + +function decodeTransferChecked(data: Uint8Array, accounts: AccountEntry[]): RawCheckedAmount { + const { amount, decimals } = getTransferCheckedInstructionDataDecoder().decode(data); + return { accounts, amount, decimals, type: 'transferChecked' }; +} + +function decodeApproveChecked(data: Uint8Array, accounts: AccountEntry[]): RawCheckedAmount { + const { amount, decimals } = getApproveCheckedInstructionDataDecoder().decode(data); + return { accounts, amount, decimals, type: 'approveChecked' }; +} + +function decodeMintToChecked(data: Uint8Array, accounts: AccountEntry[]): RawCheckedAmount { + const { amount, decimals } = getMintToCheckedInstructionDataDecoder().decode(data); + return { accounts, amount, decimals, type: 'mintToChecked' }; +} + +function decodeBurnChecked(data: Uint8Array, accounts: AccountEntry[]): RawCheckedAmount { + const { amount, decimals } = getBurnCheckedInstructionDataDecoder().decode(data); + return { accounts, amount, decimals, type: 'burnChecked' }; +} + +function decodeAccountsOnly( + data: Uint8Array, + accounts: AccountEntry[], + type: RawAccountsOnly['type'], +): RawAccountsOnly | undefined { + if (data.length < 1) return undefined; + return { accounts, type }; +} + +function decodeInitializeMint2(data: Uint8Array, accounts: AccountEntry[]): RawInitializeMint2 { + const { decimals, mintAuthority, freezeAuthority } = getInitializeMint2InstructionDataDecoder().decode(data); + return { + accounts, + decimals, + freezeAuthority: freezeAuthority.__option === 'Some' ? freezeAuthority.value : undefined, + mintAuthority, + type: 'initializeMint2', + }; +} + +function decodeInitializeAccount3(data: Uint8Array, accounts: AccountEntry[]): RawInitializeAccount3 { + const { owner } = getInitializeAccount3InstructionDataDecoder().decode(data); + return { accounts, owner, type: 'initializeAccount3' }; +} diff --git a/app/features/token-batch/lib/format-sub-instruction.ts b/app/features/token-batch/lib/format-sub-instruction.ts new file mode 100644 index 000000000..f0e50618c --- /dev/null +++ b/app/features/token-batch/lib/format-sub-instruction.ts @@ -0,0 +1,119 @@ +// Converts raw decoded wire data into labeled, human-readable output +// for display in the UI. + +import { formatTokenAmount } from '@entities/token-amount'; +import { PublicKey } from '@solana/web3.js'; +import { AuthorityType } from '@solana-program/token-2022'; + +import type { AccountEntry, DecodedField, DecodedParams, LabeledAccount, MintInfo, RawDecoded } from './types'; + +// Account layouts: each SPL Token instruction expects a fixed sequence of named +// accounts, optionally followed by multisig signer accounts. +// See: https://github.com/solana-program/token/blob/main/interface/src/instruction.rs +const ACCOUNT_ROLES: Record = { + approve: ['Source', 'Delegate', 'Owner'], + approveChecked: ['Source', 'Mint', 'Delegate', 'Owner'], + burn: ['Account', 'Mint', 'Owner/Delegate'], + burnChecked: ['Account', 'Mint', 'Owner/Delegate'], + closeAccount: ['Account', 'Destination', 'Owner'], + freezeAccount: ['Account', 'Mint', 'Freeze Authority'], + initializeAccount3: ['Account', 'Mint'], + initializeMint2: ['Mint'], + mintTo: ['Mint', 'Destination', 'Mint Authority'], + mintToChecked: ['Mint', 'Destination', 'Mint Authority'], + revoke: ['Source', 'Owner'], + setAuthority: ['Account', 'Current Authority'], + thawAccount: ['Account', 'Mint', 'Freeze Authority'], + transfer: ['Source', 'Destination', 'Owner/Delegate'], + transferChecked: ['Source', 'Mint', 'Destination', 'Owner/Delegate'], +}; + +// These instructions don't include the mint in their on-chain account list. +// When the mint address has been resolved via RPC, we inject a synthetic +// "Mint" account after the first account (index 0). +const MINT_INJECT_TYPES = new Set(['transfer', 'approve', 'closeAccount', 'revoke']); + +export function formatDecoded(raw: RawDecoded, mintInfo?: MintInfo): DecodedParams { + const accounts = labelAccounts(raw.accounts, ACCOUNT_ROLES[raw.type]); + + if (mintInfo?.mint && MINT_INJECT_TYPES.has(raw.type)) { + accounts.splice(1, 0, { + isSigner: false, + isWritable: false, + label: 'Mint*', + pubkey: new PublicKey(mintInfo.mint), + }); + } + + return { + accounts, + fields: formatFields(raw, mintInfo?.decimals), + }; +} + +function formatFields(raw: RawDecoded, externalDecimals?: number): DecodedField[] { + switch (raw.type) { + case 'transfer': + case 'approve': + case 'mintTo': + case 'burn': + return [ + { + label: 'Amount', + value: + externalDecimals === undefined + ? raw.amount.toString() + : formatTokenAmount({ amount: raw.amount, decimals: externalDecimals }), + }, + ]; + + case 'transferChecked': + case 'approveChecked': + case 'mintToChecked': + case 'burnChecked': + return [ + { label: 'Decimals', value: raw.decimals.toString() }, + { label: 'Amount', value: formatTokenAmount({ amount: raw.amount, decimals: raw.decimals }) }, + ]; + + case 'closeAccount': + case 'freezeAccount': + case 'thawAccount': + case 'revoke': + return []; + + case 'initializeMint2': + return [ + { label: 'Decimals', value: raw.decimals.toString() }, + { isAddress: true, label: 'Mint Authority', value: raw.mintAuthority }, + ...(raw.freezeAuthority + ? [{ isAddress: true, label: 'Freeze Authority', value: raw.freezeAuthority }] + : [{ label: 'Freeze Authority', value: '(none)' }]), + ]; + + case 'initializeAccount3': + return [{ isAddress: true, label: 'Owner', value: raw.owner }]; + + case 'setAuthority': + return [ + { + label: 'Authority Type', + value: AuthorityType[raw.authorityType] ?? `Unknown (${raw.authorityType})`, + }, + ...(raw.newAuthority + ? [{ isAddress: true, label: 'New Authority', value: raw.newAuthority }] + : [{ label: 'New Authority', value: '(none)' }]), + ]; + } +} + +// SPL Token instructions support multisig owners/delegates. The `layout` defines the +// named positional accounts; any remaining accounts are additional signers required to +// meet the multisig threshold. +// See: https://github.com/solana-program/token/blob/main/program/src/processor.rs#L988 +function labelAccounts(accounts: AccountEntry[], roles: readonly string[]): LabeledAccount[] { + return accounts.map((account, i) => ({ + ...account, + label: i < roles.length ? roles[i] : `Signer ${i - roles.length + 1}`, + })); +} diff --git a/app/features/token-batch/lib/types.ts b/app/features/token-batch/lib/types.ts new file mode 100644 index 000000000..5230ea0ef --- /dev/null +++ b/app/features/token-batch/lib/types.ts @@ -0,0 +1,72 @@ +import type { PublicKey } from '@solana/web3.js'; + +export type AccountEntry = { pubkey: PublicKey; isSigner: boolean; isWritable: boolean }; + +export type LabeledAccount = AccountEntry & { label: string }; + +export type DecodedField = { label: string; value: string; isAddress?: boolean }; + +export type DecodedParams = { + fields: DecodedField[]; + accounts: LabeledAccount[]; +}; + +// ── Raw decoded results (wire data only, no presentation) ──────────── + +export type RawAmount = { + type: 'transfer' | 'approve' | 'mintTo' | 'burn'; + amount: bigint; + accounts: AccountEntry[]; +}; + +export type RawCheckedAmount = { + type: 'transferChecked' | 'approveChecked' | 'mintToChecked' | 'burnChecked'; + amount: bigint; + decimals: number; + accounts: AccountEntry[]; +}; + +export type RawCloseAccount = { + type: 'closeAccount'; + accounts: AccountEntry[]; +}; + +export type RawSetAuthority = { + type: 'setAuthority'; + authorityType: number; + newAuthority: string | undefined; + accounts: AccountEntry[]; +}; + +export type RawAccountsOnly = { + type: 'freezeAccount' | 'thawAccount' | 'revoke'; + accounts: AccountEntry[]; +}; + +export type RawInitializeMint2 = { + type: 'initializeMint2'; + decimals: number; + mintAuthority: string; + freezeAuthority: string | undefined; + accounts: AccountEntry[]; +}; + +export type RawInitializeAccount3 = { + type: 'initializeAccount3'; + owner: string; + accounts: AccountEntry[]; +}; + +export type RawDecoded = + | RawAmount + | RawCheckedAmount + | RawCloseAccount + | RawSetAuthority + | RawAccountsOnly + | RawInitializeMint2 + | RawInitializeAccount3; + +// Resolved mint info returned from the RPC fetch pipeline. +// For unchecked Transfer/Approve the mint is discovered via a 2-hop lookup; +// for MintTo/Burn it comes directly from the accounts. +export type MintInfo = { decimals: number; mint?: string }; diff --git a/app/features/token-batch/model/batch-mint-registry.tsx b/app/features/token-batch/model/batch-mint-registry.tsx new file mode 100644 index 000000000..db5261616 --- /dev/null +++ b/app/features/token-batch/model/batch-mint-registry.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from 'react'; + +import type { MintInfo } from '../lib/types'; + +type BatchMintRegistry = { + register: (mint: string, decimals: number) => void; + getUniqueMint: () => MintInfo | undefined; +}; + +const BatchMintRegistryContext = createContext(undefined); + +export function BatchMintRegistryProvider({ children }: { children: ReactNode }) { + const [mints, setMints] = useState>(new Map()); + + const register = useCallback((mint: string, decimals: number) => { + setMints(prev => { + if (prev.get(mint) === decimals) return prev; + const next = new Map(prev); + next.set(mint, decimals); + return next; + }); + }, []); + + const getUniqueMint = useCallback((): MintInfo | undefined => { + if (mints.size !== 1) return undefined; + const [mint, decimals] = [...mints.entries()][0]; + return { decimals, mint }; + }, [mints]); + + const value = useMemo(() => ({ getUniqueMint, register }), [getUniqueMint, register]); + + return {children}; +} + +export function useBatchMintRegistry(): BatchMintRegistry | undefined { + return useContext(BatchMintRegistryContext); +} diff --git a/app/features/token-batch/model/use-sub-instruction-mint-info.ts b/app/features/token-batch/model/use-sub-instruction-mint-info.ts new file mode 100644 index 000000000..d6c392e87 --- /dev/null +++ b/app/features/token-batch/model/use-sub-instruction-mint-info.ts @@ -0,0 +1,81 @@ +'use client'; + +import { selectMintDecimals, selectTokenAccountMint, useAccountQuery } from '@entities/account'; +import type { AccountMeta } from '@solana/web3.js'; +import { useEffect } from 'react'; + +import type { TokenInstructionName } from '../lib/const'; +import type { MintInfo } from '../lib/types'; +import { useBatchMintRegistry } from './batch-mint-registry'; + +// Resolves mint decimals for a single batch sub-instruction using AccountsProvider. +// +// - Transfer/Approve: 2 hops (token account → discover mint → read decimals) +// - MintTo: 1 hop (accounts[0] IS the mint) +// - Burn: 1 hop (accounts[1] IS the mint) +// - Checked variants / others: no lookup needed +// +// When the on-chain lookup fails (e.g. closed token account), falls back to +// the batch-level mint registry if all other sub-instructions resolved to +// the same mint. +export function useSubInstructionMintInfo( + typeName: TokenInstructionName | 'Unknown', + accounts: AccountMeta[], +): MintInfo | undefined { + const registry = useBatchMintRegistry(); + const lookup = resolveLookupAddress(typeName, accounts); + + // Hop 1: For Transfer/Approve, fetch the token account to discover its mint. + const tokenAccountQuery = useAccountQuery(lookup?.kind === 'tokenAccount' ? [lookup.address] : undefined, { + select: selectTokenAccountMint, + }); + + // The mint address is either known directly (MintTo/Burn) or discovered + // from the token account query (Transfer/Approve). + const mintAddress = lookup?.kind === 'mint' ? lookup.address : tokenAccountQuery.data; + + // Hop 2 (or only hop): Fetch the mint account to get decimals. + const mintQuery = useAccountQuery(mintAddress ? [mintAddress] : undefined, { + select: selectMintDecimals, + }); + + const decimals = mintQuery.data; + const resolved = decimals !== undefined && mintAddress !== undefined ? { decimals, mint: mintAddress } : undefined; + + // Register discovered mint so other sub-instructions can use it as fallback. + useEffect(() => { + if (mintAddress === undefined || decimals === undefined) return; + registry?.register(mintAddress, decimals); + }, [mintAddress, decimals, registry]); + + if (resolved) return resolved; + + // Fallback: if this sub-instruction needs decimals but couldn't resolve + // (e.g. closed token account), use the batch-wide unique mint. + if (lookup && !resolved) { + return registry?.getUniqueMint(); + } + + return undefined; +} + +type LookupAddress = { kind: 'mint'; address: string } | { kind: 'tokenAccount'; address: string }; + +function resolveLookupAddress( + typeName: TokenInstructionName | 'Unknown', + accounts: AccountMeta[], +): LookupAddress | undefined { + switch (typeName) { + case 'Transfer': + case 'Approve': + case 'CloseAccount': + case 'Revoke': + return accounts[0] ? { address: accounts[0].pubkey.toBase58(), kind: 'tokenAccount' } : undefined; + case 'MintTo': + return accounts[0] ? { address: accounts[0].pubkey.toBase58(), kind: 'mint' } : undefined; + case 'Burn': + return accounts[1] ? { address: accounts[1].pubkey.toBase58(), kind: 'mint' } : undefined; + default: + return undefined; + } +} diff --git a/app/features/token-batch/ui/SubInstructionRow.tsx b/app/features/token-batch/ui/SubInstructionRow.tsx new file mode 100644 index 000000000..90c40b9fc --- /dev/null +++ b/app/features/token-batch/ui/SubInstructionRow.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { Address } from '@components/common/Address'; +import { HexData } from '@shared/HexData'; +import { Badge } from '@shared/ui/badge'; +import { PublicKey } from '@solana/web3.js'; + +import type { ParsedSubInstruction } from '../lib/batch-parser'; +import { type DecodedParams, decodeSubInstructionParams, type LabeledAccount } from '../lib/decode-sub-instruction'; +import type { DecodedField } from '../lib/types'; +import { useSubInstructionMintInfo } from '../model/use-sub-instruction-mint-info'; + +export function SubInstructionRow({ subIx }: { subIx: ParsedSubInstruction }) { + const mintInfo = useSubInstructionMintInfo(subIx.typeName, subIx.accounts); + const decoded = decodeSubInstructionParams(subIx.typeName, subIx.data, subIx.accounts, mintInfo); + + return ( +
+
+ #{subIx.index + 1} + + {subIx.typeName} + +
+ + {decoded ? : } +
+ ); +} + +function DecodedContent({ decoded }: { decoded: DecodedParams }) { + return ( +
+ {decoded.fields.map(field => ( + + ))} + {decoded.accounts.map((account, i) => ( + + ))} +
+ ); +} + +function RawContent({ subIx }: { subIx: ParsedSubInstruction }) { + const accounts = subIx.accounts.map((account, i) => ({ ...account, label: `Account ${i}` })); + return ( +
+
+ Data: + +
+ {accounts.map((account, i) => ( + + ))} +
+ ); +} + +function FieldRow({ field }: { field: DecodedField }) { + return ( +
+ {field.label}: + {field.isAddress ? ( +
+ ) : ( + {field.value} + )} +
+ ); +} + +function AccountRow({ account }: { account: LabeledAccount }) { + return ( +
+ {account.label}: +
+ {account.isWritable && ( + + Writable + + )} + {account.isSigner && ( + + Signer + + )} +
+ ); +} diff --git a/app/features/token-batch/ui/TokenBatchCard.tsx b/app/features/token-batch/ui/TokenBatchCard.tsx new file mode 100644 index 000000000..d295c67aa --- /dev/null +++ b/app/features/token-batch/ui/TokenBatchCard.tsx @@ -0,0 +1,59 @@ +import { InstructionCard } from '@components/instruction/InstructionCard'; +import type { SignatureResult, TransactionInstruction } from '@solana/web3.js'; + +import { parseBatchInstruction, type ParsedSubInstruction } from '../lib/batch-parser'; +import { BatchMintRegistryProvider } from '../model/batch-mint-registry'; +import { SubInstructionRow } from './SubInstructionRow'; + +export function TokenBatchCard({ + ix, + index, + result, + innerCards, + childIndex, +}: { + ix: TransactionInstruction; + index: number; + result: SignatureResult; + // JSX.Element for compatibility with existing instruction card consumers + innerCards?: JSX.Element[]; + childIndex?: number; +}) { + const { subInstructions, error }: { subInstructions: ParsedSubInstruction[]; error: string | undefined } = (() => { + try { + return { error: undefined, subInstructions: parseBatchInstruction(new Uint8Array(ix.data), ix.keys) }; + } catch (e) { + return { error: e instanceof Error ? e.message : String(e), subInstructions: [] }; + } + })(); + + const title = `Token Program: Batch (${subInstructions.length} instruction${subInstructions.length !== 1 ? 's' : ''})`; + + return ( + + + +
+ {error && ( +
+ Parse error: {error} +
+ )} + {subInstructions.length > 0 && ( + + {subInstructions.map(subIx => ( + + ))} + + )} + {subInstructions.length === 0 && !error && ( +
+ No sub-instructions found +
+ )} +
+ + +
+ ); +} diff --git a/app/features/token-batch/ui/stories/TokenBatchCard.stories.tsx b/app/features/token-batch/ui/stories/TokenBatchCard.stories.tsx new file mode 100644 index 000000000..50c9f46c5 --- /dev/null +++ b/app/features/token-batch/ui/stories/TokenBatchCard.stories.tsx @@ -0,0 +1,196 @@ +import { TOKEN_2022_PROGRAM_ID } from '@providers/accounts/tokens'; +import { Keypair, TransactionInstruction } from '@solana/web3.js'; +import type { Meta, StoryObj } from '@storybook/react'; +import { nextjsParameters, withTokenInfoBatch, withTransactions } from '@storybook-config/decorators'; +import { expect, within } from 'storybook/test'; + +import { concatBytes, toBuffer, writeU64LE } from '@/app/shared/lib/bytes'; + +import { makeAccount, makeBatchIx, makeBatchIxWithKeys, makeSetAuthorityData } from '../../lib/__tests__/test-utils'; +import { BATCH_DISCRIMINATOR } from '../../lib/const'; +import { TokenBatchCard } from '../TokenBatchCard'; + +const meta = { + component: TokenBatchCard, + decorators: [withTransactions, withTokenInfoBatch], + parameters: { + ...nextjsParameters, + layout: 'padded', + }, + tags: ['autodocs', 'test'], + title: 'Features/TokenBatch/TokenBatchCard', +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +// Transfer (disc 3) + Burn (disc 8) batched together +const transferData = concatBytes(new Uint8Array([3]), writeU64LE(1_000_000n)); +const burnData = concatBytes(new Uint8Array([8]), writeU64LE(500n)); + +export const TwoSubInstructions: Story = { + args: { + index: 0, + ix: makeBatchIx( + [ + { data: transferData, numAccounts: 3 }, + { data: burnData, numAccounts: 3 }, + ], + 6, + ), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Batch (2 instructions)', { exact: false })).toBeInTheDocument(); + await expect(canvas.getByText('Transfer')).toBeInTheDocument(); + await expect(canvas.getByText('Burn')).toBeInTheDocument(); + await expect(canvas.getByTestId('sub-ix-0')).toBeInTheDocument(); + await expect(canvas.getByTestId('sub-ix-1')).toBeInTheDocument(); + }, +}; + +// Single TransferChecked (disc 12) — verifies singular title, decoded amount and decimals +const transferCheckedData = concatBytes(new Uint8Array([12]), writeU64LE(5_000_000n), new Uint8Array([6])); + +export const SingleSubInstruction: Story = { + args: { + index: 1, + ix: makeBatchIx([{ data: transferCheckedData, numAccounts: 4 }], 4), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Batch (1 instruction)', { exact: false })).toBeInTheDocument(); + await expect(canvas.getByText('TransferChecked')).toBeInTheDocument(); + await expect(canvas.getByText('5')).toBeInTheDocument(); + await expect(canvas.getByText('Decimals:')).toBeInTheDocument(); + await expect(canvas.getByText('6')).toBeInTheDocument(); + }, +}; + +// Transfer with a specific amount — verifies decoded Amount field +export const DecodedTransferAmount: Story = { + args: { + index: 5, + ix: makeBatchIx([{ data: concatBytes(new Uint8Array([3]), writeU64LE(42000n)), numAccounts: 3 }], 3), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('42000')).toBeInTheDocument(); + await expect(canvas.getByText('Amount:')).toBeInTheDocument(); + }, +}; + +// Unknown discriminator renders raw hex data +export const UnknownDiscriminator: Story = { + args: { + index: 2, + ix: makeBatchIx([{ data: new Uint8Array([0xfe, 0xab, 0xcd]), numAccounts: 2 }], 2), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Unknown')).toBeInTheDocument(); + await expect(canvas.getByText('fe ab cd')).toBeInTheDocument(); + }, +}; + +// Truncated data triggers parse error +export const ParseError: Story = { + args: { + index: 3, + ix: new TransactionInstruction({ + data: toBuffer(new Uint8Array([BATCH_DISCRIMINATOR, 5])), + keys: [], + programId: TOKEN_2022_PROGRAM_ID, + }), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByTestId('batch-error')).toBeInTheDocument(); + }, +}; + +// Empty batch (just the discriminator, no sub-instructions) +export const EmptyBatch: Story = { + args: { + index: 4, + ix: new TransactionInstruction({ + data: toBuffer(new Uint8Array([BATCH_DISCRIMINATOR])), + keys: [], + programId: TOKEN_2022_PROGRAM_ID, + }), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('Batch (0 instructions)', { exact: false })).toBeInTheDocument(); + await expect(canvas.getByTestId('batch-empty')).toBeInTheDocument(); + }, +}; + +// SetAuthority with a new authority pubkey +const newAuthority = Keypair.generate().publicKey; + +export const SetAuthorityWithNewAuthority: Story = { + args: { + index: 7, + ix: makeBatchIxWithKeys( + [{ data: makeSetAuthorityData(0, newAuthority), numAccounts: 2 }], + [makeAccount(true, false), makeAccount(false, true)], + ), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('SetAuthority')).toBeInTheDocument(); + await expect(canvas.getByText('Authority Type:')).toBeInTheDocument(); + await expect(canvas.getByText('MintTokens')).toBeInTheDocument(); + await expect(canvas.getByText('New Authority:')).toBeInTheDocument(); + await expect(canvasElement.querySelector(`[aria-label="${newAuthority.toBase58()}"]`)).toBeTruthy(); + }, +}; + +// SetAuthority revoking authority (no new authority) +export const SetAuthorityRevoke: Story = { + args: { + index: 8, + ix: makeBatchIx([{ data: makeSetAuthorityData(3), numAccounts: 2 }], 2), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText('SetAuthority')).toBeInTheDocument(); + // Scope to the Authority Type field row to avoid false positives if a + // CloseAccount sub-instruction badge is ever added to this story. + const authorityTypeRow = canvas.getByText('Authority Type:').closest('div'); + if (!authorityTypeRow) throw new Error('Expected Authority Type row'); + await expect(within(authorityTypeRow).getByText('CloseAccount')).toBeInTheDocument(); + await expect(canvas.getByText('(none)')).toBeInTheDocument(); + }, +}; + +// Writable and signer badges rendered on accounts +export const WritableAndSignerBadges: Story = { + args: { + index: 6, + ix: makeBatchIxWithKeys( + [{ data: concatBytes(new Uint8Array([3]), writeU64LE(100n)), numAccounts: 3 }], + [ + makeAccount(true, false), // writable, not signer + makeAccount(true, false), // writable, not signer + makeAccount(false, true), // not writable, signer + ], + ), + result: { err: null }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const writableBadges = canvas.getAllByText('Writable'); + await expect(writableBadges.length).toBe(2); + await expect(canvas.getByText('Signer')).toBeInTheDocument(); + }, +}; diff --git a/app/shared/components/stories/DownloadDropdown.stories.tsx b/app/shared/components/__stories__/DownloadDropdown.stories.tsx similarity index 90% rename from app/shared/components/stories/DownloadDropdown.stories.tsx rename to app/shared/components/__stories__/DownloadDropdown.stories.tsx index c99a71ff5..ee4332b96 100644 --- a/app/shared/components/stories/DownloadDropdown.stories.tsx +++ b/app/shared/components/__stories__/DownloadDropdown.stories.tsx @@ -7,9 +7,10 @@ import { DownloadDropdown } from '../DownloadDropdown'; const meta: Meta = { component: DownloadDropdown, parameters: { - layout: 'centered', + layout: 'padded', }, - title: 'Shared/UI/DownloadDropdown', + tags: ['autodocs'], + title: 'Shared/DownloadDropdown', }; export default meta; @@ -17,6 +18,15 @@ type Story = StoryObj; const SAMPLE_DATA = new Uint8Array([72, 101, 108, 108, 111]); +// Centered default for autodocs preview +export const Default: Story = { + args: { + data: SAMPLE_DATA, + filename: 'test-transaction', + }, + parameters: { layout: 'centered' }, +}; + export const WithData: Story = { args: { data: SAMPLE_DATA, diff --git a/app/shared/lib/__tests__/bytes.test.ts b/app/shared/lib/__tests__/bytes.test.ts index 952aed11d..5ce2472b7 100644 --- a/app/shared/lib/__tests__/bytes.test.ts +++ b/app/shared/lib/__tests__/bytes.test.ts @@ -1,10 +1,85 @@ import { describe, expect, it } from 'vitest'; -import { toBase64, toHex } from '../bytes'; +import { concatBytes, readU8, readU64LE, toBase64, toBuffer, toHex, writeU64LE } from '../bytes'; // Note: Buffer is used in tests for decoding since tests run in Node.js environment. // The production code uses only Uint8Array for browser compatibility. +describe('concatBytes', () => { + it('should return empty array for no arguments', () => { + expect(concatBytes()).toEqual(new Uint8Array([])); + }); + + it('should return a copy of a single array', () => { + const input = new Uint8Array([1, 2, 3]); + const result = concatBytes(input); + expect(result).toEqual(input); + expect(result).not.toBe(input); + }); + + it('should concatenate multiple arrays', () => { + const result = concatBytes(new Uint8Array([1, 2]), new Uint8Array([3]), new Uint8Array([4, 5, 6])); + expect(result).toEqual(new Uint8Array([1, 2, 3, 4, 5, 6])); + }); + + it('should handle empty arrays in the mix', () => { + const result = concatBytes(new Uint8Array([]), new Uint8Array([1]), new Uint8Array([])); + expect(result).toEqual(new Uint8Array([1])); + }); +}); + +describe('writeU64LE / readU64LE', () => { + it('should round-trip zero', () => { + const bytes = writeU64LE(0n); + expect(bytes.length).toBe(8); + expect(readU64LE(bytes, 0)).toBe(0n); + }); + + it('should round-trip a small value', () => { + const value = 42000n; + expect(readU64LE(writeU64LE(value), 0)).toBe(value); + }); + + it('should round-trip max u64', () => { + const max = 2n ** 64n - 1n; + expect(readU64LE(writeU64LE(max), 0)).toBe(max); + }); + + it('should write in little-endian order', () => { + const bytes = writeU64LE(1n); + expect(bytes[0]).toBe(1); + expect(bytes[7]).toBe(0); + }); + + it('should read at an offset', () => { + const prefix = new Uint8Array([0xff]); + const data = concatBytes(prefix, writeU64LE(999n)); + expect(readU64LE(data, 1)).toBe(999n); + }); + + it('should throw RangeError when data is too short', () => { + expect(() => readU64LE(new Uint8Array([1, 2, 3]), 0)).toThrow(RangeError); + }); + + it('should throw RangeError when offset exceeds length', () => { + expect(() => readU64LE(new Uint8Array(8), 8)).toThrow(RangeError); + }); +}); + +describe('readU8', () => { + it('should read a byte at offset', () => { + expect(readU8(new Uint8Array([10, 20, 30]), 1)).toBe(20); + }); + + it('should throw RangeError when offset is out of bounds', () => { + expect(() => readU8(new Uint8Array([1]), 1)).toThrow(RangeError); + }); + + it('should throw RangeError for empty array', () => { + expect(() => readU8(new Uint8Array([]), 0)).toThrow(RangeError); + }); +}); + describe('toHex', () => { it('should convert empty array', () => { expect(toHex(new Uint8Array([]))).toBe(''); @@ -30,6 +105,43 @@ describe('toHex', () => { }); }); +describe('toBuffer', () => { + it('should convert empty Uint8Array', () => { + const result = toBuffer(new Uint8Array([])); + expect(result).toBeInstanceOf(Buffer); + expect(result.length).toBe(0); + }); + + it('should preserve bytes', () => { + const input = new Uint8Array([1, 2, 3, 255]); + const result = toBuffer(input); + expect(result).toBeInstanceOf(Buffer); + expect([...result]).toEqual([1, 2, 3, 255]); + }); + + it('should share the same underlying memory', () => { + const input = new Uint8Array([10, 20, 30]); + const result = toBuffer(input); + input[0] = 99; + expect(result[0]).toBe(99); + }); + + it('should return the same Buffer when given a Buffer', () => { + const input = Buffer.from([1, 2, 3]); + const result = toBuffer(input); + expect(result).toBe(input); + }); + + it('should handle a Uint8Array view over a larger ArrayBuffer', () => { + const backing = new ArrayBuffer(16); + const view = new Uint8Array(backing, 4, 3); + view.set([0xaa, 0xbb, 0xcc]); + const result = toBuffer(view); + expect(result.length).toBe(3); + expect([...result]).toEqual([0xaa, 0xbb, 0xcc]); + }); +}); + describe('toBase64', () => { it('should convert empty array', () => { expect(toBase64(new Uint8Array([]))).toBe(''); diff --git a/app/shared/lib/bytes.ts b/app/shared/lib/bytes.ts index eb8ff5ef5..ae763ae4a 100644 --- a/app/shared/lib/bytes.ts +++ b/app/shared/lib/bytes.ts @@ -5,6 +5,36 @@ */ export type ByteArray = Buffer | Uint8Array; +export function concatBytes(...arrays: Uint8Array[]): Uint8Array { + const totalLen = arrays.reduce((sum, a) => sum + a.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const a of arrays) { + result.set(a, offset); + offset += a.length; + } + return result; +} + +export function writeU64LE(value: bigint): Uint8Array { + const buf = new Uint8Array(8); + const view = new DataView(buf.buffer); + view.setBigUint64(0, value, true); + return buf; +} + +export function readU64LE(data: Uint8Array, offset: number): bigint { + if (offset + 8 > data.length) + throw new RangeError(`readU64LE: offset ${offset} out of bounds (length ${data.length})`); + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + return view.getBigUint64(offset, true); +} + +export function readU8(data: Uint8Array, offset: number): number { + if (offset >= data.length) throw new RangeError(`readU8: offset ${offset} out of bounds (length ${data.length})`); + return data[offset]; +} + /** * Convert bytes to a binary string. * Used for encoding to base64 via btoa(). @@ -35,6 +65,15 @@ const toHexFallback = (bytes: ByteArray): string => { return hex; }; +/** + * Convert Uint8Array to Buffer. + * Use only when an external API (e.g. @solana/web3.js TransactionInstruction) requires Buffer. + */ +export function toBuffer(bytes: ByteArray): Buffer { + if (Buffer.isBuffer(bytes)) return bytes; + return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + /** * Encode Uint8Array to hex string * Replaces: buffer.toString('hex') diff --git a/bench/BUILD.md b/bench/BUILD.md index 7ab1f2a86..052222d36 100644 --- a/bench/BUILD.md +++ b/bench/BUILD.md @@ -2,27 +2,27 @@ |------|-------|------|---------------| | Static | `/` | 17.8 kB | 1.03 MB | | Static | `/_not-found` | 326 B | 164 kB | -| Dynamic | `/address/[address]` | 13.2 kB | 975 kB | +| Dynamic | `/address/[address]` | 13.4 kB | 975 kB | | Dynamic | `/address/[address]/anchor-account` | 7.47 kB | 1.01 MB | -| Dynamic | `/address/[address]/anchor-program` | 333 B | 892 kB | -| Dynamic | `/address/[address]/attestation` | 6.27 kB | 982 kB | +| Dynamic | `/address/[address]/anchor-program` | 336 B | 892 kB | +| Dynamic | `/address/[address]/attestation` | 6.28 kB | 982 kB | | Dynamic | `/address/[address]/attributes` | 2.96 kB | 936 kB | | Dynamic | `/address/[address]/blockhashes` | 2.35 kB | 936 kB | | Dynamic | `/address/[address]/compression` | 5.6 kB | 972 kB | | Dynamic | `/address/[address]/concurrent-merkle-tree` | 4.06 kB | 970 kB | | Dynamic | `/address/[address]/domains` | 2.94 kB | 939 kB | | Dynamic | `/address/[address]/entries` | 3.57 kB | 957 kB | -| Dynamic | `/address/[address]/feature-gate` | 334 B | 892 kB | +| Dynamic | `/address/[address]/feature-gate` | 335 B | 892 kB | | Dynamic | `/address/[address]/idl` | 129 kB | 1.2 MB | | Dynamic | `/address/[address]/instructions` | 1.59 kB | 1.05 MB | | Dynamic | `/address/[address]/metadata` | 7.55 kB | 951 kB | -| Dynamic | `/address/[address]/nftoken-collection-nfts` | 8.75 kB | 1.02 MB | +| Dynamic | `/address/[address]/nftoken-collection-nfts` | 9.28 kB | 1.02 MB | | Dynamic | `/address/[address]/program-multisig` | 4.81 kB | 1.01 MB | | Dynamic | `/address/[address]/rewards` | 4.32 kB | 940 kB | | Dynamic | `/address/[address]/security` | 9.94 kB | 1.02 MB | | Dynamic | `/address/[address]/slot-hashes` | 4.21 kB | 940 kB | -| Dynamic | `/address/[address]/stake-history` | 4.33 kB | 940 kB | -| Dynamic | `/address/[address]/token-extensions` | 12.5 kB | 1.02 MB | +| Dynamic | `/address/[address]/stake-history` | 4.34 kB | 940 kB | +| Dynamic | `/address/[address]/token-extensions` | 12.9 kB | 1.02 MB | | Dynamic | `/address/[address]/tokens` | 27.5 kB | 1.13 MB | | Dynamic | `/address/[address]/transfers` | 3.58 kB | 1.07 MB | | Dynamic | `/address/[address]/verified-build` | 7.45 kB | 1.01 MB | @@ -43,7 +43,7 @@ | Dynamic | `/api/verification/rugcheck/[mintAddress]` | 0 B | 0 B | | Dynamic | `/api/verified-programs/list/[page]` | 0 B | 0 B | | Dynamic | `/api/verified-programs/metadata/[programId]` | 0 B | 0 B | -| Dynamic | `/block/[slot]` | 9.61 kB | 959 kB | +| Dynamic | `/block/[slot]` | 9.61 kB | 960 kB | | Dynamic | `/block/[slot]/accounts` | 3.71 kB | 940 kB | | Dynamic | `/block/[slot]/programs` | 4.14 kB | 940 kB | | Dynamic | `/block/[slot]/rewards` | 4.29 kB | 945 kB | @@ -53,7 +53,7 @@ | Static | `/opengraph-image.png` | 0 B | 0 B | | Static | `/supply` | 6.05 kB | 947 kB | | Static | `/tos` | 325 B | 164 kB | -| Dynamic | `/tx/[signature]` | 51 kB | 1.44 MB | -| Dynamic | `/tx/[signature]/inspect` | 633 B | 1.23 MB | -| Static | `/tx/inspector` | 637 B | 1.23 MB | +| Dynamic | `/tx/[signature]` | 53.9 kB | 1.44 MB | +| Dynamic | `/tx/[signature]/inspect` | 631 B | 1.23 MB | +| Static | `/tx/inspector` | 636 B | 1.23 MB | | Static | `/verified-programs` | 6.34 kB | 173 kB | \ No newline at end of file