Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added /tx/inspector route to return transaction data #421

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions app/api/tx/inspector/route.ts
Original file line number Diff line number Diff line change
@@ -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));
}


3 changes: 2 additions & 1 deletion app/components/account/AnchorAccountCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down
15 changes: 4 additions & 11 deletions app/components/inspector/InspectorPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
10 changes: 1 addition & 9 deletions app/components/inspector/RawInputCard.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions app/components/instruction/AnchorDetailsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
19 changes: 2 additions & 17 deletions app/providers/cluster.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<State | undefined>(undefined);
const DispatchContext = createContext<Dispatch | undefined>(undefined);
Expand Down
43 changes: 2 additions & 41 deletions app/utils/anchor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down
Loading