From b91b274dbc85a865d6e91d4f6c78ae5deb36482e Mon Sep 17 00:00:00 2001 From: Britt Cyr Date: Thu, 14 Sep 2023 06:50:43 -0400 Subject: [PATCH] Show token balance changes on a simulated transaction (#286) Currently, the simulation only shows whether or not the transaction succeeds and the logs. It would be very useful for this to include token balance changes since the token program doesnt log the accounts or amounts. This should bring the displayed information of a simulation up to parity with what the explorer does for completed transactions. The difficult part of this is that the simulateTransaction RPC spec does not include the pre/post token balances similar to asking for a completed transaction. To address this, we request the accountData for all accounts immediately before simulating and record the values of all token accounts. Then the simulateTransaction allows optional arguments for accounts to get their post simulation data. Parse that to get the post transaction token balance. Then convert it to the same format that the existing token balances component from the transaction page. Example with token balances (before, the simulate was the same thing without the token changes component): ![image](https://github.com/solana-labs/explorer/assets/1320260/f53a2c60-a287-4839-9038-1431c68cb9cd) --------- Co-authored-by: Callum McIntyre --- app/components/inspector/InspectorPage.tsx | 14 +- app/components/inspector/SimulatorCard.tsx | 184 ++++++++++++++++-- .../transaction/TokenBalancesCard.tsx | 6 +- .../(inspector)/[signature]/inspect/page.tsx | 2 +- app/tx/(inspector)/inspector/page.tsx | 2 +- 5 files changed, 184 insertions(+), 24 deletions(-) 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 ; }