Skip to content

Commit f3cc2f0

Browse files
authored
feat(interactive-idl): split tx simulation and execution (#1072)
## Description Add a "Simulate" button to the interactive IDL instruction UI so users can simulate an instruction against the RPC without broadcasting. Extract separate `useExecuteTransaction` and `useSimulateTransaction` hooks with phase-tagged result types from `useInstruction`. - Add a `Simulate` button to `InteractInstruction`. - Add switch for enabling/disabling simulation during `Execute` action. - Extract transaction logic from `use-instruction.ts` into `model/transaction/`: `useExecuteTransaction`, `useSimulateTransaction`, `formatTransactionError`, `serializeTransactionMessage`, and discriminated result types in `types.ts`. - Tag execution results by phase (`broadcast_failed`, `pre_broadcast_failed`) and simulation results by phase (`rpc_simulation_failed`, `simulation_execution_failed`). - Replace `TxSuccessStatus` with `TxExecutionStatus` add `TxSimulationStatus`. - Move the UTC time format into shared helper `formatLogTimestamp`. - Split `InstructionActivity` into `InstructionExecutionActivity` and `InstructionSimulationActivity`. `InteractWithIdlView` renders one or the other based on the last action taken. - Rename `invoke`->`execute` to keep it consistent with ui. - Add analytics event for simulation. - Fix nulls/any in `use-instruction.ts` and `InstructionActivity.tsx` and drop them from the eslint legacy-file exclusions. ## Type of change - [x] New feature ## Screenshots <img width="880" height="607" alt="image" src="https://github.com/user-attachments/assets/e3444232-b753-4b84-b7c4-3c44b45cbbf4" /> | | | | :---: | :---: | | <img width="552" height="441" alt="image" src="https://github.com/user-attachments/assets/edcd18ca-5808-4035-8b57-1a44012f8eac" /> | <img width="500" alt="image" src="https://github.com/user-attachments/assets/0c4b8ec8-300a-4db5-be65-0f8d01ebfaf1" /> | | <img width="536" height="543" alt="image" src="https://github.com/user-attachments/assets/81347345-f0bb-49e0-bc61-4d59c6449e7c" /> | <img width="540" height="670" alt="image" src="https://github.com/user-attachments/assets/e31aa2b4-3530-4fd5-9c23-543dbb5a8f57" /> | ## Testing #### Programs with IDL: _References to Vercel Preview will be added once deployment is finished._ - LMB [BUYuxRfhCMWavaUWxhGtPP3ksKEDZxCD5gzknk3JfAya](https://explorer-git-fork-hoodieshq-feat-inter-e206b3-solana-foundation.vercel.app/address/ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S/idl) - Voting [AXcxp15oz1L4YYtqZo6Qt6EkUj1jtLR6wXYqaJvn4oye](https://explorer-git-fork-hoodieshq-feat-inter-e206b3-solana-foundation.vercel.app/address/AXcxp15oz1L4YYtqZo6Qt6EkUj1jtLR6wXYqaJvn4oye/idl) - Blog [4ypHLeD2zDinCMiuHnwpRTwygKDxR5Sn6wbVHy4mbnvE](https://explorer-git-fork-hoodieshq-feat-inter-e206b3-solana-foundation.vercel.app/address/4ypHLeD2zDinCMiuHnwpRTwygKDxR5Sn6wbVHy4mbnvE/idl) - PMP [ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S](https://explorer-git-fork-hoodieshq-feat-inter-e206b3-solana-foundation.vercel.app/address/ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S/idl) <!-- - [LMB](https://explorer-git-feat-interactive-idl-tx-simulation-hoodies.vercel.app/address/BUYuxRfhCMWavaUWxhGtPP3ksKEDZxCD5gzknk3JfAya/idl?cluster=devnet) - [Voting](https://explorer-git-feat-interactive-idl-tx-simulation-hoodies.vercel.app/address/AXcxp15oz1L4YYtqZo6Qt6EkUj1jtLR6wXYqaJvn4oye/?cluster=devnet) - [Blog](https://explorer-git-feat-interactive-idl-tx-simulation-hoodies.vercel.app/address/4ypHLeD2zDinCMiuHnwpRTwygKDxR5Sn6wbVHy4mbnvE/?cluster=devnet) - [PMP](https://explorer-git-feat-interactive-idl-tx-simulation-hoodies.vercel.app/address/ProgM6JCCvbYkfKqJYHePx4xxSUSqJp7rh8Lyv7nk7S/idl?cluster=devnet) --> #### Cases: - Successful simulation (create a valid ix) - Failed simulation (create an invalid ix, e.g. simulate same initialize ix twice) - Successful execution (create valid ix) - Failed instruction after broadcast (create invalid ix, click execute, sign with wallet. This will produce error logs with failed tx) - Failed instruction before broadcast (e.g. click execute, but reject signing by wallet) - Execute with simulation enabled - Execute with simulation disabled ## Related Issues - Closes [HOO-225](https://linear.app/solana-fndn/issue/HOO-225/refactor-use-instruction-and-move-layer-to-send-and-simulate) - Closes [HOO-226](https://linear.app/solana-fndn/issue/HOO-226/allow-to-simulate-instruction-as-well-as-execution) ## Checklist <!-- Verify that you have completed the following before requesting review --> - [x] My code follows the project's style guidelines - [x] I have added tests that prove my fix/feature works - [x] All tests pass locally and in CI - [x] I have run `build:info` script to update build information - [x] CI/CD checks pass - [x] I have included screenshots for protocol screens (if applicable) ## Additional Notes <!-- Add any other context about the PR here --> <!-- For Solana Verify (Verified Builds) related changes, note that bugs should be reported to disclosures@solana.org -->
1 parent 8039c67 commit f3cc2f0

49 files changed

Lines changed: 2417 additions & 633 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/entities/program-logs/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { ProgramLogs } from './ui/ProgramLogs';
2-
export { TxSuccessStatus } from './ui/TxSuccessStatus';
2+
export { TxExecutionStatus } from './ui/TxExecutionStatus';
3+
export { TxSimulationStatus } from './ui/TxSimulationStatus';
34
export { TxErrorStatus } from './ui/TxErrorStatus';
45
export { useParsedLogs } from './model/useParsedLogs';
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { formatLogTimestamp } from '../formatLogTimestamp';
4+
5+
describe('formatLogTimestamp', () => {
6+
it('should format a UTC date as HH:mm:ss UTC', () => {
7+
expect(formatLogTimestamp(new Date('2024-01-15T10:30:45Z'))).toBe('10:30:45 UTC');
8+
});
9+
10+
it('should zero-pad single-digit hours, minutes, and seconds', () => {
11+
expect(formatLogTimestamp(new Date('2024-01-15T01:02:03Z'))).toBe('01:02:03 UTC');
12+
});
13+
14+
it('should ignore local timezone offset and always render in UTC', () => {
15+
const date = new Date(Date.UTC(2024, 0, 15, 23, 59, 59));
16+
expect(formatLogTimestamp(date)).toBe('23:59:59 UTC');
17+
});
18+
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function formatLogTimestamp(date: Date): string {
2+
const time = date.toLocaleTimeString('en-US', {
3+
hour: '2-digit',
4+
hour12: false,
5+
minute: '2-digit',
6+
second: '2-digit',
7+
timeZone: 'UTC',
8+
});
9+
return `${time} UTC`;
10+
}

app/entities/program-logs/model/useParsedLogs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { TransactionError } from '@solana/web3.js';
33
import { useCluster } from '@/app/providers/cluster';
44
import { parseProgramLogs } from '@/app/utils/program-logs';
55

6-
export function useParsedLogs(error: TransactionError | null) {
6+
export function useParsedLogs(error: TransactionError | undefined) {
77
const { cluster } = useCluster();
88

99
const parseLogs = (logs: string[]) => parseProgramLogs(logs, error, cluster);

app/entities/program-logs/ui/ProgramLogs.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,21 @@ import type { InstructionLogs } from '@/app/utils/program-logs';
88

99
export function ProgramLogs({
1010
header,
11-
logs,
12-
parseLogs,
11+
rawLogs,
12+
parsedLogs,
1313
programName,
1414
}: {
1515
header?: React.ReactNode;
16-
logs: string[];
17-
parseLogs: (logs: string[]) => InstructionLogs[];
16+
rawLogs: string[];
17+
parsedLogs: InstructionLogs[];
1818
programName?: string;
1919
}) {
2020
const [showRaw, setShowRaw] = useState(false);
2121

2222
const content = showRaw ? (
2323
<div className="overflow-hidden rounded-lg">
2424
<SolarizedJsonViewer
25-
src={logs}
25+
src={rawLogs}
2626
name={false}
2727
enableClipboard={true}
2828
collapsed={false}
@@ -33,7 +33,7 @@ export function ProgramLogs({
3333
/>
3434
</div>
3535
) : (
36-
<ProgramLogRows logs={parseLogs(logs)} programName={programName} />
36+
<ProgramLogRows logs={parsedLogs} programName={programName} />
3737
);
3838

3939
return (
@@ -47,7 +47,7 @@ export function ProgramLogs({
4747
<div className="flex min-h-0 flex-col gap-2 overflow-auto">
4848
{header}
4949

50-
{content}
50+
<div className="overflow-x-auto">{content}</div>
5151
</div>
5252
</div>
5353
);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { cva } from 'class-variance-authority';
2+
import { ReactNode } from 'react';
3+
import { ExternalLink } from 'react-feather';
4+
5+
import { Copyable } from '@/app/components/common/Copyable';
6+
import { Badge } from '@/app/components/shared/ui/badge';
7+
import { cn } from '@/app/components/shared/utils';
8+
9+
import { formatLogTimestamp } from '../model/formatLogTimestamp';
10+
11+
export type StatusTheme = 'accent' | 'destructive';
12+
13+
const themedColor = {
14+
accent: 'text-accent-700',
15+
destructive: 'text-destructive',
16+
} as const;
17+
18+
const timestampVariants = cva('whitespace-nowrap text-xs tracking-tight', {
19+
variants: { theme: themedColor },
20+
});
21+
22+
const monoTextVariants = cva('overflow-hidden text-ellipsis whitespace-nowrap font-mono text-sm tracking-tight', {
23+
variants: { theme: themedColor },
24+
});
25+
26+
type StatusBarProps = {
27+
message?: ReactNode;
28+
date: Date;
29+
theme: StatusTheme;
30+
badge: { label: string; variant: 'success' | 'destructive' };
31+
link: string | undefined;
32+
};
33+
34+
export function StatusBar({ message, date, theme, badge, link }: StatusBarProps) {
35+
const badgeNode = (
36+
<Badge variant={badge.variant} size="xs" className={cn(!link && 'ml-auto')}>
37+
{badge.label}
38+
{Boolean(link) && (
39+
<>
40+
{' '}
41+
<ExternalLink size={12} />
42+
</>
43+
)}
44+
</Badge>
45+
);
46+
return (
47+
<div className="flex items-center gap-2 rounded border border-solid border-neutral-600 px-4 py-2">
48+
{message}
49+
<div className="flex items-center">
50+
<span className={timestampVariants({ theme })}>{formatLogTimestamp(date)}</span>
51+
</div>
52+
{link ? (
53+
<a href={link} target="_blank" rel="noopener noreferrer" className="ml-auto">
54+
{badgeNode}
55+
</a>
56+
) : (
57+
badgeNode
58+
)}
59+
</div>
60+
);
61+
}
62+
63+
export function CopyableMonoText({ text, theme }: { text: string; theme: StatusTheme }) {
64+
return (
65+
<div className="flex w-1/2 min-w-0 items-center gap-1">
66+
<Copyable text={text}>
67+
<span className={monoTextVariants({ theme })}>{text}</span>
68+
</Copyable>
69+
</div>
70+
);
71+
}
Lines changed: 19 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,23 @@
1-
import { Badge } from '@components/shared/ui/badge';
2-
import { ExternalLink } from 'react-feather';
3-
4-
import { Copyable } from '@/app/components/common/Copyable';
5-
6-
export function TxErrorStatus({ message, date, link }: { message: string | null; date: Date; link: string | null }) {
7-
const time = date.toLocaleTimeString('en-US', {
8-
hour: '2-digit',
9-
hour12: false,
10-
minute: '2-digit',
11-
second: '2-digit',
12-
timeZone: 'UTC',
13-
});
14-
const timestamp = `${time} UTC`;
1+
import { CopyableMonoText, StatusBar } from './StatusBar';
152

3+
export function TxErrorStatus({
4+
message,
5+
date,
6+
link,
7+
label = 'Error',
8+
}: {
9+
message: string | undefined;
10+
date: Date;
11+
link: string | undefined;
12+
label?: string;
13+
}) {
1614
return (
17-
<div className="border-1 flex items-center gap-2 rounded border border-solid border-neutral-600 px-4 py-2">
18-
{message && (
19-
<div className="flex w-1/2 items-center gap-1">
20-
<Copyable text={message}>
21-
<span className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-sm tracking-tight text-destructive">
22-
{message}
23-
</span>
24-
</Copyable>
25-
</div>
26-
)}
27-
28-
<div className="flex items-center">
29-
<span className="whitespace-nowrap text-xs tracking-tight text-destructive">{timestamp}</span>
30-
</div>
31-
{link ? (
32-
<a href={link} target="_blank" rel="noopener noreferrer" className="ml-auto">
33-
<Badge variant="destructive" size="xs" className="ml-auto">
34-
Error <ExternalLink size={12} />
35-
</Badge>
36-
</a>
37-
) : (
38-
<Badge variant="destructive" size="xs" className="ml-auto">
39-
Error
40-
</Badge>
41-
)}
42-
</div>
15+
<StatusBar
16+
message={message ? <CopyableMonoText text={message} theme="destructive" /> : undefined}
17+
date={date}
18+
theme="destructive"
19+
badge={{ label, variant: 'destructive' }}
20+
link={link}
21+
/>
4322
);
4423
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { CopyableMonoText, StatusBar } from './StatusBar';
2+
3+
type TxExecutionStatusProps = {
4+
status: 'success' | 'error';
5+
signature: string;
6+
date: Date;
7+
link: string;
8+
};
9+
10+
export function TxExecutionStatus({ status, signature, date, link }: TxExecutionStatusProps) {
11+
const isSuccess = status === 'success';
12+
return (
13+
<StatusBar
14+
message={<CopyableMonoText text={signature} theme={isSuccess ? 'accent' : 'destructive'} />}
15+
date={date}
16+
theme={isSuccess ? 'accent' : 'destructive'}
17+
badge={{ label: isSuccess ? 'Success' : 'Error', variant: isSuccess ? 'success' : 'destructive' }}
18+
link={link}
19+
/>
20+
);
21+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { CopyableMonoText, StatusBar } from './StatusBar';
2+
3+
type TxSimulationStatusProps =
4+
| { status: 'success'; unitsConsumed: number | undefined; date: Date; link?: string }
5+
| { status: 'error'; message?: string; date: Date; link?: string };
6+
7+
export function TxSimulationStatus(props: TxSimulationStatusProps) {
8+
if (props.status === 'success') {
9+
return (
10+
<StatusBar
11+
message={
12+
props.unitsConsumed !== undefined ? (
13+
<ComputeUnitsBadge unitsConsumed={props.unitsConsumed} />
14+
) : undefined
15+
}
16+
date={props.date}
17+
theme="accent"
18+
badge={{ label: 'Simulated', variant: 'success' }}
19+
link={props.link}
20+
/>
21+
);
22+
}
23+
return (
24+
<StatusBar
25+
message={props.message ? <CopyableMonoText text={props.message} theme="destructive" /> : undefined}
26+
date={props.date}
27+
theme="destructive"
28+
badge={{ label: 'Simulation Error', variant: 'destructive' }}
29+
link={props.link}
30+
/>
31+
);
32+
}
33+
34+
function ComputeUnitsBadge({ unitsConsumed }: { unitsConsumed: number }) {
35+
return (
36+
<div className="flex shrink-0 items-center gap-1">
37+
<span className="whitespace-nowrap text-xs tracking-tight text-accent-700">
38+
{unitsConsumed.toLocaleString('en-US')} CU
39+
</span>
40+
</div>
41+
);
42+
}

app/entities/program-logs/ui/TxSuccessStatus.tsx

Lines changed: 0 additions & 35 deletions
This file was deleted.

0 commit comments

Comments
 (0)