diff --git a/app/components/inspector/InspectorPage.tsx b/app/components/inspector/InspectorPage.tsx index c28493b0..179e85d1 100755 --- a/app/components/inspector/InspectorPage.tsx +++ b/app/components/inspector/InspectorPage.tsx @@ -128,7 +128,7 @@ function decodeUrlParams(params: URLSearchParams): [TransactionData | string, UR } } -export function TransactionInspectorPage({ signature }: { signature?: string }) { +export function TransactionInspectorPage({ signature, showTokenBalanceChanges }: { signature?: string; showTokenBalanceChanges: boolean }) { const [transaction, setTransaction] = React.useState(); const currentSearchParams = useSearchParams(); const currentPathname = usePathname(); @@ -196,9 +196,9 @@ export function TransactionInspectorPage({ signature }: { signature?: string }) {signature ? ( - + ) : transaction ? ( - + ) : ( )} @@ -206,7 +206,7 @@ export function TransactionInspectorPage({ signature }: { signature?: string }) ); } -function PermalinkView({ signature }: { signature: string; reset: () => void }) { +function PermalinkView({ signature, showTokenBalanceChanges }: { signature: string; reset: () => void; showTokenBalanceChanges: boolean }) { const details = useRawTransactionDetails(signature); const fetchTransaction = useFetchRawTransaction(); const refreshTransaction = () => fetchTransaction(signature); @@ -233,10 +233,10 @@ function PermalinkView({ signature }: { signature: string; reset: () => void }) const { message, signatures } = transaction; const tx = { message, rawMessage: message.serialize(), signatures }; - return ; + return ; } -function LoadedView({ transaction, onClear }: { transaction: TransactionData; onClear: () => void }) { +function LoadedView({ transaction, onClear, showTokenBalanceChanges }: { transaction: TransactionData; onClear: () => void; showTokenBalanceChanges: boolean }) { const { message, rawMessage, signatures } = transaction; const fetchAccountInfo = useFetchAccountInfo(); @@ -249,7 +249,7 @@ function LoadedView({ transaction, onClear }: { transaction: TransactionData; on return ( <> - + {signatures && } diff --git a/app/components/inspector/SimulatorCard.tsx b/app/components/inspector/SimulatorCard.tsx index c369207f..5bdb387d 100755 --- a/app/components/inspector/SimulatorCard.tsx +++ b/app/components/inspector/SimulatorCard.tsx @@ -1,12 +1,16 @@ import { ProgramLogsCardBody } from '@components/ProgramLogsCardBody'; import { useCluster } from '@providers/cluster'; -import { Connection, VersionedMessage, VersionedTransaction } from '@solana/web3.js'; +import { AccountLayout, MintLayout, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { AccountInfo, AddressLookupTableAccount, Connection, MessageAddressTableLookup, ParsedAccountData, ParsedMessageAccount, SimulatedTransactionAccountInfo, TokenBalance, VersionedMessage, VersionedTransaction } from '@solana/web3.js'; +import { PublicKey } from '@solana/web3.js'; import { InstructionLogs, parseProgramLogs } from '@utils/program-logs'; import React from 'react'; -export function SimulatorCard({ message }: { message: VersionedMessage }) { +import { generateTokenBalanceRows,TokenBalancesCardInner, TokenBalancesCardInnerProps } from '../transaction/TokenBalancesCard'; + +export function SimulatorCard({ message, showTokenBalanceChanges }: { message: VersionedMessage; showTokenBalanceChanges: boolean }) { const { cluster, url } = useCluster(); - const { simulate, simulating, simulationLogs: logs, simulationError } = useSimulator(message); + const { simulate, simulating, simulationLogs: logs, simulationError, simulationTokenBalanceRows } = useSimulator(message); if (simulating) { return (
@@ -49,15 +53,20 @@ export function SimulatorCard({ message }: { message: VersionedMessage }) { } return ( -
-
-

Transaction Simulation

- + <> +
+
+

Transaction Simulation

+ +
+
- -
+ {showTokenBalanceChanges && simulationTokenBalanceRows && !simulationError && simulationTokenBalanceRows.rows.length ? ( + + ) : null} + ); } @@ -66,6 +75,7 @@ function useSimulator(message: VersionedMessage) { const [simulating, setSimulating] = React.useState(false); const [logs, setLogs] = React.useState | null>(null); const [error, setError] = React.useState(); + const [tokenBalanceRows, setTokenBalanceRows] = React.useState(); React.useEffect(() => { setLogs(null); @@ -81,11 +91,115 @@ function useSimulator(message: VersionedMessage) { const connection = new Connection(url, 'confirmed'); (async () => { try { - // Simulate without signers to skip signer verification + const addressTableLookups: MessageAddressTableLookup[] = message.addressTableLookups; + const addressTableLookupKeys: PublicKey[] = addressTableLookups.map((addressTableLookup: MessageAddressTableLookup) => { + return addressTableLookup.accountKey; + }); + const addressTableLookupsFetched: (AccountInfo | null)[] = await connection.getMultipleAccountsInfo(addressTableLookupKeys); + const nonNullAddressTableLookups: AccountInfo[] = addressTableLookupsFetched.filter((o): o is AccountInfo => !!o); + + const addressLookupTablesParsed: AddressLookupTableAccount[] = nonNullAddressTableLookups.map((addressTableLookup: AccountInfo, index) => { + return new AddressLookupTableAccount({ + key: addressTableLookupKeys[index], + state: AddressLookupTableAccount.deserialize(addressTableLookup.data) + }); + }) + + // Fetch all the accounts before simulating + const accountKeys = message.getAccountKeys({ + addressLookupTableAccounts: addressLookupTablesParsed, + }).staticAccountKeys; + const parsedAccountsPre = await connection.getMultipleParsedAccounts(accountKeys); + + // Simulate without signers to skip signer verification. Request + // all account data after the simulation. const resp = await connection.simulateTransaction(new VersionedTransaction(message), { + accounts: { + addresses: accountKeys.map(function (key) { + return key.toBase58(); + }), + encoding: 'base64', + }, replaceRecentBlockhash: true, }); + const mintToDecimals: { [mintPk: string]: number} = getMintDecimals( + accountKeys, + parsedAccountsPre.value, + resp.value.accounts as SimulatedTransactionAccountInfo[], + ) + + const preTokenBalances: TokenBalance[] = []; + const postTokenBalances: TokenBalance[] = []; + const tokenAccountKeys: ParsedMessageAccount[] = []; + + for (let index = 0; index < accountKeys.length; index++) { + const key = accountKeys[index]; + const parsedAccountPre = parsedAccountsPre.value[index]; + const accountDataPost = resp.value.accounts?.at(index)?.data[0]; + const accountOwnerPost = resp.value.accounts?.at(index)?.owner; + + if ( + (parsedAccountPre?.owner.toBase58() == TOKEN_PROGRAM_ID.toBase58() || + parsedAccountPre?.owner.toBase58() == "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") && + (parsedAccountPre?.data as ParsedAccountData).parsed.type === 'account' + ) { + const mint = (parsedAccountPre?.data as ParsedAccountData).parsed.info.mint; + const owner = (parsedAccountPre?.data as ParsedAccountData).parsed.info.owner; + const tokenAmount = (parsedAccountPre?.data as ParsedAccountData).parsed.info.tokenAmount; + const preTokenBalance = { + accountIndex: tokenAccountKeys.length, + mint: mint, + owner: owner, + uiTokenAmount: tokenAmount, + }; + preTokenBalances.push(preTokenBalance); + } + + if ( + (accountOwnerPost === TOKEN_PROGRAM_ID.toBase58() || + accountOwnerPost === "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb") && + Buffer.from(accountDataPost!, 'base64').length >= 165 + ) { + const accountParsedPost = AccountLayout.decode(Buffer.from(accountDataPost!, 'base64')); + const mint = new PublicKey(accountParsedPost.mint); + const owner = new PublicKey(accountParsedPost.owner); + const postRawAmount = Number(accountParsedPost.amount.readBigUInt64LE(0)); + + const decimals = mintToDecimals[mint.toBase58()]; + const tokenAmount = postRawAmount / 10 ** decimals; + + const postTokenBalance = { + accountIndex: tokenAccountKeys.length, + mint: mint.toBase58(), + owner: owner.toBase58(), + uiTokenAmount: { + amount: postRawAmount.toString(), + decimals: decimals, + uiAmount: tokenAmount, + uiAmountString: tokenAmount.toString(), + }, + }; + postTokenBalances.push(postTokenBalance); + } + // All fields are ignored other than key, so set placeholders. + const parsedMessageAccount = { + pubkey: key, + signer: false, + writable: true, + }; + tokenAccountKeys.push(parsedMessageAccount); + } + + const tokenBalanceRows = generateTokenBalanceRows( + preTokenBalances, + postTokenBalances, + tokenAccountKeys + ); + if (tokenBalanceRows) { + setTokenBalanceRows({ rows: tokenBalanceRows }); + } + if (resp.value.logs === null) { throw new Error('Expected to receive logs from simulation'); } @@ -97,6 +211,10 @@ function useSimulator(message: VersionedMessage) { // Prettify logs setLogs(parseProgramLogs(resp.value.logs, resp.value.err, cluster)); } + // If the response has an error, the logs will say what it it, so no need to parse here. + if (resp.value.err) { + setError('TransactionError'); + } } catch (err) { console.error(err); setLogs(null); @@ -113,5 +231,47 @@ function useSimulator(message: VersionedMessage) { simulating, simulationError: error, simulationLogs: logs, + simulationTokenBalanceRows: tokenBalanceRows, }; } + +function getMintDecimals( + accountKeys: PublicKey[], + parsedAccountsPre: (AccountInfo | null)[], + accountDatasPost: SimulatedTransactionAccountInfo[] +): { [mintPk: string]: number} { + const mintToDecimals: { [mintPk: string]: number } = {}; + // Get all the necessary mint decimals by looking at parsed token accounts + // and mints before, as well as mints after. + for (let index = 0; index < accountKeys.length; index++) { + const parsedAccount = parsedAccountsPre[index]; + const key = accountKeys[index]; + + // Token account before + if ( + parsedAccount?.owner.toBase58() == TOKEN_PROGRAM_ID.toBase58() && + (parsedAccount?.data as ParsedAccountData).parsed.type === 'account' + ) { + mintToDecimals[(parsedAccount?.data as ParsedAccountData).parsed.info.mint] = ( + parsedAccount?.data as ParsedAccountData + ).parsed.info.tokenAmount.decimals; + } + // Mint account before + if ( + parsedAccount?.owner.toBase58() == TOKEN_PROGRAM_ID.toBase58() && + (parsedAccount?.data as ParsedAccountData).parsed.type === 'mint' + ) { + mintToDecimals[key.toBase58()] = (parsedAccount?.data as ParsedAccountData).parsed.info.decimals; + } + + // Token account after + const accountDataPost = accountDatasPost.at(index)?.data[0]; + const accountOwnerPost = accountDatasPost.at(index)?.owner; + if (accountOwnerPost === TOKEN_PROGRAM_ID.toBase58() && Buffer.from(accountDataPost!, 'base64').length === 82) { + const accountParsedPost = MintLayout.decode(Buffer.from(accountDataPost!, 'base64')); + mintToDecimals[key.toBase58()] = accountParsedPost.decimals; + } + } + + return mintToDecimals; +} diff --git a/app/components/transaction/TokenBalancesCard.tsx b/app/components/transaction/TokenBalancesCard.tsx index d608976e..d3f98f33 100644 --- a/app/components/transaction/TokenBalancesCard.tsx +++ b/app/components/transaction/TokenBalancesCard.tsx @@ -43,12 +43,12 @@ export function TokenBalancesCard({ signature }: SignatureProps) { return } -type TokenBalancesCardInnerProps = { +export type TokenBalancesCardInnerProps = { rows: TokenBalanceRow[] } -function TokenBalancesCardInner({ rows }: TokenBalancesCardInnerProps) { +export function TokenBalancesCardInner({ rows }: TokenBalancesCardInnerProps) { const { cluster, url } = useCluster(); const [tokenInfosLoading, setTokenInfosLoading] = useState(true); const [tokenSymbols, setTokenSymbols] = useState>(new Map()); @@ -107,7 +107,7 @@ function TokenBalancesCardInner({ rows }: TokenBalancesCardInnerProps) { ); } -function generateTokenBalanceRows( +export function generateTokenBalanceRows( preTokenBalances: TokenBalance[], postTokenBalances: TokenBalance[], accounts: ParsedMessageAccount[] diff --git a/app/tx/(inspector)/[signature]/inspect/page.tsx b/app/tx/(inspector)/[signature]/inspect/page.tsx index 4e3e2e12..19e2c837 100644 --- a/app/tx/(inspector)/[signature]/inspect/page.tsx +++ b/app/tx/(inspector)/[signature]/inspect/page.tsx @@ -15,5 +15,5 @@ export async function generateMetadata({ params: { signature } }: Props): Promis } export default function TransactionInspectionPage({ params: { signature } }: Props) { - return ; + return ; } diff --git a/app/tx/(inspector)/inspector/page.tsx b/app/tx/(inspector)/inspector/page.tsx index 448c1515..9234fcbe 100644 --- a/app/tx/(inspector)/inspector/page.tsx +++ b/app/tx/(inspector)/inspector/page.tsx @@ -7,5 +7,5 @@ type Props = Readonly<{ }>; export default function Page({ params: { signature } }: Props) { - return ; + return ; }