From 353d9cb4d91a6dfc261ef872135764555d1c1566 Mon Sep 17 00:00:00 2001 From: YaTut1901 Date: Tue, 26 Nov 2024 14:20:08 +0200 Subject: [PATCH] added /tx/inspector route to return transactiod data --- app/api/tx/inspector/route.ts | 169 +++++++ app/components/account/AnchorAccountCard.tsx | 3 +- app/components/inspector/InspectorPage.tsx | 15 +- app/components/inspector/RawInputCard.tsx | 10 +- .../instruction/AnchorDetailsCard.tsx | 5 +- app/providers/cluster.tsx | 19 +- app/utils/anchor.tsx | 43 +- app/utils/cluster.ts | 33 +- app/utils/tx.ts | 417 +++++++++++++++++- 9 files changed, 623 insertions(+), 91 deletions(-) create mode 100644 app/api/tx/inspector/route.ts diff --git a/app/api/tx/inspector/route.ts b/app/api/tx/inspector/route.ts new file mode 100644 index 00000000..c4984927 --- /dev/null +++ b/app/api/tx/inspector/route.ts @@ -0,0 +1,169 @@ +import { VersionedMessage } from '@solana/web3.js'; +import base58 from 'bs58'; +import { ReadonlyURLSearchParams } from 'next/navigation'; +import { NextResponse } from 'next/server'; + +import { clusterUrl, parseQuery } from '@/app/utils/cluster'; +import { Cluster } from '@/app/utils/cluster'; +import { MIN_MESSAGE_LENGTH, TransactionData } from '@/app/utils/tx'; +import { getTxInfo } from '@/app/utils/tx'; + +export type AddressContext = { + ownedBy: string; + balance: number; + size: number; + message?: string; +} + +export type AddressWithContext = { + address: string; + context?: AddressContext; +} + +export type Signature = { + signature: string; + signer: string; + validity?: string; + feePayer: boolean; +} + +export type AnchorArgument = { + name: string, + type: string, + value: string, + message?: string +} + +export type AnchorProgramInstruction = { + programName: string, + instructionName: string, + message?: string, + address: string, + accounts: Account[], + arguments: AnchorArgument[] +} + +export type DefaultInstruction = { + name: string, + address: AddressWithContext, + accounts: Account[], + data: string +} + +export type AccountWithContext = { + name: string, + readOnly: boolean; + signer: boolean; + address: AddressWithContext; +} + +export type Account = { + name: string, + readOnly: boolean; + signer: boolean; + address: string; +} + +export type AddressTableLookup = { + address: string, + lookupTableIndex: number, + resolvedAddress?: string, + readonly: boolean, + message?: string +} + +export type TransactionInspectorResponse = { + serializedSize: number; + fees: number; + feePayer?: AddressWithContext, + signatures?: Signature[], + accounts: AccountWithContext[], + addressTableLookups?: AddressTableLookup[], + instructions: (AnchorProgramInstruction | DefaultInstruction)[] +} + +function decodeSignatures(signaturesParam: string): (string | null)[] { + let signatures; + try { + signatures = JSON.parse(signaturesParam); + } catch (err) { + throw new Error('Signatures param is not valid JSON'); + } + + if (!Array.isArray(signatures)) { + throw new Error('Signatures param is not a JSON array'); + } + + const validSignatures: (string | null)[] = []; + for (const signature of signatures) { + if (signature === null) { + continue; + } + + if (typeof signature !== 'string') { + throw new Error('Signature is not a string'); + } + + try { + base58.decode(signature); + validSignatures.push(signature); + } catch (err) { + throw new Error('Signature is not valid base58'); + } + } + + return validSignatures; +} + +function decodeUrlParams(searchParams: URLSearchParams): TransactionData { + const messageParam = searchParams.get('message'); + if (!messageParam) { + throw new Error('Missing message parameter'); + } + + let signatures: (string | null)[] | undefined; + const signaturesParam = searchParams.get('signatures'); + if (signaturesParam) { + try { + signatures = decodeSignatures(decodeURIComponent(signaturesParam)); + } catch (err) { + throw new Error(`Invalid signatures parameter: ${(err as Error).message}`); + } + } + try { + const buffer = Uint8Array.from(atob(decodeURIComponent(messageParam)), c => c.charCodeAt(0)); + + if (buffer.length < MIN_MESSAGE_LENGTH) { + throw new Error('Message buffer is too short'); + } + + const message = VersionedMessage.deserialize(buffer); + + return { + message, + rawMessage: buffer, + signatures, + }; + } catch (err) { + throw new Error('Invalid message format'); + } +} + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const cluster: Cluster = parseQuery(searchParams as ReadonlyURLSearchParams); + const customUrl: string | null = searchParams.get('customUrl'); + + const url = customUrl ? customUrl : clusterUrl(cluster, ''); + + let txData: TransactionData; + try { + txData = decodeUrlParams(searchParams); + } catch (err) { + return NextResponse.json({ error: (err as Error).message }, { status: 400 }); + } + + return NextResponse.json(await getTxInfo(txData, url)); +} + + diff --git a/app/components/account/AnchorAccountCard.tsx b/app/components/account/AnchorAccountCard.tsx index 1fa16eaa..3d610ebd 100644 --- a/app/components/account/AnchorAccountCard.tsx +++ b/app/components/account/AnchorAccountCard.tsx @@ -4,7 +4,8 @@ import { IdlTypeDef } from '@coral-xyz/anchor/dist/cjs/idl'; import { Account } from '@providers/accounts'; import { useAnchorProgram } from '@providers/anchor'; import { useCluster } from '@providers/cluster'; -import { getAnchorProgramName, mapAccountToRows } from '@utils/anchor'; +import { mapAccountToRows } from '@utils/anchor'; +import { getAnchorProgramName } from '@utils/tx'; import React, { useMemo } from 'react'; export function AnchorAccountCard({ account }: { account: Account }) { diff --git a/app/components/inspector/InspectorPage.tsx b/app/components/inspector/InspectorPage.tsx index 179e85d1..2d273e1f 100755 --- a/app/components/inspector/InspectorPage.tsx +++ b/app/components/inspector/InspectorPage.tsx @@ -14,20 +14,17 @@ import base58 from 'bs58'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import React from 'react'; +import { DEFAULT_FEES } from '@/app/utils/tx'; +import { MIN_MESSAGE_LENGTH, TransactionData } from '@/app/utils/tx'; + import { AccountsCard } from './AccountsCard'; import { AddressTableLookupsCard } from './AddressTableLookupsCard'; import { AddressWithContext, createFeePayerValidator } from './AddressWithContext'; import { InstructionsSection } from './InstructionsSection'; -import { MIN_MESSAGE_LENGTH, RawInput } from './RawInputCard'; +import { RawInput } from './RawInputCard'; import { TransactionSignatures } from './SignaturesCard'; import { SimulatorCard } from './SimulatorCard'; -export type TransactionData = { - rawMessage: Uint8Array; - message: VersionedMessage; - signatures?: (string | null)[]; -}; - // Decode a url param and return the result. If decoding fails, return whether // the param should be deleted. function decodeParam(params: URLSearchParams, name: string): string | boolean { @@ -258,10 +255,6 @@ function LoadedView({ transaction, onClear, showTokenBalanceChanges }: { transac ); } -const DEFAULT_FEES = { - lamportsPerSignature: 5000, -}; - function OverviewCard({ message, raw, onClear }: { message: VersionedMessage; raw: Uint8Array; onClear: () => void }) { const fee = message.header.numRequiredSignatures * DEFAULT_FEES.lamportsPerSignature; const feePayerValidator = createFeePayerValidator(fee); diff --git a/app/components/inspector/RawInputCard.tsx b/app/components/inspector/RawInputCard.tsx index 90bba213..60c9c3aa 100755 --- a/app/components/inspector/RawInputCard.tsx +++ b/app/components/inspector/RawInputCard.tsx @@ -1,10 +1,9 @@ import { VersionedMessage } from '@solana/web3.js'; +import { MIN_MESSAGE_LENGTH, TransactionData } from '@utils/tx'; import base58 from 'bs58'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import React from 'react'; -import type { TransactionData } from './InspectorPage'; - function getMessageDataFromBytes(bytes: Uint8Array): { message: VersionedMessage; rawMessage: Uint8Array; @@ -63,13 +62,6 @@ function getTransactionDataFromUserSuppliedBytes(bytes: Uint8Array): { } } -export const MIN_MESSAGE_LENGTH = - 3 + // header - 1 + // accounts length - 32 + // accounts, must have at least one address for fees - 32 + // recent blockhash - 1; // instructions length - export function RawInput({ value, setTransactionData, diff --git a/app/components/instruction/AnchorDetailsCard.tsx b/app/components/instruction/AnchorDetailsCard.tsx index cb93688e..bfad3edb 100644 --- a/app/components/instruction/AnchorDetailsCard.tsx +++ b/app/components/instruction/AnchorDetailsCard.tsx @@ -3,15 +3,14 @@ import { BorshEventCoder, BorshInstructionCoder, Idl, Instruction, Program } fro import { IdlEvent, IdlField, IdlInstruction, IdlTypeDefTyStruct } from '@coral-xyz/anchor/dist/cjs/idl'; import { SignatureResult, TransactionInstruction } from '@solana/web3.js'; import { - getAnchorAccountsFromInstruction, getAnchorNameForInstruction, - getAnchorProgramName, - instructionIsSelfCPI, mapIxArgsToRows, } from '@utils/anchor'; import { camelToTitleCase } from '@utils/index'; import { useMemo } from 'react'; +import { getAnchorAccountsFromInstruction, getAnchorProgramName, instructionIsSelfCPI } from '@/app/utils/tx'; + import { InstructionCard } from './InstructionCard'; export default function AnchorDetailsCard(props: { diff --git a/app/providers/cluster.tsx b/app/providers/cluster.tsx index 81a951df..e3a85095 100644 --- a/app/providers/cluster.tsx +++ b/app/providers/cluster.tsx @@ -1,8 +1,8 @@ 'use client'; -import { Cluster, clusterName, ClusterStatus, clusterUrl, DEFAULT_CLUSTER } from '@utils/cluster'; +import { Cluster, clusterName, ClusterStatus, clusterUrl, DEFAULT_CLUSTER, parseQuery } from '@utils/cluster'; import { localStorageIsAvailable } from '@utils/local-storage'; -import { ReadonlyURLSearchParams, usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import React, { createContext, useContext, useEffect, useReducer, useState } from 'react'; import { createSolanaRpc } from 'web3js-experimental'; @@ -50,21 +50,6 @@ function clusterReducer(state: State, action: Action): State { } } -function parseQuery(searchParams: ReadonlyURLSearchParams | null): Cluster { - const clusterParam = searchParams?.get('cluster'); - switch (clusterParam) { - case 'custom': - return Cluster.Custom; - case 'devnet': - return Cluster.Devnet; - case 'testnet': - return Cluster.Testnet; - case 'mainnet-beta': - default: - return Cluster.MainnetBeta; - } -} - const ModalContext = createContext<[boolean, SetShowModal] | undefined>(undefined); const StateContext = createContext(undefined); const DispatchContext = createContext(undefined); diff --git a/app/utils/anchor.tsx b/app/utils/anchor.tsx index e305793a..2e33d987 100644 --- a/app/utils/anchor.tsx +++ b/app/utils/anchor.tsx @@ -5,23 +5,12 @@ import { IdlField, IdlInstruction, IdlType, IdlTypeDef } from '@coral-xyz/anchor import { useAnchorProgram } from '@providers/anchor'; import { PublicKey, TransactionInstruction } from '@solana/web3.js'; import { Cluster } from '@utils/cluster'; -import { camelToTitleCase, numberWithSeparator, snakeToTitleCase } from '@utils/index'; -import { getProgramName } from '@utils/tx'; +import { camelToTitleCase, numberWithSeparator } from '@utils/index'; +import { ANCHOR_SELF_CPI_NAME, getAnchorProgramName,getProgramName, instructionIsSelfCPI } from '@utils/tx'; import React, { Fragment, ReactNode, useState } from 'react'; import { ChevronDown, ChevronUp, CornerDownRight } from 'react-feather'; import ReactJson from 'react-json-view'; -const ANCHOR_SELF_CPI_TAG = Buffer.from('1d9acb512ea545e4', 'hex').reverse(); -const ANCHOR_SELF_CPI_NAME = 'Anchor Self Invocation'; - -export function instructionIsSelfCPI(ixData: Buffer): boolean { - return ixData.slice(0, 8).equals(ANCHOR_SELF_CPI_TAG); -} - -export function getAnchorProgramName(program: Program | null): string | undefined { - return program && 'name' in program.idl.metadata ? snakeToTitleCase(program.idl.metadata.name) : undefined; -} - export function AnchorProgramName({ programId, url, @@ -61,34 +50,6 @@ export function getAnchorNameForInstruction(ix: TransactionInstruction, program: return _ixTitle.charAt(0).toUpperCase() + _ixTitle.slice(1); } -export function getAnchorAccountsFromInstruction( - decodedIx: { name: string } | null, - program: Program -): - | { - name: string; - isMut: boolean; - isSigner: boolean; - pda?: object; - }[] - | null { - if (decodedIx) { - // get ix accounts - const idlInstructions = program.idl.instructions.filter(ix => ix.name === decodedIx.name); - if (idlInstructions.length === 0) { - return null; - } - return idlInstructions[0].accounts as { - // type coercing since anchor doesn't export the underlying type - name: string; - isMut: boolean; - isSigner: boolean; - pda?: object; - }[]; - } - return null; -} - export function mapIxArgsToRows(ixArgs: any, ixType: IdlInstruction, idl: Idl) { return Object.entries(ixArgs).map(([key, value]) => { try { diff --git a/app/utils/cluster.ts b/app/utils/cluster.ts index f30963fc..b0453b48 100644 --- a/app/utils/cluster.ts +++ b/app/utils/cluster.ts @@ -1,3 +1,5 @@ +import { ReadonlyURLSearchParams } from "next/navigation"; + export enum ClusterStatus { Connected, Connecting, @@ -26,6 +28,21 @@ export function clusterSlug(cluster: Cluster): string { } } +export function parseQuery(searchParams: ReadonlyURLSearchParams | null): Cluster { + const clusterParam = searchParams?.get('cluster'); + switch (clusterParam) { + case 'custom': + return Cluster.Custom; + case 'devnet': + return Cluster.Devnet; + case 'testnet': + return Cluster.Testnet; + case 'mainnet-beta': + default: + return Cluster.MainnetBeta; + } +} + export function clusterName(cluster: Cluster): string { switch (cluster) { case Cluster.MainnetBeta: @@ -44,14 +61,6 @@ export const TESTNET_URL = 'https://api.testnet.solana.com'; export const DEVNET_URL = 'https://api.devnet.solana.com'; export function clusterUrl(cluster: Cluster, customUrl: string): string { - const modifyUrl = (url: string): string => { - if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { - return url; - } else { - return url.replace('api', 'explorer-api'); - } - }; - switch (cluster) { case Cluster.Devnet: return process.env.NEXT_PUBLIC_DEVNET_RPC_URL ?? modifyUrl(DEVNET_URL); @@ -64,4 +73,12 @@ export function clusterUrl(cluster: Cluster, customUrl: string): string { } } +function modifyUrl(url: string): string { + if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { + return url; + } else { + return url.replace('api', 'explorer-api'); + } +} + export const DEFAULT_CLUSTER = Cluster.MainnetBeta; diff --git a/app/utils/tx.ts b/app/utils/tx.ts index 1bad9c47..cfaedd0a 100644 --- a/app/utils/tx.ts +++ b/app/utils/tx.ts @@ -1,21 +1,100 @@ +import { AnchorProvider, BorshEventCoder, BorshInstructionCoder, Idl, Instruction, Program } from '@coral-xyz/anchor'; +import { IdlField, IdlInstruction } from '@coral-xyz/anchor/dist/cjs/idl'; +import { IdlEvent, IdlTypeDefTyStruct } from '@coral-xyz/anchor/dist/cjs/idl'; +import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'; +import { Connection } from '@solana/web3.js'; import { + AccountMeta, + AddressLookupTableAccount, + Keypair, + MessageCompiledInstruction, ParsedInstruction, ParsedTransaction, PartiallyDecodedInstruction, + PublicKey, + SystemProgram, Transaction, TransactionInstruction, + VersionedMessage, } from '@solana/web3.js'; -import { Cluster } from '@utils/cluster'; +import { AccountInfo } from '@solana/web3.js'; +import { Cluster, CLUSTERS, clusterSlug } from '@utils/cluster'; import { SerumMarketRegistry } from '@utils/serumMarketRegistry'; import bs58 from 'bs58'; +import * as nacl from 'tweetnacl'; +import { Account, AccountWithContext, AddressContext, AddressTableLookup, AddressWithContext, AnchorArgument, AnchorProgramInstruction, DefaultInstruction, Signature, TransactionInspectorResponse } from '../api/tx/inspector/route'; +import { snakeToTitleCase } from '.'; +import { formatIdl } from './convertLegacyIdl'; import { LOADER_IDS, PROGRAM_INFO_BY_ID, SPECIAL_IDS, SYSVAR_IDS } from './programs'; +export const ANCHOR_SELF_CPI_TAG = Buffer.from('1d9acb512ea545e4', 'hex').reverse(); +export const ANCHOR_SELF_CPI_NAME = 'Anchor Self Invocation'; + +export const DEFAULT_FEES = { + lamportsPerSignature: 5000, +}; + +export type TransactionData = { + rawMessage: Uint8Array; + message: VersionedMessage; + signatures?: (string | null)[]; +}; + +export const MIN_MESSAGE_LENGTH = + 3 + // header + 1 + // accounts length + 32 + // accounts, must have at least one address for fees + 32 + // recent blockhash + 1; // instructions length + export type TokenLabelInfo = { name?: string; symbol?: string; }; +type AnchorInstructionDetails = { + ixDef: IdlInstruction; + ixAccounts: { name: string; }[]; + decodedIxData: Instruction; +} + +export function getAnchorAccountsFromInstruction( + decodedIx: { name: string } | null, + program: Program +): + | { + name: string; + isMut: boolean; + isSigner: boolean; + pda?: object; + }[] + | null { + if (decodedIx) { + // get ix accounts + const idlInstructions = program.idl.instructions.filter(ix => ix.name === decodedIx.name); + if (idlInstructions.length === 0) { + return null; + } + return idlInstructions[0].accounts as { + // type coercing since anchor doesn't export the underlying type + name: string; + isMut: boolean; + isSigner: boolean; + pda?: object; + }[]; + } + return null; +} + +export function getAnchorProgramName(program: Program | null): string | undefined { + return program && 'name' in program.idl.metadata ? snakeToTitleCase(program.idl.metadata.name) : undefined; +} + +export function instructionIsSelfCPI(ixData: Buffer): boolean { + return ixData.slice(0, 8).equals(ANCHOR_SELF_CPI_TAG); +} + export function getProgramName(address: string, cluster: Cluster): string { const label = programLabel(address, cluster); if (label) return label; @@ -97,3 +176,339 @@ export function intoParsedTransaction(tx: Transaction): ParsedTransaction { signatures: tx.signatures.map(value => bs58.encode(value.signature as any)), }; } + +export async function getTxInfo(data: TransactionData, clusterUrl: string): Promise { + const size = 1 + 64 * data.message.header.numRequiredSignatures + data.rawMessage.length; + const fees = data.message.header.numRequiredSignatures * DEFAULT_FEES.lamportsPerSignature; + + return { + accounts: await getAccounts(data, clusterUrl), + addressTableLookups: await getAddressTableLookups(data, clusterUrl), + feePayer: await getFeePayer(data, clusterUrl), + fees: fees, + instructions: await getInstructions(data, clusterUrl), + serializedSize: size, + signatures: getSignatures(data) + } +} + +async function getInstructions(data: TransactionData, clusterUrl: string): Promise<(AnchorProgramInstruction | DefaultInstruction)[]> { + const lookupsForAccountKeyIndex = getLookupsForAccountKeyIndex(data); + + return await Promise.all(data.message.compiledInstructions.map(async ix => { + const accountMetas = getAccountMetas(data, ix, lookupsForAccountKeyIndex); + + const programId = data.message.staticAccountKeys[ix.programIdIndex]; + const idl = await Program.fetchIdl(programId, getProvider(clusterUrl)); + + return idl ? getAnchorProgramInstruction(ix, accountMetas, idl, clusterUrl, programId) : getDefaultInstruction(ix, programId, clusterUrl); + })); +} + +function getAccountMetas( + data: TransactionData, + ix: MessageCompiledInstruction, + lookupsForAccountKeyIndex: { lookupTableIndex: number, lookupTableKey: PublicKey }[] +): AccountMeta[] { + return ix.accountKeyIndexes.map((accountIndex, _index) => { + let lookup: PublicKey; + if (accountIndex >= data.message.staticAccountKeys.length) { + const lookupIndex = accountIndex - data.message.staticAccountKeys.length; + lookup = lookupsForAccountKeyIndex[lookupIndex].lookupTableKey; + } else { + lookup = data.message.staticAccountKeys[accountIndex]; + } + + const isSigner = accountIndex < data.message.header.numRequiredSignatures; + const isWritable = data.message.isAccountWritable(accountIndex); + const accountMeta: AccountMeta = { + isSigner, + isWritable, + pubkey: lookup, + }; + return accountMeta; + }); +} + +function getLookupsForAccountKeyIndex(data: TransactionData): { lookupTableIndex: number, lookupTableKey: PublicKey }[] { + return [ + ...data.message.addressTableLookups.flatMap(lookup => + lookup.writableIndexes.map(index => ({ + lookupTableIndex: index, + lookupTableKey: lookup.accountKey, + })) + ), + ...data.message.addressTableLookups.flatMap(lookup => + lookup.readonlyIndexes.map(index => ({ + lookupTableIndex: index, + lookupTableKey: lookup.accountKey, + })) + ), + ]; +} + +function getAnchorProgramInstruction(ix: MessageCompiledInstruction, accountMetas: AccountMeta[], idl: Idl, clusterUrl: string, programId: PublicKey): AnchorProgramInstruction { + const program = new Program(formatIdl(idl, programId.toString()), getProvider(clusterUrl)); + let errorMessage: string | undefined; + let anchorDetails: AnchorInstructionDetails | undefined; + try { + anchorDetails = getAnchorDetails(ix, program); + } catch (e) { + errorMessage = (e as Error).message; + } + + return { + accounts: anchorDetails ? getAnchorAccounts(accountMetas, anchorDetails.ixAccounts) : [], + address: program.programId.toBase58(), + arguments: anchorDetails ? getAnchorArguments(anchorDetails.decodedIxData, anchorDetails.ixDef) : [], + instructionName: getAnchorNameForInstruction(ix, program) || 'Unknown Instruction', + message: errorMessage, + programName: getAnchorProgramName(program) || 'Unknown Program', + } +} + +function getAnchorArguments(decodedIxData: Instruction, ixDef: IdlInstruction): AnchorArgument[] { + return Object.entries(decodedIxData.data).map(([key, value]) => { + const fieldDef = ixDef.args.find(ixDefArg => ixDefArg.name === key); + if (!fieldDef) { + return { + message: `Could not find expected ${key} field on account type definition for ${ixDef.name}`, + name: key, + type: 'unknown', + value: value.toString(), + } + } + + return { + name: key, + type: fieldDef.type.toString(), + value: value.toString(), + } + }); +} + +function getAnchorDetails(ix: MessageCompiledInstruction, program: Program): AnchorInstructionDetails { + const ixData = Buffer.from(ix.data); + if (instructionIsSelfCPI(ixData)) { + const coder = new BorshEventCoder(program.idl); + const decodedIxData = coder.decode(ixData.slice(8).toString('base64')); + + if (!decodedIxData) { + throw new Error('Failed to decode self CPI'); + } + + const ixEventDef = program.idl.events?.find( + ixDef => ixDef.name === decodedIxData?.name + ) as IdlEvent; + + const ixEventFields = program.idl.types?.find((type: any) => type.name === ixEventDef.name); + + const ixDef = { + ...ixEventDef, + accounts: [], + args: ((ixEventFields?.type as IdlTypeDefTyStruct).fields as IdlField[]) ?? [], + }; + + const ixAccounts = [{ name: 'eventAuthority' }]; + + return { + decodedIxData, + ixAccounts, + ixDef, + } + } + + const coder = new BorshInstructionCoder(program.idl); + const decodedIxData = coder.decode(ixData); + + if (decodedIxData) { + const ixDef = program.idl.instructions.find(ixDef => ixDef.name === decodedIxData?.name); + if (ixDef) { + const ixAccounts = getAnchorAccountsFromInstruction(decodedIxData, program)?.map(account => ({ name: account.name })) ?? []; + + return { + decodedIxData, + ixAccounts, + ixDef, + } + } + } + + throw new Error('Failed to decode instruction'); +} + +function getAnchorAccounts(accountMetas: AccountMeta[], ixAccounts: { name: string }[]): Account[] { + return accountMetas.map((meta, index) => ({ + address: meta.pubkey.toBase58(), + name: ixAccounts[index].name, + readOnly: !meta.isWritable, + signer: meta.isSigner + })); +} + +function getAnchorNameForInstruction(ix: MessageCompiledInstruction, program: Program): string | null { + const ixData = Buffer.from(ix.data); + if (instructionIsSelfCPI(ixData)) { + return ANCHOR_SELF_CPI_NAME; + } + + const coder = new BorshInstructionCoder(program.idl); + const decodedIx = coder.decode(ixData); + + if (!decodedIx) { + return null; + } + + const _ixTitle = decodedIx.name; + return _ixTitle.charAt(0).toUpperCase() + _ixTitle.slice(1); +} + +async function getDefaultInstruction(ix: MessageCompiledInstruction, programId: PublicKey, clusterUrl: string): Promise { + const connection = new Connection(clusterUrl, 'confirmed'); + const accountInfo = await connection.getAccountInfo(programId); + + return { + accounts: [], + address: { + address: programId.toBase58(), + context: getAddressContext(accountInfo), + }, + data: Buffer.from(ix.data).toString('hex'), + name: getProgramName(programId.toBase58(), getCluster(clusterUrl)) ?? 'Unknown Program' + } +} + +function getCluster(clusterUrl: string): Cluster { + for (const cluster of CLUSTERS) { + if (clusterUrl.includes(clusterSlug(cluster))) { + return cluster; + } + } + return Cluster.Custom; +} + +function getProvider(url: string) { + return new AnchorProvider(new Connection(url), new NodeWallet(Keypair.generate()), {}); +} + +async function getAddressTableLookups(data: TransactionData, clusterUrl: string): Promise { + if (data.message.version === 'legacy' || data.message.addressTableLookups.length === 0) return; + + return (await Promise.all(data.message.addressTableLookups.flatMap(async lookup => { + const indexes = [ + ...lookup.writableIndexes.map(index => ({ index, readOnly: false })), + ...lookup.readonlyIndexes.map(index => ({ index, readOnly: true })), + ].sort((a, b) => (a.index - b.index)); + + const lookupTable: AddressLookupTableAccount | null = await getAddressLookupTable(lookup.accountKey.toBase58(), clusterUrl); + + return indexes.map(({ index, readOnly }) => { + return { + address: lookup.accountKey.toBase58(), + lookupTableIndex: index, + message: lookupTable ? undefined : 'Address lookup table not found', + readonly: readOnly, + resolvedAddress: lookupTable?.state.addresses[index]?.toBase58(), + } + }); + }))).flat(); +} + +async function getAddressLookupTable(address: string, clusterUrl: string): Promise { + const connection = new Connection(clusterUrl, 'confirmed'); + const result = await connection.getAddressLookupTable(new PublicKey(address)); + return result.value ?? null; +} + +async function getAccounts(data: TransactionData, clusterUrl: string): Promise { + const { numRequiredSignatures, numReadonlySignedAccounts, numReadonlyUnsignedAccounts } = data.message.header; + + const accountInfos: (AccountInfo | null)[] = await getAccountInfos(data.message.staticAccountKeys, clusterUrl); + + return data.message.staticAccountKeys.map((key, index) => ({ + address: { + address: key.toBase58(), + context: getAddressContext(accountInfos[index]), + }, + name: `Account #${index + 1}`, + readOnly: (index < numRequiredSignatures && index >= numRequiredSignatures - numReadonlySignedAccounts) || + (index >= data.message.staticAccountKeys.length - numReadonlyUnsignedAccounts), + signer: index < numRequiredSignatures, + })); +} + +async function getAccountInfos(keys: PublicKey[], clusterUrl: string): Promise<(AccountInfo | null)[]> { + const connection: Connection = new Connection(clusterUrl, 'confirmed'); + const result: (AccountInfo | null)[] = []; + const batchSize = 100; + let nextBatchStart = 0; + + while (nextBatchStart < keys.length) { + const batch = keys.slice(nextBatchStart, nextBatchStart + batchSize); + nextBatchStart += batchSize; + const batchResult = await connection.getMultipleAccountsInfo(batch); + result.push(...batchResult); + } + + return result; +} + +function getSignatures(data: TransactionData): Signature[] | undefined { + if (data.signatures == null || data.signatures.length === 0) return; + + return data.signatures + .filter((signature): signature is string => signature !== null) + .map((signature, index) => ({ + feePayer: index === 0, + signature: signature, + signer: data.message.staticAccountKeys[index].toBase58(), + verified: verifySignature(data.rawMessage, + bs58.decode(signature), + data.message.staticAccountKeys[index].toBytes()), + })); +} + +function verifySignature(message: Uint8Array, signature: Uint8Array, key: Uint8Array): boolean { + return nacl.sign.detached.verify(message, signature, key); +} + +async function getFeePayer(data: TransactionData, clusterUrl: string): Promise { + if (data.message.staticAccountKeys.length === 0) return; + + const connection = new Connection(clusterUrl, 'confirmed'); + const feePayerInfo: AccountInfo | null = await connection.getAccountInfo(data.message.staticAccountKeys[0]); + + const context = getAddressContext(feePayerInfo); + + if (!context) { + return { + address: data.message.staticAccountKeys[0].toBase58(), + } + } + + return { + address: data.message.staticAccountKeys[0].toBase58(), + context: { + ...context, + message: getMessage(feePayerInfo), + }, + } +} + +function getAddressContext(accountInfo: AccountInfo | null): AddressContext | undefined { + return accountInfo ? { + balance: accountInfo.lamports, + ownedBy: accountInfo.owner.toBase58(), + size: accountInfo.data.length, + } + : undefined; +} + +function getMessage(feePayerInfo: AccountInfo | null): string | undefined { + if (feePayerInfo == null || feePayerInfo.lamports === 0) return "Account doesn't exist"; + if (!feePayerInfo.owner.equals(SystemProgram.programId)) return 'Only system-owned accounts can pay fees'; + if (feePayerInfo.lamports < DEFAULT_FEES.lamportsPerSignature) { + return 'Insufficient funds for fees'; + } + return; +}