Skip to content
Merged
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
7 changes: 6 additions & 1 deletion components/abi-form/abi-item-form-with-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,12 @@ export function AbiItemFormWithPreview({
addresses={addresses}
/>
</div>
<div className={cn("col-span-3", showForm && "md:col-span-2")}>
<div
className={cn(
"col-span-3 min-w-0 overflow-x-auto",
showForm && "md:col-span-2",
)}
>
{data && sender && (
<SolidityCall
{...{
Expand Down
42 changes: 40 additions & 2 deletions components/contract-execution/contract-functions-list.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Search } from "lucide-react";
import { useMemo, useState } from "react";
import type { AbiFunction } from "viem";
import { cn } from "../../lib/utils.js";
import { Accordion } from "../shadcn/accordion.js";
import { Input } from "../shadcn/input.js";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../shadcn/tabs.js";
import { FunctionItem } from "./function-item.js";
import { RawOperations } from "./raw-operations.js";
import type { ContractFunctionsListProps } from "./types.js";

export function ContractFunctionsList({
Expand All @@ -18,14 +20,16 @@ export function ContractFunctionsList({
onQuery,
onWrite,
onSimulate,
onRawCall,
onRawTransaction,
addressRenderer,
onHashClick,
title,
}: ContractFunctionsListProps) {
const [searchTerm, setSearchTerm] = useState("");

const contractFunctions = useMemo(() => {
if (!abi) return [];
if (!abi || !Array.isArray(abi)) return [];
return abi.filter((item) => item.type === "function") as AbiFunction[];
}, [abi]);

Expand All @@ -46,6 +50,9 @@ export function ContractFunctionsList({
};
}, [contractFunctions, searchTerm]);

const hasRawOperations = onRawCall || onRawTransaction;
const tabCount = hasRawOperations ? 3 : 2;

return (
<div className="pb-7">
<div className="mb-4">
Expand All @@ -64,7 +71,13 @@ export function ContractFunctionsList({
</div>

<Tabs defaultValue="read" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsList
className={cn(
"grid w-full",
tabCount === 2 && "grid-cols-2",
tabCount === 3 && "grid-cols-3",
)}
>
<TabsTrigger
value="read"
className="flex cursor-pointer items-center gap-2"
Expand All @@ -83,6 +96,14 @@ export function ContractFunctionsList({
{writeFunctions.length}
</span>
</TabsTrigger>
{hasRawOperations && (
<TabsTrigger
value="raw"
className="flex cursor-pointer items-center gap-2"
>
Raw
</TabsTrigger>
)}
</TabsList>

<TabsContent value="read" className="mt-4">
Expand Down Expand Up @@ -150,6 +171,23 @@ export function ContractFunctionsList({
)}
</div>
</TabsContent>

{hasRawOperations && (
<TabsContent value="raw" className="mt-4">
<RawOperations
address={address}
chainId={chainId}
sender={sender}
addresses={addresses}
requiresConnection={requiresConnection}
isConnected={isConnected}
onRawCall={onRawCall}
onRawTransaction={onRawTransaction}
addressRenderer={addressRenderer}
onHashClick={onHashClick}
/>
</TabsContent>
)}
</Tabs>
</div>
</div>
Expand Down
7 changes: 0 additions & 7 deletions components/contract-execution/function-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,6 @@ function formatDecodedResult(result: unknown): string {
if (typeof result === "bigint") {
return result.toString();
}
if (
typeof result === "string" ||
typeof result === "number" ||
typeof result === "boolean"
) {
return String(result);
}
if (Array.isArray(result)) {
return JSON.stringify(
result,
Expand Down
7 changes: 6 additions & 1 deletion components/contract-execution/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
// biome-ignore lint/performance/noBarrelFile: This is a public API entry point
export { ContractFunctionsList } from "./contract-functions-list.js";
export { FunctionItem } from "./function-item.js";
export { RawOperations } from "./raw-operations.js";
export { DefaultResultDisplay } from "./result-display.js";
export {
ActionButtons,
ConnectWalletAlert,
MsgSenderInput,
} from "./shared-components.js";
export type { ContractFunctionsListProps, ExecutionParams } from "./types.js";
export type {
ContractFunctionsListProps,
ExecutionParams,
RawCallParams,
} from "./types.js";
252 changes: 252 additions & 0 deletions components/contract-execution/raw-operations.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useCallback, useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import type { Address } from "viem";
import { isAddress } from "viem";
import { z } from "zod";
import { AbiItemFormWithPreview } from "../abi-form/abi-item-form-with-preview.js";
import type { AddressData } from "../address-autocomplete-input.js";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "../shadcn/accordion.js";
import { Button } from "../shadcn/button.js";
import { DefaultResultDisplay } from "./result-display.js";
import { ConnectWalletAlert, MsgSenderInput } from "./shared-components.js";
import type { RawCallParams } from "./types.js";

type InternalResult = {
type: "call" | "execution" | "error";
data?: string;
hash?: string;
cleanResult?: string;
error?: string;
};

const executionFormSchema = z.object({
msgSender: z
.string()
.refine(
(val) => {
if (!val) return true;
return isAddress(val);
},
{ message: "Invalid address format" },
)
.optional(),
});

interface RawOperationsProps {
address: Address;
chainId: number;
sender?: Address;
addresses?: AddressData[];
requiresConnection: boolean;
isConnected: boolean;
onRawCall?: (params: RawCallParams) => Promise<`0x${string}`>;
onRawTransaction?: (params: RawCallParams) => Promise<`0x${string}`>;
addressRenderer?: (address: Address) => React.ReactNode;
onHashClick?: (hash: string) => void;
}

export function RawOperations({
address,
chainId,
sender,
addresses,
requiresConnection,
isConnected,
onRawCall,
onRawTransaction,
addressRenderer,
onHashClick,
}: RawOperationsProps) {
return (
<div className="rounded-lg border bg-card">
<Accordion type="multiple" className="w-full">
{onRawCall && (
<RawOperationItem
type="call"
address={address}
chainId={chainId}
sender={sender}
addresses={addresses}
requiresConnection={requiresConnection}
isConnected={isConnected}
onExecute={onRawCall}
addressRenderer={addressRenderer}
onHashClick={onHashClick}
/>
)}
{onRawTransaction && (
<RawOperationItem
type="transaction"
address={address}
chainId={chainId}
sender={sender}
addresses={addresses}
requiresConnection={requiresConnection}
isConnected={isConnected}
onExecute={onRawTransaction}
addressRenderer={addressRenderer}
onHashClick={onHashClick}
/>
)}
</Accordion>
</div>
);
}

interface RawOperationItemProps {
type: "call" | "transaction";
address: Address;
chainId: number;
sender?: Address;
addresses?: AddressData[];
requiresConnection: boolean;
isConnected: boolean;
onExecute: (params: RawCallParams) => Promise<`0x${string}`>;
addressRenderer?: (address: Address) => React.ReactNode;
onHashClick?: (hash: string) => void;
}

function RawOperationItem({
type,
address,
chainId,
sender,
addresses,
requiresConnection,
isConnected,
onExecute,
addressRenderer,
onHashClick,
}: RawOperationItemProps) {
const [callData, setCallData] = useState<string>("");
const [value, setValue] = useState<bigint | undefined>();
const [result, setResult] = useState<InternalResult | null>(null);
const [isExecuting, setIsExecuting] = useState(false);

const form = useForm({
mode: "onChange",
resolver: zodResolver(executionFormSchema),
defaultValues: {
msgSender: "",
},
});

const msgSender = form.watch().msgSender || "";

const isWrite = type === "transaction";
const title = type === "call" ? "Raw Call" : "Raw Transaction";
const description =
type === "call"
? "Execute eth_call with arbitrary calldata"
: "Send transaction with arbitrary calldata";

const handleCallDataChange = useCallback(
({ data, value: newValue }: { data?: `0x${string}`; value?: bigint }) => {
setCallData(data || "");
setValue(newValue);
},
[],
);

const handleExecute = async () => {
if (!callData) return;
setIsExecuting(true);
try {
const result = await onExecute({
data: callData as `0x${string}`,
value,
msgSender: msgSender ? (msgSender as Address) : undefined,
});

if (isWrite) {
setResult({
type: "execution",
hash: result,
cleanResult: "Transaction submitted",
});
} else {
setResult({
type: "call",
data: result,
cleanResult: result,
});
}
} catch (error) {
setResult({
type: "error",
error: error instanceof Error ? error.message : "Unknown error",
});
} finally {
setIsExecuting(false);
}
};

return (
<AccordionItem
value={type}
className="border-border border-b last:border-b-0"
>
<AccordionTrigger className="cursor-pointer p-3 text-left hover:no-underline">
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{title}</span>
<span className="text-muted-foreground text-xs">{description}</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-3 pb-3">
<FormProvider {...form}>
<div className="mt-4 space-y-6">
{isWrite && <MsgSenderInput />}

{isWrite && requiresConnection && !isConnected && (
<ConnectWalletAlert />
)}

<AbiItemFormWithPreview
addresses={addresses}
onChange={handleCallDataChange}
abiFunction={type === "call" ? "rawCall" : "raw"}
address={address}
sender={sender || address}
chainId={chainId}
ArgProps={
addressRenderer
? {
addressRenderer,
}
: undefined
}
/>

<div className="flex flex-row items-center justify-center gap-2">
<Button
onClick={handleExecute}
disabled={!callData || isExecuting || (isWrite && !isConnected)}
className="w-fit"
>
{isExecuting
? "Executing..."
: type === "call"
? "Call"
: "Send Transaction"}
</Button>
</div>

{result && (
<DefaultResultDisplay
key={`${result.type}-${result.data}`}
result={result}
onHashClick={onHashClick}
/>
)}
</div>
</FormProvider>
</AccordionContent>
</AccordionItem>
);
}
Loading